@relayfile/local-mount 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ Builds a mounted copy of `projectDir` at `mountDir` and returns a handle:
19
19
  ```ts
20
20
  interface SymlinkMountHandle {
21
21
  mountDir: string;
22
- syncBack(): Promise<number>;
22
+ syncBack(opts?: { signal?: AbortSignal }): Promise<number>;
23
23
  startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
24
24
  cleanup(): void;
25
25
  }
@@ -62,6 +62,8 @@ High-level helper that:
62
62
 
63
63
  It resolves with the child process exit code. `onAfterSync(count)` receives the sum of files changed by auto-sync plus the final sync-back pass.
64
64
 
65
+ `launchOnMount({ shutdownSignal })` threads an optional `AbortSignal` into the shutdown phase only. It does not cancel the spawned CLI. If shutdown is aborted, `onAfterSync` still fires with the partial count gathered so far and the mount directory is still cleaned up.
66
+
65
67
  ### Auto-sync
66
68
 
67
69
  By default, `launchOnMount` keeps the mount and project directory in sync continuously while the CLI is running, rather than only at exit. The same machinery is available standalone via `handle.startAutoSync()`.
@@ -70,15 +72,15 @@ By default, `launchOnMount` keeps the mount and project directory in sync contin
70
72
  interface AutoSyncOptions {
71
73
  /** Full-reconcile interval as a safety net. Default: 10_000 ms. */
72
74
  scanIntervalMs?: number;
73
- /** chokidar `awaitWriteFinish` stability threshold. Default: 200 ms. */
74
- writeFinishMs?: number;
75
+ /** Per-path event debounce in ms. Default: 50 ms. */
76
+ debounceMs?: number;
75
77
  /** Invoked on sync errors. Defaults to swallowing them. */
76
78
  onError?: (err: Error) => void;
77
79
  }
78
80
 
79
81
  interface AutoSyncHandle {
80
- stop(): Promise<void>;
81
- reconcile(): Promise<number>;
82
+ stop(opts?: { signal?: AbortSignal }): Promise<void>;
83
+ reconcile(opts?: { signal?: AbortSignal }): Promise<number>;
82
84
  totalChanges(): number;
83
85
  ready(): Promise<void>;
84
86
  }
@@ -91,12 +93,13 @@ Control it from `launchOnMount`:
91
93
  launchOnMount({ /* ... */, autoSync: false });
92
94
 
93
95
  // Tune it.
94
- launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, writeFinishMs: 100 } });
96
+ launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 } });
95
97
  ```
96
98
 
97
99
  How it works:
