agent-relay 4.0.31 → 4.0.33

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.
Files changed (140) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +210 -75
  6. package/dist/src/cli/commands/on/start.d.ts.map +1 -1
  7. package/dist/src/cli/commands/on/start.js +149 -111
  8. package/dist/src/cli/commands/on/start.js.map +1 -1
  9. package/node_modules/@agent-relay/cloud/package.json +2 -2
  10. package/node_modules/@agent-relay/config/package.json +1 -1
  11. package/node_modules/@agent-relay/hooks/package.json +4 -4
  12. package/node_modules/@agent-relay/sdk/README.md +3 -0
  13. package/node_modules/@agent-relay/sdk/dist/relay.d.ts +16 -0
  14. package/node_modules/@agent-relay/sdk/dist/relay.d.ts.map +1 -1
  15. package/node_modules/@agent-relay/sdk/dist/relay.js +83 -0
  16. package/node_modules/@agent-relay/sdk/dist/relay.js.map +1 -1
  17. package/node_modules/@agent-relay/sdk/package.json +2 -2
  18. package/node_modules/@agent-relay/telemetry/package.json +1 -1
  19. package/node_modules/@agent-relay/trajectory/package.json +2 -2
  20. package/node_modules/@agent-relay/user-directory/package.json +2 -2
  21. package/node_modules/@agent-relay/utils/package.json +2 -2
  22. package/node_modules/@relayfile/local-mount/README.md +215 -0
  23. package/node_modules/@relayfile/local-mount/dist/auto-sync.d.ts +31 -0
  24. package/node_modules/@relayfile/local-mount/dist/auto-sync.js +466 -0
  25. package/node_modules/@relayfile/local-mount/dist/dotfiles.d.ts +18 -0
  26. package/node_modules/@relayfile/local-mount/dist/dotfiles.js +43 -0
  27. package/node_modules/@relayfile/local-mount/dist/index.d.ts +4 -0
  28. package/node_modules/@relayfile/local-mount/dist/index.js +3 -0
  29. package/node_modules/@relayfile/local-mount/dist/launch.d.ts +50 -0
  30. package/node_modules/@relayfile/local-mount/dist/launch.js +129 -0
  31. package/node_modules/@relayfile/local-mount/dist/symlink-mount.d.ts +23 -0
  32. package/{dist/src/cli/commands/on → node_modules/@relayfile/local-mount/dist}/symlink-mount.js +88 -20
  33. package/node_modules/@relayfile/local-mount/node_modules/chokidar/LICENSE +21 -0
  34. package/node_modules/@relayfile/local-mount/node_modules/chokidar/README.md +305 -0
  35. package/node_modules/@relayfile/local-mount/node_modules/chokidar/esm/handler.d.ts +90 -0
  36. package/node_modules/@relayfile/local-mount/node_modules/chokidar/esm/handler.js +629 -0
  37. package/node_modules/@relayfile/local-mount/node_modules/chokidar/esm/index.d.ts +215 -0
  38. package/node_modules/@relayfile/local-mount/node_modules/chokidar/esm/index.js +798 -0
  39. package/node_modules/@relayfile/local-mount/node_modules/chokidar/esm/package.json +1 -0
  40. package/node_modules/@relayfile/local-mount/node_modules/chokidar/handler.d.ts +90 -0
  41. package/node_modules/@relayfile/local-mount/node_modules/chokidar/handler.js +635 -0
  42. package/node_modules/@relayfile/local-mount/node_modules/chokidar/index.d.ts +215 -0
  43. package/node_modules/@relayfile/local-mount/node_modules/chokidar/index.js +804 -0
  44. package/node_modules/@relayfile/local-mount/node_modules/chokidar/package.json +69 -0
  45. package/node_modules/@relayfile/local-mount/node_modules/readdirp/LICENSE +21 -0
  46. package/node_modules/@relayfile/local-mount/node_modules/readdirp/README.md +120 -0
  47. package/node_modules/@relayfile/local-mount/node_modules/readdirp/esm/index.d.ts +108 -0
  48. package/node_modules/@relayfile/local-mount/node_modules/readdirp/esm/index.js +257 -0
  49. package/node_modules/@relayfile/local-mount/node_modules/readdirp/esm/package.json +1 -0
  50. package/node_modules/@relayfile/local-mount/node_modules/readdirp/index.d.ts +108 -0
  51. package/node_modules/@relayfile/local-mount/node_modules/readdirp/index.js +263 -0
  52. package/node_modules/@relayfile/local-mount/node_modules/readdirp/package.json +70 -0
  53. package/node_modules/@relayfile/local-mount/package.json +47 -0
  54. package/node_modules/@smithy/config-resolver/package.json +2 -2
  55. package/node_modules/@smithy/core/dist-cjs/index.js +2 -1
  56. package/node_modules/@smithy/core/dist-cjs/submodules/cbor/index.js +32 -14
  57. package/node_modules/@smithy/core/dist-cjs/submodules/endpoints/index.js +2 -2
  58. package/node_modules/@smithy/core/dist-cjs/submodules/event-streams/index.js +16 -8
  59. package/node_modules/@smithy/core/dist-cjs/submodules/protocols/index.js +17 -10
  60. package/node_modules/@smithy/core/dist-cjs/submodules/schema/index.js +6 -1
  61. package/node_modules/@smithy/core/dist-cjs/submodules/serde/index.js +6 -3
  62. package/node_modules/@smithy/core/dist-cjs/util-identity-and-auth/DefaultIdentityProviderConfig.js +2 -1
  63. package/node_modules/@smithy/core/dist-es/submodules/cbor/CborCodec.js +23 -11
  64. package/node_modules/@smithy/core/dist-es/submodules/cbor/parseCborBody.js +9 -3
  65. package/node_modules/@smithy/core/dist-es/submodules/endpoints/toEndpointV1.js +2 -2
  66. package/node_modules/@smithy/core/dist-es/submodules/event-streams/EventStreamSerde.js +16 -8
  67. package/node_modules/@smithy/core/dist-es/submodules/protocols/HttpBindingProtocol.js +9 -4
  68. package/node_modules/@smithy/core/dist-es/submodules/protocols/HttpProtocol.js +8 -6
  69. package/node_modules/@smithy/core/dist-es/submodules/schema/TypeRegistry.js +6 -1
  70. package/node_modules/@smithy/core/dist-es/submodules/serde/parse-utils.js +6 -3
  71. package/node_modules/@smithy/core/dist-es/util-identity-and-auth/DefaultIdentityProviderConfig.js +2 -1
  72. package/node_modules/@smithy/core/dist-types/submodules/schema/TypeRegistry.d.ts +1 -1
  73. package/node_modules/@smithy/core/package.json +2 -2
  74. package/node_modules/@smithy/middleware-endpoint/package.json +3 -3
  75. package/node_modules/@smithy/middleware-retry/package.json +5 -5
  76. package/node_modules/@smithy/middleware-serde/package.json +2 -2
  77. package/node_modules/@smithy/node-http-handler/dist-cjs/index.js +188 -93
  78. package/node_modules/@smithy/node-http-handler/dist-es/http2/ClientHttp2SessionRef.js +45 -0
  79. package/node_modules/@smithy/node-http-handler/dist-es/node-http2-connection-manager.js +71 -35
  80. package/node_modules/@smithy/node-http-handler/dist-es/node-http2-connection-pool.js +32 -18
  81. package/node_modules/@smithy/node-http-handler/dist-es/node-http2-handler.js +41 -40
  82. package/node_modules/@smithy/node-http-handler/dist-types/http2/ClientHttp2SessionRef.d.ts +42 -0
  83. package/node_modules/@smithy/node-http-handler/dist-types/node-http2-connection-manager.d.ts +34 -14
  84. package/node_modules/@smithy/node-http-handler/dist-types/node-http2-connection-pool.d.ts +32 -8
  85. package/node_modules/@smithy/node-http-handler/dist-types/node-http2-handler.d.ts +0 -5
  86. package/node_modules/@smithy/node-http-handler/package.json +1 -1
  87. package/node_modules/@smithy/service-error-classification/dist-cjs/index.js +5 -0
  88. package/node_modules/@smithy/service-error-classification/dist-es/index.js +4 -0
  89. package/node_modules/@smithy/service-error-classification/dist-types/index.d.ts +6 -0
  90. package/node_modules/@smithy/service-error-classification/package.json +1 -1
  91. package/node_modules/@smithy/smithy-client/package.json +4 -4
  92. package/node_modules/@smithy/util-defaults-mode-browser/package.json +2 -2
  93. package/node_modules/@smithy/util-defaults-mode-node/package.json +3 -3
  94. package/node_modules/@smithy/util-endpoints/dist-cjs/index.js +65 -53
  95. package/node_modules/@smithy/util-endpoints/dist-es/utils/evaluateCondition.js +9 -7
  96. package/node_modules/@smithy/util-endpoints/dist-es/utils/evaluateConditions.js +12 -8
  97. package/node_modules/@smithy/util-endpoints/dist-es/utils/evaluateEndpointRule.js +14 -13
  98. package/node_modules/@smithy/util-endpoints/dist-es/utils/evaluateErrorRule.js +7 -4
  99. package/node_modules/@smithy/util-endpoints/dist-es/utils/evaluateExpression.js +10 -8
  100. package/node_modules/@smithy/util-endpoints/dist-es/utils/evaluateRules.js +4 -4
  101. package/node_modules/@smithy/util-endpoints/dist-es/utils/getEndpointHeaders.js +5 -5
  102. package/node_modules/@smithy/util-endpoints/dist-es/utils/getEndpointProperties.js +4 -4
  103. package/node_modules/@smithy/util-endpoints/dist-types/types/shared.d.ts +3 -3
  104. package/node_modules/@smithy/util-endpoints/dist-types/utils/endpointFunctions.d.ts +2 -15
  105. package/node_modules/@smithy/util-endpoints/dist-types/utils/evaluateCondition.d.ts +6 -3
  106. package/node_modules/@smithy/util-endpoints/dist-types/utils/evaluateConditions.d.ts +3 -3
  107. package/node_modules/@smithy/util-endpoints/dist-types/utils/getEndpointHeaders.d.ts +1 -1
  108. package/node_modules/@smithy/util-endpoints/dist-types/utils/getEndpointProperties.d.ts +2 -2
  109. package/node_modules/@smithy/util-endpoints/dist-types/utils/getReferenceValue.d.ts +2 -2
  110. package/node_modules/@smithy/util-endpoints/package.json +1 -1
  111. package/node_modules/@smithy/util-retry/package.json +2 -2
  112. package/node_modules/@smithy/util-stream/package.json +2 -2
  113. package/node_modules/agent-trajectories/dist/{chunk-2XT3DOJC.js → chunk-27AQPWHK.js} +136 -72
  114. package/node_modules/agent-trajectories/dist/chunk-27AQPWHK.js.map +1 -0
  115. package/node_modules/agent-trajectories/dist/cli/index.js +135 -71
  116. package/node_modules/agent-trajectories/dist/cli/index.js.map +1 -1
  117. package/node_modules/agent-trajectories/dist/{index-thTh5iI8.d.ts → index-C7XhwsoN.d.ts} +24 -0
  118. package/node_modules/agent-trajectories/dist/index.d.ts +2 -2
  119. package/node_modules/agent-trajectories/dist/index.js +1 -1
  120. package/node_modules/agent-trajectories/dist/sdk/index.d.ts +1 -1
  121. package/node_modules/agent-trajectories/dist/sdk/index.js +1 -1
  122. package/node_modules/agent-trajectories/package.json +1 -1
  123. package/package.json +25 -11
  124. package/packages/cloud/package.json +2 -2
  125. package/packages/config/package.json +1 -1
  126. package/packages/hooks/package.json +4 -4
  127. package/packages/sdk/README.md +3 -0
  128. package/packages/sdk/dist/relay.d.ts +16 -0
  129. package/packages/sdk/dist/relay.d.ts.map +1 -1
  130. package/packages/sdk/dist/relay.js +83 -0
  131. package/packages/sdk/dist/relay.js.map +1 -1
  132. package/packages/sdk/package.json +2 -2
  133. package/packages/telemetry/package.json +1 -1
  134. package/packages/trajectory/package.json +2 -2
  135. package/packages/user-directory/package.json +2 -2
  136. package/packages/utils/package.json +2 -2
  137. package/dist/src/cli/commands/on/symlink-mount.d.ts +0 -12
  138. package/dist/src/cli/commands/on/symlink-mount.d.ts.map +0 -1
  139. package/dist/src/cli/commands/on/symlink-mount.js.map +0 -1
  140. package/node_modules/agent-trajectories/dist/chunk-2XT3DOJC.js.map +0 -1
