Bidirectional autosync for local-mount#49
Conversation
Introduce a new autosync subsystem to keep mount↔project files in sync. Adds packages/local-mount/src/auto-sync.ts implementing a chokidar-based watcher + periodic reconcile with mount-wins resolution, readonly/ignored/excluded handling, safe write targets, and lifecycle APIs (start/stop/reconcile/ready/totalChanges). Exposes AutoSync types from the package index and wires a startAutoSync() method into the symlink mount handle. Integrates auto-sync into the launch flow via an autoSync option that runs during the spawned CLI and aggregates auto-sync changes into final sync counts. Adds comprehensive tests (auto-sync.test.ts) and records the chokidar dependency in package.json.
Update packages/local-mount/README.md to document the new auto-sync functionality and API surface. Adds startAutoSync to SymlinkMountHandle and describes AutoSyncOptions and AutoSyncHandle. Updates launchOnMount behavior to start/stop bidirectional auto-sync (or disable it), documents onAfterSync semantics, outlines how auto-sync works (chokidar + periodic reconcile, mtime tracking), lists conflict/delete rules and readonly/ignored path behavior, and clarifies that syncBack is a one-shot mount-only pass that writes files (never deletes).
There was a problem hiding this comment.
Pull request overview
Adds bidirectional, in-process autosync to @relayfile/local-mount so changes propagate continuously during a CLI run rather than only syncing back on exit.
Changes:
- Introduces a new
startAutoSync()API onSymlinkMountHandleimplemented viachokidarwatchers plus a periodic full reconcile. - Extends
launchOnMountwith anautoSyncoption (default enabled) to run autosync for the lifetime of the spawned CLI. - Adds Vitest coverage for autosync behavior and adds
chokidaras a dependency.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/local-mount/src/symlink-mount.ts | Plumbs autosync context + exposes startAutoSync() on the mount handle. |
| packages/local-mount/src/launch.ts | Starts/stops autosync around the spawned CLI; aggregates sync counts. |
| packages/local-mount/src/index.ts | Re-exports autosync public types. |
| packages/local-mount/src/auto-sync.ts | New autosync implementation (watchers + reconcile + conflict rules). |
| packages/local-mount/src/auto-sync.test.ts | New tests for bidirectional syncing, readonly/ignored behavior, and reserved files. |
| packages/local-mount/package.json | Adds chokidar dependency. |
| package-lock.json | Locks chokidar and readdirp versions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let autoSyncChanges = 0; | ||
| if (autoSync) { | ||
| await autoSync.stop(); | ||
| autoSyncChanges = autoSync.totalChanges(); | ||
| autoSync = undefined; | ||
| } | ||
| const finalSynced = await handle.syncBack(); | ||
| syncedCount = autoSyncChanges + finalSynced; |
There was a problem hiding this comment.
Updated the doc comment in 96e690c to reflect that syncedFileCount is the total across both directions (autosync activity + final syncBack) rather than a strict mount→project count. The broader meaning is more useful as an "anything changed?" signal for callers — kept the aggregation but realigned the contract.
| function primeState(state: Map<string, FileState>, ctx: AutoSyncContext): void { | ||
| // Record current mtimes for every file that exists in both trees. Files that | ||
| // differ, or exist on only one side, are left out so the first reconcile | ||
| // treats them as changed and syncs them. | ||
| walk(ctx.realMountDir, ctx, (abs) => { | ||
| const rel = toRelPosix(abs, ctx); | ||
| if (rel === null) return; | ||
| if (!isSyncCandidate(rel, ctx)) return; | ||
| const mountStat = safeFileStat(abs); | ||
| if (!mountStat) return; | ||
| const projectAbs = path.join(ctx.realProjectDir, rel); | ||
| const projectStat = safeFileStat(projectAbs); | ||
| if (!projectStat) return; | ||
| state.set(rel, { | ||
| mountMtimeMs: mountStat.mtimeMs, | ||
| projectMtimeMs: projectStat.mtimeMs, | ||
| }); |
There was a problem hiding this comment.
Good catch — fixed in 96e690c by adding a sameContent check before seeding state. Files that differ at startup are now left out, so the first reconcile sees no prev entry and goes through the content-based resolution path.
| const changed = syncOneFile(relPosix, state, ctx); | ||
| if (changed) count += 1; |
There was a problem hiding this comment.
Fixed in 96e690c. visit() now wraps syncOneFile in try/catch and forwards errors to onError, so one bad path no longer aborts the whole reconcile pass. reconcile() takes onError as a parameter.
| const target = resolveSafeWriteTarget(ctx.realProjectDir, projectAbs); | ||
| if (!target) return false; | ||
| if (existsSync(target) && sameContent(mountAbs, target)) { | ||
| updateState(state, relPosix, mountAbs, target); | ||
| return false; | ||
| } | ||
| copyFileSync(mountAbs, target); | ||
| updateState(state, relPosix, mountAbs, target); |
There was a problem hiding this comment.
Fixed in 96e690c. Added an isSymlinkTarget check in both doMountToProject and doProjectToMount — if the target already exists as a symlink, we refuse to write through it. Matches syncBacks behavior and blocks writes from escaping the root.
| ? prev?.mountMtimeMs === undefined || mountStat.mtimeMs > prev.mountMtimeMs | ||
| : false; | ||
| const projectChanged = projectStat | ||
| ? prev?.projectMtimeMs === undefined || projectStat.mtimeMs > prev.projectMtimeMs |
There was a problem hiding this comment.
Fixed in 96e690c — switched both mountChanged and projectChanged to !==. Coarse-resolution or backdated writes no longer get silently skipped during periodic reconciles.
noodleonthecape
left a comment
There was a problem hiding this comment.
Nice change. I pulled this locally, ran the @relayfile/local-mount typecheck and test suite, and spot-checked the auto-sync logic plus coverage. The mount/project conflict handling, readonly behavior, reserved-file exclusions, and launch lifecycle all look solid to me.
- primeState now skips files whose content differs at startup, so the first reconcile actually resolves initial drift (previously seeded matching mtimes and then suppressed the sync) - reconcile() isolates per-path errors so one bad path no longer aborts the whole pass; errors are surfaced via onError - doMountToProject / doProjectToMount refuse to write through a target that already exists as a symlink, matching syncBack's behavior and preventing writes from escaping the root - mtime change detection uses !== instead of > so coarse-resolution or backdated writes aren't silently missed during periodic reconciles - Clarified onAfterSync doc: the count is now total changes in both directions (autosync + final syncBack), not a strict mount→project tally Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AutoSyncContext.isIgnored's lambda in symlink-mount always OR'd the file-form and directory-form match, so a regular file whose path matched a directory-only pattern (e.g. `cache/`) was incorrectly ignored by autosync. The initial mount walk already distinguished the two via `entry.isDirectory()`, so the bug only affected the autosync path. - AutoSyncContext.isIgnored now accepts an optional `isDirectory` flag; symlink-mount constructs it as a thin pass-through to `isPathMatched` - walk() in auto-sync.ts passes `entry.isDirectory()` to isIgnored - isSyncCandidate() keeps the default (file), matching its callers - shouldChokidarIgnore() uses chokidar's optional `stats` second arg when available so dir-form matches are only applied to actual directories. Without stats (pre-stat prune call) we fall back to file-form to avoid false-positive pruning; chokidar calls the filter a second time with stats so pruning still happens at the directory level - Regression test covers `cache/` pattern with a file at `docs/cache` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
@relayfile/local-mountauto-sync: chokidar watchers on both the mount and project trees plus a periodic full-scan safety net, so edits propagate continuously during a CLI run instead of only at exit.startAutoSync()on the mount handle and anautoSyncoption onlaunchOnMount(enabled by default; passfalseto opt out or an options object to tunescanIntervalMs/writeFinishMs).Test plan
npx tsc --noEmitinpackages/local-mountnpx vitest runinpackages/local-mount(21/21 pass, including 10 new autosync tests)launchOnMountand verify edits from the agent show up in the project dir while the CLI is still running🤖 Generated with Claude Code