98
- - chokidar watches both the mount and the project tree
100
+ - [@parcel/watcher](https://www.npmjs.com/package/@parcel/watcher) watches both the mount and the project tree using native FSEvents/inotify/ReadDirectoryChangesW
99
101
  - every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events
102
+ - `stop({ signal })` still closes watchers if aborted, but skips the final draining reconcile
100
103
  - per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed
101
104
 
102
105
  Conflict and delete rules:
@@ -154,6 +157,7 @@ import os from 'node:os';
154
157
 
155
158
  const projectDir = '/projects/acme-api';
156
159
  const mountDir = path.join(os.tmpdir(), 'acme-api-agent-mount');
160
+ const abortController = new AbortController();
157
161
 
158
162
  const { ignoredPatterns, readonlyPatterns } = readAgentDotfiles(projectDir, {
159
163
  agentName: 'reviewer',
@@ -172,6 +176,7 @@ const result = await launchOnMount({
172
176
  // Add extra instructions or scratch files inside the mount if needed.
173
177
  console.log(`Mount ready at ${dir}`);
174
178
  },
179
+ shutdownSignal: abortController.signal,
175
180
  onAfterSync: async (count) => {
176
181
  console.log(`Synced ${count} writable file(s) back to the project`);
177
182
  },
@@ -193,6 +198,10 @@ console.log(result.exitCode);
193
198
 
194
199
  The returned number is the count of files written back to `projectDir` in that pass. `syncBack()` never deletes — delete propagation is handled by auto-sync.
195
200
 
201
+ `syncBack({ signal })` checks for aborts between files. If the signal aborts during shutdown, it returns the partial count accumulated so far instead of throwing, which lets callers report a partial sync and still run cleanup.
202
+
203
+ `reconcile({ signal })` and the internal tree walk also poll for aborts between file visits so an in-flight draining scan can stop cooperatively.
204
+
196
205
  ## Safety constraints
197
206
 
198
207
  The implementation is intentionally conservative about `mountDir`:
@@ -5,7 +5,8 @@ export interface AutoSyncContext {
5
5
  /**
6
6
  * Directory-only ignore patterns (ending in `/`) must only match when the
7
7
  * path is a directory. Callers that know the path's type pass `isDirectory`;
8
- * callers that don't (chokidar's prune filter) should check both forms.
8
+ * callers that don't should omit the second argument and fall back to the
9
+ * file-form check.
9
10
  */
10
11
  isIgnored: (relPosix: string, isDirectory?: boolean) => boolean;
11
12
  isReadonly: (relPosix: string) => boolean;
@@ -14,15 +15,22 @@ export interface AutoSyncContext {
14
15
  export interface AutoSyncOptions {
15
16
  /** Full-reconcile interval as a safety net. Default: 10_000ms. */
16
17
  scanIntervalMs?: number;
17
- /** chokidar awaitWriteFinish stabilityThreshold in ms. Default: 200. */
18
- writeFinishMs?: number;
18
+ /**
19
+ * Per-path event debounce in ms. Rapid watcher events for the same path
20
+ * are coalesced into a single sync. Default: 50.
21
+ */
22
+ debounceMs?: number;
19
23
  /** Invoked on errors during sync — logged by default consumer. */
20
24
  onError?: (err: Error) => void;
21
25
  }
22
26
  export interface AutoSyncHandle {
23
- stop(): Promise<void>;
27
+ stop(opts?: {
28
+ signal?: AbortSignal;
29
+ }): Promise<void>;
24
30
  /** Force a reconcile now; returns number of files copied/deleted. */
25
- reconcile(): Promise<number>;
31
+ reconcile(opts?: {
32
+ signal?: AbortSignal;
33
+ }): Promise<number>;
26
34
  /** Cumulative files changed (copied or deleted) since autosync started. */
27
35
  totalChanges(): number;
28
36
  /** Resolves once both watchers have completed their initial scan. */
package/dist/auto-sync.js CHANGED
@@ -1,16 +1,22 @@
1
1
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
- import chokidar from 'chokidar';
3
+ import watcher from '@parcel/watcher';
4
4
  export function startAutoSync(ctx, opts = {}) {
5
5
  const scanIntervalMs = opts.scanIntervalMs ?? 10_000;
6
- const writeFinishMs = opts.writeFinishMs ?? 200;
6
+ const debounceMs = opts.debounceMs ?? 50;
7
7
  const onError = opts.onError ?? (() => { });
8
8
  const state = new Map();
9
9
  primeState(state, ctx);
10
10
  let syncing = false;
11
11
  let pending = false;
12
+ let stopped = false;
12
13
  let totalChanges = 0;
13
- const runReconcile = async () => {
14
+ const pendingDebounces = new Map();
15
+ const runReconcile = async (opts) => {
16
+ const signal = opts?.signal;
17
+ if (signal?.aborted) {
18
+ return 0;
19
+ }
14
20
  if (syncing) {
15
21
  pending = true;
16
22
  return 0;
@@ -18,7 +24,7 @@ export function startAutoSync(ctx, opts = {}) {
18
24
  syncing = true;
19
25
  let count = 0;
20
26
  try {
21
- count = reconcile(state, ctx, onError);
27
+ count = reconcile(state, ctx, onError, signal);
22
28
  }
23
29
  catch (err) {
24
30
  onError(err);
@@ -26,10 +32,10 @@ export function startAutoSync(ctx, opts = {}) {
26
32
  finally {
27
33
  syncing = false;
28
34
  }
29
- if (pending) {
35
+ if (pending && !signal?.aborted) {
30
36
  pending = false;
31
37
  try {
32
- count += reconcile(state, ctx, onError);
38
+ count += reconcile(state, ctx, onError, signal);
33
39
  }
34
40
  catch (err) {
35
41
  onError(err);
@@ -54,43 +60,101 @@ export function startAutoSync(ctx, opts = {}) {
54
60
  onError(err);
55
61
  }
56
62
  };
57
- const makeWatcher = (root) => {
58
- const watcher = chokidar.watch(root, {
59
- ignoreInitial: true,
60
- persistent: true,
61
- followSymlinks: false,
62
- awaitWriteFinish: {
63
- stabilityThreshold: writeFinishMs,
64
- pollInterval: 50,
65
- },
66
- ignored: (candidate, stats) => shouldChokidarIgnore(candidate, root, ctx, stats),
67
- });
68
- const onEvent = (p) => syncPathFromRoot(root, p);
69
- watcher.on('add', onEvent);
70
- watcher.on('change', onEvent);
71
- watcher.on('unlink', onEvent);
72
- watcher.on('error', (err) => onError(err));
73
- const ready = new Promise((resolve) => {
74
- watcher.once('ready', () => resolve());
75
- });
76
- return { watcher, ready };
63
+ const schedulePathSync = (root, absPath) => {
64
+ // Once stop() has begun, refuse to schedule new timers. Watcher
65
+ // callbacks can still fire during the unsubscribe await (native
66
+ // backends deliver queued events asynchronously); without this guard
67
+ // those events would create timers that outlive stop() and do file
68
+ // work against a mount the caller may have already cleaned up.
69
+ if (stopped)
70
+ return;
71
+ // Coalesce bursts of events for the same path. The reconcile path
72
+ // re-checks content via mtime+bytes, so a partial-write event that
73
+ // races a later write is harmless.
74
+ const existing = pendingDebounces.get(absPath);
75
+ if (existing)
76
+ clearTimeout(existing);
77
+ const t = setTimeout(() => {
78
+ pendingDebounces.delete(absPath);
79
+ syncPathFromRoot(root, absPath);
80
+ }, debounceMs);
81
+ pendingDebounces.set(absPath, t);
77
82
  };
78
- const mount = makeWatcher(ctx.realMountDir);
79
- const project = makeWatcher(ctx.realProjectDir);
80
- const mountWatcher = mount.watcher;
81
- const projectWatcher = project.watcher;
82
- const watchersReady = Promise.all([mount.ready, project.ready]);
83
+ const ignoreGlobs = buildIgnoreGlobs(ctx);
84
+ const subscribeTo = (root) => watcher.subscribe(root, (err, events) => {
85
+ if (err) {
86
+ onError(err);
87
+ return;
88
+ }
89
+ for (const ev of events) {
90
+ schedulePathSync(root, ev.path);
91
+ }
92
+ }, { ignore: ignoreGlobs });
93
+ let mountSub;
94
+ let projectSub;
95
+ // Subscribe in parallel but track each outcome independently. With
96
+ // Promise.all, a failure on one side would reject before the other's
97
+ // assignment ran and leak the succeeded subscription. allSettled lets us
98
+ // tear down whichever fulfilled before re-throwing the first failure.
99
+ const watchersReady = (async () => {
100
+ const [mountResult, projectResult] = await Promise.allSettled([
101
+ subscribeTo(ctx.realMountDir),
102
+ subscribeTo(ctx.realProjectDir),
103
+ ]);
104
+ if (mountResult.status === 'fulfilled')
105
+ mountSub = mountResult.value;
106
+ if (projectResult.status === 'fulfilled')
107
+ projectSub = projectResult.value;
108
+ if (mountResult.status === 'fulfilled' && projectResult.status === 'fulfilled') {
109
+ return;
110
+ }
111
+ await Promise.allSettled([
112
+ mountSub?.unsubscribe(),
113
+ projectSub?.unsubscribe(),
114
+ ]);
115
+ mountSub = undefined;
116
+ projectSub = undefined;
117
+ throw mountResult.status === 'rejected'
118
+ ? mountResult.reason
119
+ : projectResult.reason;
120
+ })();
121
+ // If subscription setup fails, surface via onError rather than an unhandled
122
+ // rejection. stop() still awaits the same promise and will observe the
123
+ // rejection after the cleanup above has already run.
124
+ watchersReady.catch((err) => onError(err));
83
125
  const interval = setInterval(() => {
84
126
  void runReconcile();
85
127
  }, scanIntervalMs);
86
128
  // Do not keep the event loop alive just because of our scan timer.
87
129
  interval.unref?.();
88
130
  return {
89
- async stop() {
131
+ async stop(opts) {
132
+ // Flip the flag first so any watcher callbacks delivered during the
133
+ // awaits below refuse to schedule new timers.
134
+ stopped = true;
90
135
  clearInterval(interval);
91
- await Promise.all([mountWatcher.close(), projectWatcher.close()]);
136
+ try {
137
+ await watchersReady;
138
+ }
139
+ catch {
140
+ // Setup failed and already cleaned up any partial subscription;
141
+ // mountSub / projectSub were reset to undefined before the throw.
142
+ }
143
+ await Promise.allSettled([
144
+ mountSub?.unsubscribe(),
145
+ projectSub?.unsubscribe(),
146
+ ]);
147
+ // Clear debounces *after* unsubscribe resolves: any timer scheduled
148
+ // between stop() being called and the watcher actually quiescing is
149
+ // gathered here, so none fire after stop() returns.
150
+ for (const t of pendingDebounces.values())
151
+ clearTimeout(t);
152
+ pendingDebounces.clear();
153
+ if (opts?.signal?.aborted) {
154
+ return;
155
+ }
92
156
  // Drain any pending work so callers can rely on "stopped means quiesced".
93
- await runReconcile();
157
+ await runReconcile(opts);
94
158
  },
95
159
  reconcile: runReconcile,
96
160
  totalChanges: () => totalChanges,
@@ -99,6 +163,23 @@ export function startAutoSync(ctx, opts = {}) {
99
163
  },
100
164
  };
101
165
  }
166
+ function buildIgnoreGlobs(ctx) {
167
+ // @parcel/watcher matches globs against absolute paths via globset. For each
168
+ // excluded directory name, ignore both the directory itself and everything
169
+ // beneath it, anywhere under the watched root. The `isExcluded` predicate is
170
+ // driven by a Set of directory names, so we probe a small set of common
171
+ // exclusions rather than introspecting it. The in-handler `isSyncCandidate`
172
+ // filter is authoritative — this is just a perf hint so the watcher doesn't
173
+ // recurse into heavy trees like node_modules or .git.
174
+ const globs = [];
175
+ const candidates = ['.git', 'node_modules', 'dist', 'build', '.next', '.cache'];
176
+ for (const name of candidates) {
177
+ if (ctx.isExcluded(name)) {
178
+ globs.push(`**/${name}`, `**/${name}/**`);
179
+ }
180
+ }
181
+ return globs;
182
+ }
102
183
  function primeState(state, ctx) {
103
184
  // Record current mtimes for every file that exists in both trees with the
104
185
  // same content. Files that differ are left out so the first reconcile sees
@@ -124,7 +205,7 @@ function primeState(state, ctx) {
124
205
  });
125
206
  });
126
207
  }
127
- function reconcile(state, ctx, onError) {
208
+ function reconcile(state, ctx, onError, signal) {
128
209
  const seen = new Set();
129
210
  let count = 0;
130
211
  const visit = (relPosix) => {
@@ -143,15 +224,25 @@ function reconcile(state, ctx, onError) {
143
224
  }
144
225
  };
145
226
  walk(ctx.realMountDir, ctx, (abs) => {
227
+ if (signal?.aborted)
228
+ return;
146
229
  const rel = toRelPosix(abs, ctx);
147
230
  if (rel !== null)
148
231
  visit(rel);
149
- });
232
+ }, signal);
233
+ if (signal?.aborted) {
234
+ return count;
235
+ }
150
236
  walk(ctx.realProjectDir, ctx, (abs) => {
237
+ if (signal?.aborted)
238
+ return;
151
239
  const rel = toRelPosixFromProject(abs, ctx);
152
240
  if (rel !== null)
153
241
  visit(rel);
154
- });
242
+ }, signal);
243
+ if (signal?.aborted) {
244
+ return count;
245
+ }
155
246
  // Tombstone sweep: any path in state we didn't visit had both sides absent,
156
247
  // so it's fully gone.
157
248
  for (const rel of Array.from(state.keys())) {
@@ -414,9 +505,11 @@ function resolveSafeWriteTarget(root, candidate) {
414
505
  return null;
415
506
  }
416
507
  }
417
- function walk(root, ctx, visit) {
508
+ function walk(root, ctx, visit, signal) {
418
509
  const stack = [root];
419
510
  while (stack.length > 0) {
511
+ if (signal?.aborted)
512
+ return;
420
513
  const cur = stack.pop();
421
514
  if (!cur)
422
515
  continue;
@@ -428,6 +521,8 @@ function walk(root, ctx, visit) {
428
521
  continue;
429
522
  }
430
523
  for (const entry of entries) {
524
+ if (signal?.aborted)
525
+ return;
431
526
  const abs = path.join(cur, entry.name);
432
527
  const rel = path.relative(root, abs).split(path.sep).join('/');
433
528
  if (!rel || rel.startsWith('..'))
@@ -443,24 +538,3 @@ function walk(root, ctx, visit) {
443
538
  }
444
539
  }
445
540
  }
446
- function shouldChokidarIgnore(candidate, root, ctx, stats) {
447
- if (candidate === root)
448
- return false;
449
- const rel = path.relative(root, candidate);
450
- if (rel === '' || rel.startsWith('..'))
451
- return false;
452
- const relPosix = rel.split(path.sep).join('/');
453
- if (ctx.isExcluded(relPosix))
454
- return true;
455
- if (ctx.isReservedFile(relPosix))
456
- return true;
457
- // chokidar calls this filter twice: first without stats (pre-stat prune),
458
- // then again with stats once it knows the entry type. Only apply the
459
- // directory-form match when we have stats confirming it's a directory,
460
- // otherwise a directory-only pattern like `cache/` would wrongly prune a
461
- // same-named file.
462
- if (stats) {
463
- return ctx.isIgnored(relPosix, stats.isDirectory());
464
- }
465
- return ctx.isIgnored(relPosix);
466
- }
package/dist/launch.d.ts CHANGED
@@ -18,6 +18,14 @@ export interface LaunchOnMountOptions {
18
18
  env?: NodeJS.ProcessEnv;
19
19
  /** Optional agent name, used in the _MOUNT_README.md "Agent:" line. */
20
20
  agentName?: string;
21
+ /**
22
+ * Optional signal used during shutdown work after the child exits.
23
+ * It is passed to both autosync shutdown and the final mount→project sync-back.
24
+ * If aborted, autosync stop may skip its draining reconcile and sync-back
25
+ * returns the partial count accumulated so far, so fewer changes may be
26
+ * propagated than during an uninterrupted shutdown.
27
+ */
28
+ shutdownSignal?: AbortSignal;
21
29
  /**
22
30
  * Invoked after the mount is created but before the CLI is spawned.
23
31
  * Useful for writing additional files into the mount (overrides, extra docs).
package/dist/launch.js CHANGED
@@ -23,11 +23,11 @@ export async function launchOnMount(opts) {
23
23
  try {
24
24
  let autoSyncChanges = 0;
25
25
  if (autoSync) {
26
- await autoSync.stop();
26
+ await autoSync.stop({ signal: opts.shutdownSignal });
27
27
  autoSyncChanges = autoSync.totalChanges();
28
28
  autoSync = undefined;
29
29
  }
30
- const finalSynced = await handle.syncBack();
30
+ const finalSynced = await handle.syncBack({ signal: opts.shutdownSignal });
31
31
  syncedCount = autoSyncChanges + finalSynced;
32
32
  if (opts.onAfterSync) {
33
33
  await opts.onAfterSync(syncedCount);
@@ -11,11 +11,13 @@ export interface SymlinkMountOptions {
11
11
  }
12
12
  export interface SymlinkMountHandle {
13
13
  mountDir: string;
14
- syncBack(): Promise<number>;
14
+ syncBack(opts?: {
15
+ signal?: AbortSignal;
16
+ }): Promise<number>;
15
17
  /**
16
18
  * Start bidirectional auto-sync: watches both the mount and project trees
17
- * with chokidar and runs a full reconcile every `scanIntervalMs` as a
18
- * safety net. Returns a handle you must `stop()` before teardown.
19
+ * via @parcel/watcher and runs a full reconcile every `scanIntervalMs`
20
+ * as a safety net. Returns a handle you must `stop()` before teardown.
19
21
  */
20
22
  startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
21
23
  cleanup(): void;
@@ -46,13 +46,21 @@ export function createSymlinkMount(projectDir, mountDir, options) {
46
46
  };
47
47
  return {
48
48
  mountDir: resolvedMountDir,
49
- async syncBack() {
49
+ async syncBack(opts) {
50
50
  let synced = 0;
51
51
  const realProjectDir = realpathSync(resolvedProjectDir);
52
52
  const realMountDir = realpathSync(resolvedMountDir);
53
53
  const files = listFiles(realMountDir);
54
+ const signal = opts?.signal;
54
55
  for (const sourceFile of files) {
55
- synced += syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher);
56
+ if (signal?.aborted) {
57
+ break;
58
+ }
59
+ const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher);
60
+ synced += syncedForFile;
61
+ if (signal && syncedForFile > 0 && !signal.aborted) {
62
+ await new Promise((resolve) => setImmediate(resolve));
63
+ }
56
64
  }
57
65
  return synced;
58
66
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "npm run build"
16
16
  },
17
17
  "dependencies": {
18
- "chokidar": "^4.0.3",
18
+ "@parcel/watcher": "^2.5.6",
19
19
  "ignore": "^7.0.5"
20
20
  },
21
21
  "devDependencies": {