@@ -0,0 +1,215 @@
1
+ # @relayfile/local-mount
2
+
3
+ Create a temporary mounted mirror of a project directory, enforce `.agentignore` and `.agentreadonly` rules inside that mount, run an agent or CLI there, then sync writable changes back to the real project on exit.
4
+
5
+ This package is useful when you want an agent to work in a constrained workspace without giving it direct write access to every file in the source tree.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @relayfile/local-mount
11
+ ```
12
+
13
+ ## What it exports
14
+
15
+ ### `createSymlinkMount(projectDir, mountDir, options)`
16
+
17
+ Builds a mounted copy of `projectDir` at `mountDir` and returns a handle:
18
+
19
+ ```ts
20
+ interface SymlinkMountHandle {
21
+ mountDir: string;
22
+ syncBack(): Promise<number>;
23
+ startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
24
+ cleanup(): void;
25
+ }
26
+ ```
27
+
28
+ Behavior:
29
+ - Copies regular files into the mount
30
+ - Applies ignore rules from `ignoredPatterns`
31
+ - Marks read-only matches as mode `0o444`
32
+ - Excludes `.git` and `node_modules` by default
33
+ - Writes `_MOUNT_README.md` and `.relayfile-local-mount` into the mount
34
+ - Skips syncing `_MOUNT_README.md`, `.relayfile-local-mount`, ignored files, read-only files, and symlinks back to the source project
35
+
36
+ ### `readAgentDotfiles(projectDir, options?)`
37
+
38
+ Reads project-local permission dotfiles and returns compiled pattern lists:
39
+
40
+ - `.agentignore`
41
+ - `.agentreadonly`
42
+ - `.{agentName}.agentignore` (optional)
43
+ - `.{agentName}.agentreadonly` (optional)
44
+
45
+ Blank lines and `#` comments are ignored.
46
+
47
+ ```ts
48
+ const { ignoredPatterns, readonlyPatterns } = readAgentDotfiles(projectDir, {
49
+ agentName: 'reviewer',
50
+ });
51
+ ```
52
+
53
+ ### `launchOnMount(options)`
54
+
55
+ High-level helper that:
56
+ 1. creates a mount,
57
+ 2. starts bidirectional auto-sync (see below, controllable via `autoSync`),
58
+ 3. runs a CLI inside the mount,
59
+ 4. forwards `SIGINT` and `SIGTERM`,
60
+ 5. stops auto-sync and runs a final sync-back pass after the child exits,
61
+ 6. cleans up the mount directory.
62
+
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
+
65
+ ### Auto-sync
66
+
67
+ 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()`.
68
+
69
+ ```ts
70
+ interface AutoSyncOptions {
71
+ /** Full-reconcile interval as a safety net. Default: 10_000 ms. */
72
+ scanIntervalMs?: number;
73
+ /** chokidar `awaitWriteFinish` stability threshold. Default: 200 ms. */
74
+ writeFinishMs?: number;
75
+ /** Invoked on sync errors. Defaults to swallowing them. */
76
+ onError?: (err: Error) => void;
77
+ }
78
+
79
+ interface AutoSyncHandle {
80
+ stop(): Promise<void>;
81
+ reconcile(): Promise<number>;
82
+ totalChanges(): number;
83
+ ready(): Promise<void>;
84
+ }
85
+ ```
86
+
87
+ Control it from `launchOnMount`:
88
+
89
+ ```ts
90
+ // Disable entirely — only the final sync-back pass runs.
91
+ launchOnMount({ /* ... */, autoSync: false });
92
+
93
+ // Tune it.
94
+ launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, writeFinishMs: 100 } });
95
+ ```
96
+
97
+ How it works:
98
+ - chokidar watches both the mount and the project tree
99
+ - every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events
100
+ - per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed
101
+
102
+ Conflict and delete rules:
103
+ - both sides changed since last sync → **mount wins**
104
+ - only one side changed → propagate that change
105
+ - one side deleted and the other unchanged since last sync → propagate the delete
106
+ - one side deleted and the other changed since last sync → recreate the missing file from the changed side
107
+ - readonly paths never flow mount→project; project-side edits still flow into the mount (the mount copy is re-chmodded `0o444`)
108
+ - `_MOUNT_README.md`, `.relayfile-local-mount`, ignored paths, and excluded directories never cross
109
+
110
+ ## Dotfile semantics
111
+
112
+ `@relayfile/local-mount` uses glob-style patterns, powered by [`ignore`](https://www.npmjs.com/package/ignore).
113
+
114
+ ### `.agentignore`
115
+
116
+ Files matching these patterns are omitted from the mount entirely.
117
+
118
+ Example:
119
+
120
+ ```gitignore
121
+ secrets/
122
+ .env
123
+ coverage/
124
+ ```
125
+
126
+ ### `.agentreadonly`
127
+
128
+ Files matching these patterns are copied into the mount, but made read-only.
129
+ Changes to those files are not synced back.
130
+
131
+ Example:
132
+
133
+ ```gitignore
134
+ package.json
135
+ docs/**
136
+ *.lock
137
+ ```
138
+
139
+ ### Per-agent overrides
140
+
141
+ If you pass `agentName`, the package also reads:
142
+
143
+ - `.{agentName}.agentignore`
144
+ - `.{agentName}.agentreadonly`
145
+
146
+ Those patterns are appended to the generic ones.
147
+
148
+ ## Practical example
149
+
150
+ ```ts
151
+ import { launchOnMount, readAgentDotfiles } from '@relayfile/local-mount';
152
+ import path from 'node:path';
153
+ import os from 'node:os';
154
+
155
+ const projectDir = '/projects/acme-api';
156
+ const mountDir = path.join(os.tmpdir(), 'acme-api-agent-mount');
157
+
158
+ const { ignoredPatterns, readonlyPatterns } = readAgentDotfiles(projectDir, {
159
+ agentName: 'reviewer',
160
+ });
161
+
162
+ const result = await launchOnMount({
163
+ cli: 'claude',
164
+ args: ['--print', 'Review the codebase and update TODOs if needed.'],
165
+ projectDir,
166
+ mountDir,
167
+ ignoredPatterns,
168
+ readonlyPatterns,
169
+ excludeDirs: ['dist'],
170
+ agentName: 'reviewer',
171
+ onBeforeLaunch: async (dir) => {
172
+ // Add extra instructions or scratch files inside the mount if needed.
173
+ console.log(`Mount ready at ${dir}`);
174
+ },
175
+ onAfterSync: async (count) => {
176
+ console.log(`Synced ${count} writable file(s) back to the project`);
177
+ },
178
+ });
179
+
180
+ console.log(result.exitCode);
181
+ ```
182
+
183
+ ## Sync-back behavior
184
+
185
+ `syncBack()` is the one-shot, mount-only sweep used as a final pass (and available on its own if you disable auto-sync). It only writes files that are safe and writable:
186
+
187
+ - changed writable files are copied back
188
+ - new writable files created in the mount are copied back
189
+ - unchanged files are skipped
190
+ - ignored files are skipped
191
+ - read-only matches are skipped
192
+ - symlinks inside the mount are skipped
193
+
194
+ 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
+
196
+ ## Safety constraints
197
+
198
+ The implementation is intentionally conservative about `mountDir`:
199
+
200
+ - `mountDir` must be different from `projectDir`
201
+ - `mountDir` cannot be a filesystem root
202
+ - `mountDir` cannot overlap the source project directory in either direction
203
+ - if `mountDir` already exists, it must contain the `.relayfile-local-mount` marker file from a previous mount created by this package
204
+
205
+ These checks help prevent accidental deletion of unrelated directories during mount recreation and cleanup.
206
+
207
+ ## Notes
208
+
209
+ - Requires Node.js 18+
210
+ - The current implementation copies files into the mount rather than creating filesystem-level FUSE mounts
211
+ - Source symlinks are only copied when they resolve to regular files inside the source project
212
+
213
+ ## License
214
+
215
+ MIT
@@ -0,0 +1,31 @@
1
+ export interface AutoSyncContext {
2
+ realMountDir: string;
3
+ realProjectDir: string;
4
+ isExcluded: (relPosix: string) => boolean;
5
+ /**
6
+ * Directory-only ignore patterns (ending in `/`) must only match when the
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.
9
+ */
10
+ isIgnored: (relPosix: string, isDirectory?: boolean) => boolean;
11
+ isReadonly: (relPosix: string) => boolean;
12
+ isReservedFile: (relPosix: string) => boolean;
13
+ }
14
+ export interface AutoSyncOptions {
15
+ /** Full-reconcile interval as a safety net. Default: 10_000ms. */
16
+ scanIntervalMs?: number;
17
+ /** chokidar awaitWriteFinish stabilityThreshold in ms. Default: 200. */
18
+ writeFinishMs?: number;
19
+ /** Invoked on errors during sync — logged by default consumer. */
20
+ onError?: (err: Error) => void;
21
+ }
22
+ export interface AutoSyncHandle {
23
+ stop(): Promise<void>;
24
+ /** Force a reconcile now; returns number of files copied/deleted. */
25
+ reconcile(): Promise<number>;
26
+ /** Cumulative files changed (copied or deleted) since autosync started. */
27
+ totalChanges(): number;
28
+ /** Resolves once both watchers have completed their initial scan. */
29
+ ready(): Promise<void>;
30
+ }
31
+ export declare function startAutoSync(ctx: AutoSyncContext, opts?: AutoSyncOptions): AutoSyncHandle;
@@ -0,0 +1,466 @@
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chokidar from 'chokidar';
4
+ export function startAutoSync(ctx, opts = {}) {
5
+ const scanIntervalMs = opts.scanIntervalMs ?? 10_000;
6
+ const writeFinishMs = opts.writeFinishMs ?? 200;
7
+ const onError = opts.onError ?? (() => { });
8
+ const state = new Map();
9
+ primeState(state, ctx);
10
+ let syncing = false;
11
+ let pending = false;
12
+ let totalChanges = 0;
13
+ const runReconcile = async () => {
14
+ if (syncing) {
15
+ pending = true;
16
+ return 0;
17
+ }
18
+ syncing = true;
19
+ let count = 0;
20
+ try {
21
+ count = reconcile(state, ctx, onError);
22
+ }
23
+ catch (err) {
24
+ onError(err);
25
+ }
26
+ finally {
27
+ syncing = false;
28
+ }
29
+ if (pending) {
30
+ pending = false;
31
+ try {
32
+ count += reconcile(state, ctx, onError);
33
+ }
34
+ catch (err) {
35
+ onError(err);
36
+ }
37
+ }
38
+ totalChanges += count;
39
+ return count;
40
+ };
41
+ const syncPathFromRoot = (root, absPath) => {
42
+ const rel = path.relative(root, absPath);
43
+ if (rel === '' || rel.startsWith('..'))
44
+ return;
45
+ const relPosix = rel.split(path.sep).join('/');
46
+ if (!isSyncCandidate(relPosix, ctx))
47
+ return;
48
+ try {
49
+ const changed = syncOneFile(relPosix, state, ctx);
50
+ if (changed)
51
+ totalChanges += 1;
52
+ }
53
+ catch (err) {
54
+ onError(err);
55
+ }
56
+ };
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 };
77
+ };
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 interval = setInterval(() => {
84
+ void runReconcile();
85
+ }, scanIntervalMs);
86
+ // Do not keep the event loop alive just because of our scan timer.
87
+ interval.unref?.();
88
+ return {
89
+ async stop() {
90
+ clearInterval(interval);
91
+ await Promise.all([mountWatcher.close(), projectWatcher.close()]);
92
+ // Drain any pending work so callers can rely on "stopped means quiesced".
93
+ await runReconcile();
94
+ },
95
+ reconcile: runReconcile,
96
+ totalChanges: () => totalChanges,
97
+ ready: async () => {
98
+ await watchersReady;
99
+ },
100
+ };
101
+ }
102
+ function primeState(state, ctx) {
103
+ // Record current mtimes for every file that exists in both trees with the
104
+ // same content. Files that differ are left out so the first reconcile sees
105
+ // no prev entry and picks a winner via the content-based resolution path.
106
+ walk(ctx.realMountDir, ctx, (abs) => {
107
+ const rel = toRelPosix(abs, ctx);
108
+ if (rel === null)
109
+ return;
110
+ if (!isSyncCandidate(rel, ctx))
111
+ return;
112
+ const mountStat = safeFileStat(abs);
113
+ if (!mountStat)
114
+ return;
115
+ const projectAbs = path.join(ctx.realProjectDir, rel);
116
+ const projectStat = safeFileStat(projectAbs);
117
+ if (!projectStat)
118
+ return;
119
+ if (!sameContent(abs, projectAbs))
120
+ return;
121
+ state.set(rel, {
122
+ mountMtimeMs: mountStat.mtimeMs,
123
+ projectMtimeMs: projectStat.mtimeMs,
124
+ });
125
+ });
126
+ }
127
+ function reconcile(state, ctx, onError) {
128
+ const seen = new Set();
129
+ let count = 0;
130
+ const visit = (relPosix) => {
131
+ if (seen.has(relPosix))
132
+ return;
133
+ seen.add(relPosix);
134
+ if (!isSyncCandidate(relPosix, ctx))
135
+ return;
136
+ try {
137
+ const changed = syncOneFile(relPosix, state, ctx);
138
+ if (changed)
139
+ count += 1;
140
+ }
141
+ catch (err) {
142
+ onError(err);
143
+ }
144
+ };
145
+ walk(ctx.realMountDir, ctx, (abs) => {
146
+ const rel = toRelPosix(abs, ctx);
147
+ if (rel !== null)
148
+ visit(rel);
149
+ });
150
+ walk(ctx.realProjectDir, ctx, (abs) => {
151
+ const rel = toRelPosixFromProject(abs, ctx);
152
+ if (rel !== null)
153
+ visit(rel);
154
+ });
155
+ // Tombstone sweep: any path in state we didn't visit had both sides absent,
156
+ // so it's fully gone.
157
+ for (const rel of Array.from(state.keys())) {
158
+ if (!seen.has(rel)) {
159
+ const mountAbs = path.join(ctx.realMountDir, rel);
160
+ const projectAbs = path.join(ctx.realProjectDir, rel);
161
+ if (!existsSync(mountAbs) && !existsSync(projectAbs)) {
162
+ state.delete(rel);
163
+ }
164
+ }
165
+ }
166
+ return count;
167
+ }
168
+ /**
169
+ * Sync a single relPath and return true if a copy or delete actually happened.
170
+ *
171
+ * Resolution rules ("mount wins"):
172
+ * - If both sides changed since last sync → mount→project.
173
+ * - Only mount changed → mount→project (unless mount-side change is disallowed
174
+ * for readonly files; then drop the mount change).
175
+ * - Only project changed → project→mount.
176
+ * - One side missing:
177
+ * • Other side changed since last sync → recreate the missing side.
178
+ * • Otherwise → propagate the delete.
179
+ */
180
+ function syncOneFile(relPosix, state, ctx) {
181
+ const mountAbs = path.join(ctx.realMountDir, relPosix);
182
+ const projectAbs = path.join(ctx.realProjectDir, relPosix);
183
+ const mountStat = safeFileStat(mountAbs);
184
+ const projectStat = safeFileStat(projectAbs);
185
+ const prev = state.get(relPosix);
186
+ const readonly = ctx.isReadonly(relPosix);
187
+ if (!mountStat && !projectStat) {
188
+ state.delete(relPosix);
189
+ return false;
190
+ }
191
+ if (!prev) {
192
+ // First time we've seen this path.
193
+ if (mountStat && projectStat) {
194
+ if (sameContent(mountAbs, projectAbs)) {
195
+ state.set(relPosix, {
196
+ mountMtimeMs: mountStat.mtimeMs,
197
+ projectMtimeMs: projectStat.mtimeMs,
198
+ });
199
+ return false;
200
+ }
201
+ // Differ with no history: arbitrary tiebreak → mount wins.
202
+ if (readonly) {
203
+ // Readonly can't accept mount-side writes; fall back to project→mount.
204
+ return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
205
+ }
206
+ return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
207
+ }
208
+ if (mountStat && !projectStat) {
209
+ if (readonly) {
210
+ // New file in mount with a readonly pattern → cannot sync back.
211
+ return false;
212
+ }
213
+ return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
214
+ }
215
+ if (!mountStat && projectStat) {
216
+ return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
217
+ }
218
+ }
219
+ // Use strict inequality rather than `>`: on filesystems with coarse mtime
220
+ // resolution, or after a backdated touch, a real content change can land
221
+ // with a non-greater mtime.
222
+ const mountChanged = mountStat
223
+ ? prev?.mountMtimeMs === undefined || mountStat.mtimeMs !== prev.mountMtimeMs
224
+ : false;
225
+ const projectChanged = projectStat
226
+ ? prev?.projectMtimeMs === undefined || projectStat.mtimeMs !== prev.projectMtimeMs
227
+ : false;
228
+ if (mountStat && projectStat) {
229
+ if (!mountChanged && !projectChanged)
230
+ return false;
231
+ if (mountChanged && !readonly) {
232
+ return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
233
+ }
234
+ if (projectChanged) {
235
+ return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
236
+ }
237
+ return false;
238
+ }
239
+ if (mountStat && !projectStat) {
240
+ if (mountChanged && !readonly) {
241
+ return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
242
+ }
243
+ // Project deleted externally and mount hasn't been touched since → mirror.
244
+ return doDeleteMount(relPosix, state, mountAbs);
245
+ }
246
+ if (!mountStat && projectStat) {
247
+ if (projectChanged) {
248
+ return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
249
+ }
250
+ // Mount deleted and project hasn't been touched since → mirror to project.
251
+ if (readonly) {
252
+ // Readonly deletes in mount don't sync back; recreate mount from project.
253
+ return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
254
+ }
255
+ return doDeleteProject(relPosix, state, projectAbs);
256
+ }
257
+ return false;
258
+ }
259
+ function doMountToProject(relPosix, state, ctx, mountAbs, projectAbs) {
260
+ const target = resolveSafeWriteTarget(ctx.realProjectDir, projectAbs);
261
+ if (!target)
262
+ return false;
263
+ if (isSymlinkTarget(target))
264
+ return false;
265
+ if (existsSync(target) && sameContent(mountAbs, target)) {
266
+ updateState(state, relPosix, mountAbs, target);
267
+ return false;
268
+ }
269
+ copyFileSync(mountAbs, target);
270
+ updateState(state, relPosix, mountAbs, target);
271
+ return true;
272
+ }
273
+ function doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly) {
274
+ const target = resolveSafeWriteTarget(ctx.realMountDir, mountAbs);
275
+ if (!target)
276
+ return false;
277
+ if (isSymlinkTarget(target))
278
+ return false;
279
+ if (existsSync(target) && sameContent(projectAbs, target)) {
280
+ updateState(state, relPosix, target, projectAbs);
281
+ return false;
282
+ }
283
+ // The mount copy of a readonly file has mode 0o444, which blocks
284
+ // copyFileSync from overwriting it. Temporarily restore write permission.
285
+ if (existsSync(target)) {
286
+ try {
287
+ chmodSync(target, 0o644);
288
+ }
289
+ catch { /* best effort */ }
290
+ }
291
+ copyFileSync(projectAbs, target);
292
+ if (readonly) {
293
+ try {
294
+ chmodSync(target, 0o444);
295
+ }
296
+ catch { /* best effort */ }
297
+ }
298
+ else {
299
+ const mode = safeFileStat(projectAbs)?.mode;
300
+ if (mode !== undefined) {
301
+ try {
302
+ chmodSync(target, mode & 0o777);
303
+ }
304
+ catch { /* best effort */ }
305
+ }
306
+ }
307
+ updateState(state, relPosix, target, projectAbs);
308
+ return true;
309
+ }
310
+ function doDeleteMount(relPosix, state, mountAbs) {
311
+ try {
312
+ rmSync(mountAbs, { force: true });
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ state.delete(relPosix);
318
+ return true;
319
+ }
320
+ function doDeleteProject(relPosix, state, projectAbs) {
321
+ try {
322
+ rmSync(projectAbs, { force: true });
323
+ }
324
+ catch {
325
+ return false;
326
+ }
327
+ state.delete(relPosix);
328
+ return true;
329
+ }
330
+ function updateState(state, relPosix, mountAbs, projectAbs) {
331
+ const mountStat = safeFileStat(mountAbs);
332
+ const projectStat = safeFileStat(projectAbs);
333
+ state.set(relPosix, {
334
+ mountMtimeMs: mountStat?.mtimeMs,
335
+ projectMtimeMs: projectStat?.mtimeMs,
336
+ });
337
+ }
338
+ function isSyncCandidate(relPosix, ctx) {
339
+ if (!relPosix || relPosix.startsWith('..'))
340
+ return false;
341
+ if (ctx.isReservedFile(relPosix))
342
+ return false;
343
+ if (ctx.isExcluded(relPosix))
344
+ return false;
345
+ if (ctx.isIgnored(relPosix))
346
+ return false;
347
+ return true;
348
+ }
349
+ function toRelPosix(absPath, ctx) {
350
+ const rel = path.relative(ctx.realMountDir, absPath);
351
+ if (rel === '' || rel.startsWith('..'))
352
+ return null;
353
+ return rel.split(path.sep).join('/');
354
+ }
355
+ function toRelPosixFromProject(absPath, ctx) {
356
+ const rel = path.relative(ctx.realProjectDir, absPath);
357
+ if (rel === '' || rel.startsWith('..'))
358
+ return null;
359
+ return rel.split(path.sep).join('/');
360
+ }
361
+ function safeFileStat(p) {
362
+ try {
363
+ const s = lstatSync(p);
364
+ if (s.isSymbolicLink())
365
+ return null;
366
+ if (!s.isFile())
367
+ return null;
368
+ return s;
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ }
374
+ function isSymlinkTarget(target) {
375
+ // If the target already exists as a symlink, writing through it would
376
+ // follow the link and potentially escape the mount/project root. Refuse.
377
+ try {
378
+ return lstatSync(target).isSymbolicLink();
379
+ }
380
+ catch {
381
+ return false;
382
+ }
383
+ }
384
+ function sameContent(left, right) {
385
+ try {
386
+ const a = statSync(left);
387
+ const b = statSync(right);
388
+ if (a.size !== b.size)
389
+ return false;
390
+ return readFileSync(left).equals(readFileSync(right));
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
396
+ function resolveSafeWriteTarget(root, candidate) {
397
+ const resolvedRoot = path.resolve(root);
398
+ const resolvedCandidate = path.resolve(candidate);
399
+ if (resolvedCandidate !== resolvedRoot &&
400
+ !resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`)) {
401
+ return null;
402
+ }
403
+ const parent = path.dirname(resolvedCandidate);
404
+ try {
405
+ mkdirSync(parent, { recursive: true });
406
+ const realParent = realpathSync(parent);
407
+ if (realParent !== resolvedRoot &&
408
+ !realParent.startsWith(`${resolvedRoot}${path.sep}`)) {
409
+ return null;
410
+ }
411
+ return path.join(realParent, path.basename(resolvedCandidate));
412
+ }
413
+ catch {
414
+ return null;
415
+ }
416
+ }
417
+ function walk(root, ctx, visit) {
418
+ const stack = [root];
419
+ while (stack.length > 0) {
420
+ const cur = stack.pop();
421
+ if (!cur)
422
+ continue;
423
+ let entries;
424
+ try {
425
+ entries = readdirSync(cur, { withFileTypes: true });
426
+ }
427
+ catch {
428
+ continue;
429
+ }
430
+ for (const entry of entries) {
431
+ const abs = path.join(cur, entry.name);
432
+ const rel = path.relative(root, abs).split(path.sep).join('/');
433
+ if (!rel || rel.startsWith('..'))
434
+ continue;
435
+ if (ctx.isExcluded(rel) || ctx.isIgnored(rel, entry.isDirectory()))
436
+ continue;
437
+ if (entry.isDirectory()) {
438
+ stack.push(abs);
439
+ }
440
+ else if (entry.isFile() || entry.isSymbolicLink()) {
441
+ visit(abs);
442
+ }
443
+ }
444
+ }
445
+ }
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
+ }