@tanstack/ai-sandbox 0.2.1 → 0.2.3

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/dist/esm/watch.js CHANGED
@@ -16,11 +16,12 @@ function diffSnapshots(prev, next, timestamp) {
16
16
  }
17
17
  return events;
18
18
  }
19
- function buildFindCommand(root, ignore) {
19
+ function buildFindCommand(ignore) {
20
20
  const prunes = ignore.map((entry) => `-not -path ${q(`*/${entry}/*`)}`).join(" ");
21
- return `find ${q(root)} -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`;
21
+ return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`;
22
22
  }
23
- function parseFindOutput(stdout) {
23
+ function parseFindOutput(stdout, root) {
24
+ const base = root.replace(/\/+$/, "");
24
25
  const snapshot = /* @__PURE__ */ new Map();
25
26
  for (const line of stdout.split("\n")) {
26
27
  if (line === "") continue;
@@ -29,7 +30,8 @@ function parseFindOutput(stdout) {
29
30
  if (firstTab === -1 || secondTab === -1) continue;
30
31
  const mtime = line.slice(0, firstTab);
31
32
  const size = line.slice(firstTab + 1, secondTab);
32
- const path = line.slice(secondTab + 1);
33
+ const rel = line.slice(secondTab + 1).replace(/^\.\/?/, "");
34
+ const path = rel === "" ? base : `${base}/${rel}`;
33
35
  snapshot.set(path, `${mtime} ${size}`);
34
36
  }
35
37
  return snapshot;
@@ -48,14 +50,37 @@ async function watchWorkspace(handle, options) {
48
50
  return startPollWatch(handle, { ...options, root, ignore, intervalMs });
49
51
  }
50
52
  async function startNativeWatch(handle, options) {
51
- const { onEvent, root, ignore } = options;
53
+ const { onEvent, root, ignore, logger } = options;
52
54
  const watch = handle.fs.watch;
53
55
  if (!watch) throw new Error("native watch is unavailable on this provider");
54
- const known = await collectPaths(handle, root, ignore);
56
+ const seed = await collectPaths(handle, root, ignore, logger);
57
+ const known = seed.files;
58
+ let seeded = seed.rootOk;
59
+ let reseeding = null;
60
+ const ensureSeeded = () => {
61
+ if (seeded) return Promise.resolve();
62
+ if (!reseeding) {
63
+ reseeding = collectPaths(handle, root, ignore, logger).then((r) => {
64
+ if (r.rootOk) {
65
+ for (const p of r.files) known.add(p);
66
+ seeded = true;
67
+ logger?.sandbox(
68
+ "sandbox watch: re-seeded after failed initial seed",
69
+ {
70
+ root
71
+ }
72
+ );
73
+ }
74
+ reseeding = null;
75
+ });
76
+ }
77
+ return reseeding;
78
+ };
55
79
  const subscription = await watch(root, (raw) => {
56
80
  const path = raw.path;
57
81
  if (isIgnored(path, ignore)) return;
58
82
  void (async () => {
83
+ await ensureSeeded();
59
84
  const exists = await handle.fs.exists(path);
60
85
  const timestamp = Date.now();
61
86
  if (!exists) {
@@ -67,11 +92,20 @@ async function startNativeWatch(handle, options) {
67
92
  known.add(path);
68
93
  onEvent({ type: "create", path, timestamp });
69
94
  }
70
- })().catch(() => void 0);
95
+ })().catch((error) => {
96
+ logger?.warn("sandbox watch: native event classify failed", {
97
+ path,
98
+ error
99
+ });
100
+ });
101
+ });
102
+ const logStopFailure = (error) => logger?.warn("sandbox watch: native subscription.stop() failed", {
103
+ root,
104
+ error
71
105
  });
72
- const onAbort = () => void subscription.stop().catch(() => void 0);
106
+ const onAbort = () => void subscription.stop().catch(logStopFailure);
73
107
  options.signal?.addEventListener("abort", onAbort, { once: true });
74
- if (options.signal?.aborted) void subscription.stop().catch(() => void 0);
108
+ if (options.signal?.aborted) void subscription.stop().catch(logStopFailure);
75
109
  return {
76
110
  stop: async () => {
77
111
  options.signal?.removeEventListener("abort", onAbort);
@@ -80,27 +114,97 @@ async function startNativeWatch(handle, options) {
80
114
  };
81
115
  }
82
116
  async function startPollWatch(handle, options) {
83
- const { onEvent, root, ignore, intervalMs } = options;
84
- const command = buildFindCommand(root, ignore);
117
+ const { onEvent, root, ignore, intervalMs, logger } = options;
118
+ const command = buildFindCommand(ignore);
85
119
  const controller = new AbortController();
86
- const snapshot = async () => {
87
- const result = await handle.process.exec(command, {
88
- cwd: root,
89
- signal: controller.signal
120
+ const STEADY_STATE_THROW_WARN_AFTER = 3;
121
+ let consecutiveThrows = 0;
122
+ const snapshot = async (isInitial = false) => {
123
+ let result;
124
+ try {
125
+ result = await handle.process.exec(command, {
126
+ cwd: root,
127
+ signal: controller.signal
128
+ });
129
+ consecutiveThrows = 0;
130
+ } catch (error) {
131
+ if (isInitial) {
132
+ logger?.warn("sandbox watch: initial `find` poll threw", {
133
+ root,
134
+ error
135
+ });
136
+ } else if (controller.signal.aborted) {
137
+ logger?.sandbox("sandbox watch: `find` poll threw during teardown", {
138
+ root,
139
+ error
140
+ });
141
+ } else {
142
+ consecutiveThrows += 1;
143
+ if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) {
144
+ logger?.warn("sandbox watch: `find` poll threw repeatedly", {
145
+ root,
146
+ error,
147
+ consecutiveThrows
148
+ });
149
+ } else {
150
+ logger?.sandbox("sandbox watch: `find` poll threw", { root, error });
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ if (result.exitCode === 0) {
156
+ return { map: parseFindOutput(result.stdout, root), complete: true };
157
+ }
158
+ if (result.stdout !== "") {
159
+ logger?.sandbox(
160
+ "sandbox watch: `find` non-zero exit with partial output",
161
+ { root, exitCode: result.exitCode, stderr: result.stderr }
162
+ );
163
+ return { map: parseFindOutput(result.stdout, root), complete: false };
164
+ }
165
+ logger?.warn("sandbox watch: `find` poll exited non-zero with no output", {
166
+ root,
167
+ exitCode: result.exitCode,
168
+ stderr: result.stderr
90
169
  });
91
- return result.exitCode === 0 ? parseFindOutput(result.stdout) : /* @__PURE__ */ new Map();
170
+ return null;
92
171
  };
93
- let previous = await snapshot();
172
+ let previous = null;
173
+ let seededFromComplete = false;
174
+ {
175
+ const poll = await snapshot(true);
176
+ if (poll) {
177
+ previous = poll.map;
178
+ seededFromComplete = poll.complete;
179
+ }
180
+ }
94
181
  const state = { running: true };
95
182
  const tick = async () => {
96
183
  if (!state.running) return;
97
184
  try {
98
- const next = await snapshot();
185
+ const poll = await snapshot();
186
+ if (poll === null) return;
187
+ if (previous === null) {
188
+ previous = poll.map;
189
+ seededFromComplete = poll.complete;
190
+ return;
191
+ }
192
+ if (!seededFromComplete && poll.complete) {
193
+ logger?.sandbox(
194
+ "sandbox watch: re-baselined after provisional partial seed",
195
+ { root }
196
+ );
197
+ previous = poll.map;
198
+ seededFromComplete = true;
199
+ return;
200
+ }
201
+ const next = poll.complete ? poll.map : new Map([...previous, ...poll.map]);
99
202
  for (const event of diffSnapshots(previous, next, Date.now())) {
100
203
  onEvent(event);
101
204
  }
102
205
  previous = next;
103
- } catch {
206
+ } catch (error) {
207
+ logger?.sandbox("sandbox watch: tick failed", { root, error });
104
208
  }
105
209
  };
106
210
  const timer = setInterval(() => void tick(), intervalMs);
@@ -119,23 +223,29 @@ async function startPollWatch(handle, options) {
119
223
  if (options.signal?.aborted) void stop();
120
224
  return { stop };
121
225
  }
122
- async function collectPaths(handle, root, ignore) {
226
+ async function collectPaths(handle, root, ignore, logger) {
123
227
  const files = /* @__PURE__ */ new Set();
124
- const walk = async (dir) => {
228
+ let rootOk = true;
229
+ const walk = async (dir, isRoot) => {
125
230
  let entries;
126
231
  try {
127
232
  entries = await handle.fs.list(dir);
128
- } catch {
233
+ } catch (error) {
234
+ if (isRoot) rootOk = false;
235
+ logger?.warn("sandbox watch: failed to list directory while seeding", {
236
+ dir,
237
+ error
238
+ });
129
239
  return;
130
240
  }
131
241
  for (const entry of entries) {
132
242
  if (ignore.includes(entry.name)) continue;
133
- if (entry.type === "dir") await walk(entry.path);
243
+ if (entry.type === "dir") await walk(entry.path, false);
134
244
  else files.add(entry.path);
135
245
  }
136
246
  };
137
- await walk(root);
138
- return files;
247
+ await walk(root, true);
248
+ return { files, rootOk };
139
249
  }
140
250
  export {
141
251
  diffSnapshots,
@@ -1 +1 @@
1
- {"version":3,"file":"watch.js","sources":["../../src/watch.ts"],"sourcesContent":["/**\n * Sandbox file-event hooks — observe create / change / delete of files inside a\n * sandbox (e.g. as an in-sandbox agent edits the workspace).\n *\n * Provider-agnostic: coded against the {@link SandboxHandle} contract only.\n * Two mechanisms, auto-selected:\n *\n * - **Native** — when a provider implements the optional `fs.watch` seam\n * (local-process does, via Node `fs.watch`), OS events drive the feed with low\n * latency.\n * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a\n * single `find … -printf` snapshot of `mtime\\tsize\\tpath` is taken every\n * `intervalMs` and diffed. Works on any Linux container with GNU findutils\n * (true for `node:*` / debian images) with no extra deps or image changes.\n *\n * The feed intentionally rides only the portable surface, so the same\n * `watchWorkspace` call behaves identically across providers.\n */\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { SandboxHandle } from './contracts'\nimport type { SandboxFileEvent } from '@tanstack/ai'\n\nexport type { SandboxFileEvent } from '@tanstack/ai'\n/** @deprecated alias retained for the low-level watch API. */\nexport type FileEvent = SandboxFileEvent\nexport type FileEventType = SandboxFileEvent['type']\n\nexport interface WatchOptions {\n /** Called for every observed file event. */\n onEvent: (event: SandboxFileEvent) => void\n /** Workspace root to watch. Defaults to `/workspace`. */\n root?: string\n /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */\n intervalMs?: number\n /**\n * Directory-name fragments to ignore (a path containing `/<entry>/` is\n * skipped). Defaults to `['.git', 'node_modules']`.\n */\n ignore?: Array<string>\n /** Stop watching when this signal aborts. */\n signal?: AbortSignal\n}\n\nexport interface SandboxWatchHandle {\n /** Stop the watcher and release its resources. */\n stop: () => Promise<void>\n}\n\nconst DEFAULT_INTERVAL_MS = 700\nconst DEFAULT_IGNORE = ['.git', 'node_modules']\n\n/** POSIX single-quote escape for embedding values in a shell command. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\\tsize`).\n * Pure — the heart of the exec-poll path, unit-tested in isolation.\n */\nexport function diffSnapshots(\n prev: Map<string, string>,\n next: Map<string, string>,\n timestamp: number,\n): Array<SandboxFileEvent> {\n const events: Array<SandboxFileEvent> = []\n for (const [path, sig] of next) {\n const before = prev.get(path)\n if (before === undefined) events.push({ type: 'create', path, timestamp })\n else if (before !== sig) events.push({ type: 'change', path, timestamp })\n }\n for (const path of prev.keys()) {\n if (!next.has(path)) events.push({ type: 'delete', path, timestamp })\n }\n return events\n}\n\n/** Build the `find` command that prints `mtime\\tsize\\tpath` for every file. */\nfunction buildFindCommand(root: string, ignore: Array<string>): string {\n const prunes = ignore\n .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)\n .join(' ')\n return `find ${q(root)} -type f ${prunes} -printf '%T@\\\\t%s\\\\t%p\\\\n'`\n}\n\n/** Parse `find -printf` output into a `Map<path, signature>`. */\nfunction parseFindOutput(stdout: string): Map<string, string> {\n const snapshot = new Map<string, string>()\n for (const line of stdout.split('\\n')) {\n if (line === '') continue\n const firstTab = line.indexOf('\\t')\n const secondTab = line.indexOf('\\t', firstTab + 1)\n if (firstTab === -1 || secondTab === -1) continue\n const mtime = line.slice(0, firstTab)\n const size = line.slice(firstTab + 1, secondTab)\n const path = line.slice(secondTab + 1)\n snapshot.set(path, `${mtime}\\t${size}`)\n }\n return snapshot\n}\n\n/** Whether a path should be ignored (contains a `/<entry>/` fragment). */\nfunction isIgnored(path: string, ignore: Array<string>): boolean {\n return ignore.some((entry) => path.includes(`/${entry}/`))\n}\n\n/**\n * Start watching a sandbox workspace for file events. Picks the native\n * `fs.watch` fast-path when the provider advertises it, otherwise polls via\n * `find`. Returns a handle whose `stop()` tears everything down.\n */\nexport async function watchWorkspace(\n handle: SandboxHandle,\n options: WatchOptions,\n): Promise<SandboxWatchHandle> {\n const root = options.root ?? DEFAULT_WORKSPACE_ROOT\n const ignore = options.ignore ?? DEFAULT_IGNORE\n const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS\n\n // Already aborted before we start — don't begin any async work.\n if (options.signal?.aborted) return { stop: () => Promise.resolve() }\n\n if (handle.fs.watch) {\n return startNativeWatch(handle, { ...options, root, ignore })\n }\n return startPollWatch(handle, { ...options, root, ignore, intervalMs })\n}\n\n/** Native fs.watch path: OS events, disambiguated against a known-path set. */\nasync function startNativeWatch(\n handle: SandboxHandle,\n options: WatchOptions & { root: string; ignore: Array<string> },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore } = options\n const watch = handle.fs.watch\n if (!watch) throw new Error('native watch is unavailable on this provider')\n // Seed the set of existing files so the first event per path is classified\n // correctly (create vs change).\n const known = await collectPaths(handle, root, ignore)\n\n const subscription = await watch(root, (raw) => {\n const path = raw.path\n if (isIgnored(path, ignore)) return\n void (async () => {\n const exists = await handle.fs.exists(path)\n const timestamp = Date.now()\n if (!exists) {\n if (known.delete(path)) onEvent({ type: 'delete', path, timestamp })\n return\n }\n if (known.has(path)) onEvent({ type: 'change', path, timestamp })\n else {\n known.add(path)\n onEvent({ type: 'create', path, timestamp })\n }\n })().catch(() => undefined)\n })\n\n const onAbort = (): void => void subscription.stop().catch(() => undefined)\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the awaits above (the once-listener\n // would have missed it) — tear down now if so.\n if (options.signal?.aborted) void subscription.stop().catch(() => undefined)\n\n return {\n stop: async () => {\n options.signal?.removeEventListener('abort', onAbort)\n await subscription.stop()\n },\n }\n}\n\n/** Exec-poll path: snapshot `find -printf` on an interval and diff. */\nasync function startPollWatch(\n handle: SandboxHandle,\n options: WatchOptions & {\n root: string\n ignore: Array<string>\n intervalMs: number\n },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, intervalMs } = options\n const command = buildFindCommand(root, ignore)\n const controller = new AbortController()\n\n const snapshot = async (): Promise<Map<string, string>> => {\n const result = await handle.process.exec(command, {\n cwd: root,\n signal: controller.signal,\n })\n return result.exitCode === 0\n ? parseFindOutput(result.stdout)\n : new Map<string, string>()\n }\n\n let previous = await snapshot()\n const state = { running: true }\n\n const tick = async (): Promise<void> => {\n if (!state.running) return\n try {\n const next = await snapshot()\n for (const event of diffSnapshots(previous, next, Date.now())) {\n onEvent(event)\n }\n previous = next\n } catch {\n // transient exec failure (e.g. mid-teardown) — try again next tick\n }\n }\n\n const timer = setInterval(() => void tick(), intervalMs)\n // Don't keep the event loop alive on the watcher alone.\n if (typeof timer.unref === 'function') timer.unref()\n\n const stop = (): Promise<void> => {\n if (state.running) {\n state.running = false\n clearInterval(timer)\n controller.abort()\n options.signal?.removeEventListener('abort', onAbort)\n }\n return Promise.resolve()\n }\n const onAbort = (): void => void stop()\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the initial `await snapshot()` above\n // (the once-listener would have missed it) — tear down now if so.\n if (options.signal?.aborted) void stop()\n\n return { stop }\n}\n\n/** Recursively collect file paths under `root`, honoring `ignore`. */\nasync function collectPaths(\n handle: SandboxHandle,\n root: string,\n ignore: Array<string>,\n): Promise<Set<string>> {\n const files = new Set<string>()\n const walk = async (dir: string): Promise<void> => {\n let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>\n try {\n entries = await handle.fs.list(dir)\n } catch {\n return\n }\n for (const entry of entries) {\n if (ignore.includes(entry.name)) continue\n if (entry.type === 'dir') await walk(entry.path)\n else files.add(entry.path)\n }\n }\n await walk(root)\n return files\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"watch.js","sources":["../../src/watch.ts"],"sourcesContent":["/**\n * Sandbox file-event hooks — observe create / change / delete of files inside a\n * sandbox (e.g. as an in-sandbox agent edits the workspace).\n *\n * Provider-agnostic: coded against the {@link SandboxHandle} contract only.\n * Two mechanisms, auto-selected:\n *\n * - **Native** — when a provider implements the optional `fs.watch` seam\n * (local-process does, via Node `fs.watch`), OS events drive the feed with low\n * latency.\n * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a\n * single `find … -printf` snapshot of `mtime\\tsize\\tpath` is taken every\n * `intervalMs` and diffed. Works on any Linux container with GNU findutils\n * (true for `node:*` / debian images) with no extra deps or image changes.\n *\n * The feed intentionally rides only the portable surface, so the same\n * `watchWorkspace` call behaves identically across providers.\n */\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { SandboxHandle } from './contracts'\nimport type { SandboxFileEvent } from '@tanstack/ai'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\n\nexport type { SandboxFileEvent } from '@tanstack/ai'\n/** @deprecated alias retained for the low-level watch API. */\nexport type FileEvent = SandboxFileEvent\nexport type FileEventType = SandboxFileEvent['type']\n\nexport interface WatchOptions {\n /** Called for every observed file event. */\n onEvent: (event: SandboxFileEvent) => void\n /** Workspace root to watch. Defaults to `/workspace`. */\n root?: string\n /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */\n intervalMs?: number\n /**\n * Directory-name fragments to ignore (a path containing `/<entry>/` is\n * skipped). Defaults to `['.git', 'node_modules']`.\n */\n ignore?: Array<string>\n /** Stop watching when this signal aborts. */\n signal?: AbortSignal\n /**\n * Optional logger. When present, a failed `find` poll (non-zero exit or a\n * thrown exec) is logged instead of silently degrading the snapshot — the\n * failure mode a plain exec-poll watcher hides.\n */\n logger?: InternalLogger\n}\n\nexport interface SandboxWatchHandle {\n /** Stop the watcher and release its resources. */\n stop: () => Promise<void>\n}\n\nconst DEFAULT_INTERVAL_MS = 700\nconst DEFAULT_IGNORE = ['.git', 'node_modules']\n\n/** POSIX single-quote escape for embedding values in a shell command. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\\tsize`).\n * Pure — the heart of the exec-poll path, unit-tested in isolation.\n */\nexport function diffSnapshots(\n prev: Map<string, string>,\n next: Map<string, string>,\n timestamp: number,\n): Array<SandboxFileEvent> {\n const events: Array<SandboxFileEvent> = []\n for (const [path, sig] of next) {\n const before = prev.get(path)\n if (before === undefined) events.push({ type: 'create', path, timestamp })\n else if (before !== sig) events.push({ type: 'change', path, timestamp })\n }\n for (const path of prev.keys()) {\n if (!next.has(path)) events.push({ type: 'delete', path, timestamp })\n }\n return events\n}\n\n/**\n * Build the `find` command that prints `mtime\\tsize\\tpath` for every file.\n * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a\n * provider's `exec` maps only `cwd` onto the real filesystem, not literal path\n * arguments, so `find <virtual-root>` would look at a non-existent host path on\n * mapped-root providers (e.g. local-process). Emitted `%p` values are\n * root-normalized in {@link parseFindOutput}.\n */\nfunction buildFindCommand(ignore: Array<string>): string {\n const prunes = ignore\n .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)\n .join(' ')\n return `find . -type f ${prunes} -printf '%T@\\\\t%s\\\\t%p\\\\n'`\n}\n\n/**\n * Parse `find -printf` output into a `Map<path, signature>`. `find .` prints\n * paths like `./sub/file`; map them back under `root` so event paths match the\n * native-watch shape (`<root>/sub/file`).\n */\nfunction parseFindOutput(stdout: string, root: string): Map<string, string> {\n const base = root.replace(/\\/+$/, '')\n const snapshot = new Map<string, string>()\n for (const line of stdout.split('\\n')) {\n if (line === '') continue\n const firstTab = line.indexOf('\\t')\n const secondTab = line.indexOf('\\t', firstTab + 1)\n if (firstTab === -1 || secondTab === -1) continue\n const mtime = line.slice(0, firstTab)\n const size = line.slice(firstTab + 1, secondTab)\n const rel = line.slice(secondTab + 1).replace(/^\\.\\/?/, '')\n const path = rel === '' ? base : `${base}/${rel}`\n snapshot.set(path, `${mtime}\\t${size}`)\n }\n return snapshot\n}\n\n/** Whether a path should be ignored (contains a `/<entry>/` fragment). */\nfunction isIgnored(path: string, ignore: Array<string>): boolean {\n return ignore.some((entry) => path.includes(`/${entry}/`))\n}\n\n/**\n * Start watching a sandbox workspace for file events. Picks the native\n * `fs.watch` fast-path when the provider advertises it, otherwise polls via\n * `find`. Returns a handle whose `stop()` tears everything down.\n */\nexport async function watchWorkspace(\n handle: SandboxHandle,\n options: WatchOptions,\n): Promise<SandboxWatchHandle> {\n const root = options.root ?? DEFAULT_WORKSPACE_ROOT\n const ignore = options.ignore ?? DEFAULT_IGNORE\n const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS\n\n // Already aborted before we start — don't begin any async work.\n if (options.signal?.aborted) return { stop: () => Promise.resolve() }\n\n if (handle.fs.watch) {\n return startNativeWatch(handle, { ...options, root, ignore })\n }\n return startPollWatch(handle, { ...options, root, ignore, intervalMs })\n}\n\n/** Native fs.watch path: OS events, disambiguated against a known-path set. */\nasync function startNativeWatch(\n handle: SandboxHandle,\n options: WatchOptions & { root: string; ignore: Array<string> },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, logger } = options\n const watch = handle.fs.watch\n if (!watch) throw new Error('native watch is unavailable on this provider')\n // Seed the set of existing files so the first event per path is classified\n // correctly (create vs change).\n const seed = await collectPaths(handle, root, ignore, logger)\n const known = seed.files\n // If the ROOT list failed, `known` is untrustworthy — every pre-existing\n // file would misclassify as `create` on its first edit. Re-seed lazily on\n // the next event(s): by the time real activity arrives the fs has usually\n // recovered, and re-listing then establishes the baseline. Dedupe concurrent\n // re-seeds behind a single in-flight promise.\n // ponytail: a file genuinely CREATED in the narrow window between the failed\n // seed and the first event gets picked up by the re-seed and so mislabels as\n // `change` once. That's strictly better than the whole-run mislabel a\n // never-recovered empty seed causes, and `diff()` is correct regardless.\n let seeded = seed.rootOk\n let reseeding: Promise<void> | null = null\n const ensureSeeded = (): Promise<void> => {\n if (seeded) return Promise.resolve()\n if (!reseeding) {\n reseeding = collectPaths(handle, root, ignore, logger).then((r) => {\n if (r.rootOk) {\n for (const p of r.files) known.add(p)\n seeded = true\n logger?.sandbox(\n 'sandbox watch: re-seeded after failed initial seed',\n {\n root,\n },\n )\n }\n reseeding = null\n })\n }\n return reseeding\n }\n\n const subscription = await watch(root, (raw) => {\n const path = raw.path\n if (isIgnored(path, ignore)) return\n void (async () => {\n await ensureSeeded()\n const exists = await handle.fs.exists(path)\n const timestamp = Date.now()\n if (!exists) {\n if (known.delete(path)) onEvent({ type: 'delete', path, timestamp })\n return\n }\n if (known.has(path)) onEvent({ type: 'change', path, timestamp })\n else {\n known.add(path)\n onEvent({ type: 'create', path, timestamp })\n }\n })().catch((error: unknown) => {\n // A failed classify (e.g. `fs.exists` threw) drops this file's event —\n // log it so a missing diff isn't silent (the whole point of the watcher).\n logger?.warn('sandbox watch: native event classify failed', {\n path,\n error,\n })\n })\n })\n\n // A failed `subscription.stop()` can leak an OS-level watch — log rather\n // than swallow it silently.\n const logStopFailure = (error: unknown): void =>\n logger?.warn('sandbox watch: native subscription.stop() failed', {\n root,\n error,\n })\n const onAbort = (): void => void subscription.stop().catch(logStopFailure)\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the awaits above (the once-listener\n // would have missed it) — tear down now if so.\n if (options.signal?.aborted) void subscription.stop().catch(logStopFailure)\n\n return {\n stop: async () => {\n options.signal?.removeEventListener('abort', onAbort)\n await subscription.stop()\n },\n }\n}\n\n/** Exec-poll path: snapshot `find -printf` on an interval and diff. */\nasync function startPollWatch(\n handle: SandboxHandle,\n options: WatchOptions & {\n root: string\n ignore: Array<string>\n intervalMs: number\n },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, intervalMs, logger } = options\n const command = buildFindCommand(ignore)\n const controller = new AbortController()\n\n // A poll result: the parsed snapshot plus whether `find` completed cleanly.\n // `null` means the poll produced no usable output at all (thrown exec, or a\n // non-zero exit with empty stdout) — callers preserve the previous snapshot.\n // Collapsing a failed poll to `{}` would make the next diff fabricate a\n // `delete` for every tracked file (and a `create` for each on recovery) —\n // one transient `find` blip would fan a phantom storm out to hooks/stream.\n interface Poll {\n map: Map<string, string>\n /** `false` when `find` exited non-zero but still printed rows (partial). */\n complete: boolean\n }\n // Escalate a steady-state poll throw to `warn` after this many in a row.\n const STEADY_STATE_THROW_WARN_AFTER = 3\n let consecutiveThrows = 0\n const snapshot = async (isInitial = false): Promise<Poll | null> => {\n let result\n try {\n result = await handle.process.exec(command, {\n cwd: root,\n signal: controller.signal,\n })\n consecutiveThrows = 0 // exec returned (any exit code) — the seam is alive\n } catch (error) {\n // Thrown exec — container not ready, `find` seam rejects, or a\n // mid-teardown abort. Treat as a failed poll so BOTH the initial seed\n // and every tick preserve `previous` instead of rejecting setup (which\n // would crash the run and leak the sandbox) or the interval.\n if (isInitial) {\n // The INITIAL poll can't be a teardown (a pre-aborted signal is guarded\n // in `watchWorkspace`), so a throw here is an unambiguous anomaly (`find`\n // missing, container never ready) that leaves the watcher dead for the\n // whole run — surface it at `warn`.\n logger?.warn('sandbox watch: initial `find` poll threw', {\n root,\n error,\n })\n } else if (controller.signal.aborted) {\n // Mid-teardown abort — expected, stay quiet.\n logger?.sandbox('sandbox watch: `find` poll threw during teardown', {\n root,\n error,\n })\n } else {\n // Steady-state throw while NOT tearing down. One is usually a transient\n // blip (→ `sandbox`), but a run of them means the exec seam is wedged:\n // every poll returns null and the watcher emits nothing for the rest of\n // the run. That silent-death case escalates to `warn` (on by default).\n consecutiveThrows += 1\n if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) {\n logger?.warn('sandbox watch: `find` poll threw repeatedly', {\n root,\n error,\n consecutiveThrows,\n })\n } else {\n logger?.sandbox('sandbox watch: `find` poll threw', { root, error })\n }\n }\n return null\n }\n if (result.exitCode === 0) {\n return { map: parseFindOutput(result.stdout, root), complete: true }\n }\n // Non-zero exit doesn't mean \"no data\": GNU `find` exits >0 on the first\n // permission-denied entry it hits mid-traversal (common in containers, and\n // the ignore list is a `-not -path` filter, not `-prune`, so `find` still\n // descends into unreadable dirs) yet still prints every readable file. Use\n // that partial output — marked `complete: false` so the tick merges rather\n // than diffs it — instead of blinding the watcher for the whole run. Only a\n // non-zero exit with NO output is a truly failed poll.\n if (result.stdout !== '') {\n logger?.sandbox(\n 'sandbox watch: `find` non-zero exit with partial output',\n { root, exitCode: result.exitCode, stderr: result.stderr },\n )\n return { map: parseFindOutput(result.stdout, root), complete: false }\n }\n logger?.warn('sandbox watch: `find` poll exited non-zero with no output', {\n root,\n exitCode: result.exitCode,\n stderr: result.stderr,\n })\n return null\n }\n\n // `null` until the first poll that yields usable output. A failed INITIAL\n // poll must NOT seed an empty baseline — the first successful poll would then\n // diff against `{}` and fabricate a `create` for every pre-existing file. So\n // the first non-null snapshot is adopted as the baseline WITHOUT diffing.\n let previous: Map<string, string> | null = null\n // Whether `previous` was established from a COMPLETE poll. A baseline seeded\n // from a PARTIAL poll is provisional — files unreadable during that poll are\n // absent from it and would later fabricate `create`s when they recover — so\n // the first complete poll re-baselines without diffing.\n let seededFromComplete = false\n {\n const poll = await snapshot(true)\n if (poll) {\n previous = poll.map\n seededFromComplete = poll.complete\n }\n }\n const state = { running: true }\n\n const tick = async (): Promise<void> => {\n if (!state.running) return\n try {\n const poll = await snapshot()\n // Failed poll — keep `previous` and retry next tick (see `snapshot`).\n if (poll === null) return\n if (previous === null) {\n // First usable snapshot after a failed initial poll — seed, don't diff.\n previous = poll.map\n seededFromComplete = poll.complete\n return\n }\n if (!seededFromComplete && poll.complete) {\n // First complete poll after a provisional (partial) seed — re-baseline\n // WITHOUT diffing, so files merely unreadable at seed time don't\n // fabricate `create`s. (Real creates during this degraded-startup\n // window are missed — an acceptable trade for not fabricating events.)\n logger?.sandbox(\n 'sandbox watch: re-baselined after provisional partial seed',\n { root },\n )\n previous = poll.map\n seededFromComplete = true\n return\n }\n // A partial (non-`complete`) poll can't distinguish \"deleted\" from\n // \"transiently unreadable this poll\", so MERGE it over `previous`: pick\n // up new/changed files without fabricating a `delete` for a path this\n // poll simply couldn't see. A real deletion still surfaces on the next\n // complete poll.\n const next = poll.complete\n ? poll.map\n : new Map([...previous, ...poll.map])\n for (const event of diffSnapshots(previous, next, Date.now())) {\n onEvent(event)\n }\n previous = next\n } catch (error) {\n // Defensive: a throw from diff dispatch — preserve `previous`, retry.\n logger?.sandbox('sandbox watch: tick failed', { root, error })\n }\n }\n\n const timer = setInterval(() => void tick(), intervalMs)\n // Don't keep the event loop alive on the watcher alone.\n if (typeof timer.unref === 'function') timer.unref()\n\n const stop = (): Promise<void> => {\n if (state.running) {\n state.running = false\n clearInterval(timer)\n controller.abort()\n options.signal?.removeEventListener('abort', onAbort)\n }\n return Promise.resolve()\n }\n const onAbort = (): void => void stop()\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the initial `await snapshot()` above\n // (the once-listener would have missed it) — tear down now if so.\n if (options.signal?.aborted) void stop()\n\n return { stop }\n}\n\n/**\n * Recursively collect file paths under `root`, honoring `ignore`. `rootOk` is\n * `false` when the ROOT `list` itself failed — the seed is then untrustworthy\n * (empty/partial), which the native watcher uses to trigger a lazy re-seed. A\n * failed *subdirectory* list is logged but doesn't flip `rootOk` (its files are\n * simply absent, a smaller misclassification surface).\n */\nasync function collectPaths(\n handle: SandboxHandle,\n root: string,\n ignore: Array<string>,\n logger?: InternalLogger,\n): Promise<{ files: Set<string>; rootOk: boolean }> {\n const files = new Set<string>()\n let rootOk = true\n const walk = async (dir: string, isRoot: boolean): Promise<void> => {\n let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>\n try {\n entries = await handle.fs.list(dir)\n } catch (error) {\n // A dir we can't list is seeded as empty, so its existing files would\n // later misclassify as `create` on first edit — log rather than hide it.\n if (isRoot) rootOk = false\n logger?.warn('sandbox watch: failed to list directory while seeding', {\n dir,\n error,\n })\n return\n }\n for (const entry of entries) {\n if (ignore.includes(entry.name)) continue\n if (entry.type === 'dir') await walk(entry.path, false)\n else files.add(entry.path)\n }\n }\n await walk(root, true)\n return { files, rootOk }\n}\n"],"names":[],"mappings":";AAuDA,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,CAAC,QAAQ,cAAc;AAG9C,SAAS,EAAE,OAAuB;AAChC,SAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,CAAC;AACzC;AAMO,SAAS,cACd,MACA,MACA,WACyB;AACzB,QAAM,SAAkC,CAAA;AACxC,aAAW,CAAC,MAAM,GAAG,KAAK,MAAM;AAC9B,UAAM,SAAS,KAAK,IAAI,IAAI;AAC5B,QAAI,WAAW,OAAW,QAAO,KAAK,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,aAChE,WAAW,IAAK,QAAO,KAAK,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,EAC1E;AACA,aAAW,QAAQ,KAAK,QAAQ;AAC9B,QAAI,CAAC,KAAK,IAAI,IAAI,EAAG,QAAO,KAAK,EAAE,MAAM,UAAU,MAAM,UAAA,CAAW;AAAA,EACtE;AACA,SAAO;AACT;AAUA,SAAS,iBAAiB,QAA+B;AACvD,QAAM,SAAS,OACZ,IAAI,CAAC,UAAU,cAAc,EAAE,KAAK,KAAK,IAAI,CAAC,EAAE,EAChD,KAAK,GAAG;AACX,SAAO,kBAAkB,MAAM;AACjC;AAOA,SAAS,gBAAgB,QAAgB,MAAmC;AAC1E,QAAM,OAAO,KAAK,QAAQ,QAAQ,EAAE;AACpC,QAAM,+BAAe,IAAA;AACrB,aAAW,QAAQ,OAAO,MAAM,IAAI,GAAG;AACrC,QAAI,SAAS,GAAI;AACjB,UAAM,WAAW,KAAK,QAAQ,GAAI;AAClC,UAAM,YAAY,KAAK,QAAQ,KAAM,WAAW,CAAC;AACjD,QAAI,aAAa,MAAM,cAAc,GAAI;AACzC,UAAM,QAAQ,KAAK,MAAM,GAAG,QAAQ;AACpC,UAAM,OAAO,KAAK,MAAM,WAAW,GAAG,SAAS;AAC/C,UAAM,MAAM,KAAK,MAAM,YAAY,CAAC,EAAE,QAAQ,UAAU,EAAE;AAC1D,UAAM,OAAO,QAAQ,KAAK,OAAO,GAAG,IAAI,IAAI,GAAG;AAC/C,aAAS,IAAI,MAAM,GAAG,KAAK,IAAK,IAAI,EAAE;AAAA,EACxC;AACA,SAAO;AACT;AAGA,SAAS,UAAU,MAAc,QAAgC;AAC/D,SAAO,OAAO,KAAK,CAAC,UAAU,KAAK,SAAS,IAAI,KAAK,GAAG,CAAC;AAC3D;AAOA,eAAsB,eACpB,QACA,SAC6B;AAC7B,QAAM,OAAO,QAAQ,QAAQ;AAC7B,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,aAAa,QAAQ,cAAc;AAGzC,MAAI,QAAQ,QAAQ,QAAS,QAAO,EAAE,MAAM,MAAM,QAAQ,UAAQ;AAElE,MAAI,OAAO,GAAG,OAAO;AACnB,WAAO,iBAAiB,QAAQ,EAAE,GAAG,SAAS,MAAM,QAAQ;AAAA,EAC9D;AACA,SAAO,eAAe,QAAQ,EAAE,GAAG,SAAS,MAAM,QAAQ,YAAY;AACxE;AAGA,eAAe,iBACb,QACA,SAC6B;AAC7B,QAAM,EAAE,SAAS,MAAM,QAAQ,WAAW;AAC1C,QAAM,QAAQ,OAAO,GAAG;AACxB,MAAI,CAAC,MAAO,OAAM,IAAI,MAAM,8CAA8C;AAG1E,QAAM,OAAO,MAAM,aAAa,QAAQ,MAAM,QAAQ,MAAM;AAC5D,QAAM,QAAQ,KAAK;AAUnB,MAAI,SAAS,KAAK;AAClB,MAAI,YAAkC;AACtC,QAAM,eAAe,MAAqB;AACxC,QAAI,OAAQ,QAAO,QAAQ,QAAA;AAC3B,QAAI,CAAC,WAAW;AACd,kBAAY,aAAa,QAAQ,MAAM,QAAQ,MAAM,EAAE,KAAK,CAAC,MAAM;AACjE,YAAI,EAAE,QAAQ;AACZ,qBAAW,KAAK,EAAE,MAAO,OAAM,IAAI,CAAC;AACpC,mBAAS;AACT,kBAAQ;AAAA,YACN;AAAA,YACA;AAAA,cACE;AAAA,YAAA;AAAA,UACF;AAAA,QAEJ;AACA,oBAAY;AAAA,MACd,CAAC;AAAA,IACH;AACA,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,MAAM,MAAM,MAAM,CAAC,QAAQ;AAC9C,UAAM,OAAO,IAAI;AACjB,QAAI,UAAU,MAAM,MAAM,EAAG;AAC7B,UAAM,YAAY;AAChB,YAAM,aAAA;AACN,YAAM,SAAS,MAAM,OAAO,GAAG,OAAO,IAAI;AAC1C,YAAM,YAAY,KAAK,IAAA;AACvB,UAAI,CAAC,QAAQ;AACX,YAAI,MAAM,OAAO,IAAI,EAAG,SAAQ,EAAE,MAAM,UAAU,MAAM,WAAW;AACnE;AAAA,MACF;AACA,UAAI,MAAM,IAAI,IAAI,EAAG,SAAQ,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,WAC3D;AACH,cAAM,IAAI,IAAI;AACd,gBAAQ,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,MAC7C;AAAA,IACF,GAAA,EAAK,MAAM,CAAC,UAAmB;AAG7B,cAAQ,KAAK,+CAA+C;AAAA,QAC1D;AAAA,QACA;AAAA,MAAA,CACD;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AAID,QAAM,iBAAiB,CAAC,UACtB,QAAQ,KAAK,oDAAoD;AAAA,IAC/D;AAAA,IACA;AAAA,EAAA,CACD;AACH,QAAM,UAAU,MAAY,KAAK,aAAa,KAAA,EAAO,MAAM,cAAc;AACzE,UAAQ,QAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM;AAGjE,MAAI,QAAQ,QAAQ,QAAS,MAAK,aAAa,KAAA,EAAO,MAAM,cAAc;AAE1E,SAAO;AAAA,IACL,MAAM,YAAY;AAChB,cAAQ,QAAQ,oBAAoB,SAAS,OAAO;AACpD,YAAM,aAAa,KAAA;AAAA,IACrB;AAAA,EAAA;AAEJ;AAGA,eAAe,eACb,QACA,SAK6B;AAC7B,QAAM,EAAE,SAAS,MAAM,QAAQ,YAAY,WAAW;AACtD,QAAM,UAAU,iBAAiB,MAAM;AACvC,QAAM,aAAa,IAAI,gBAAA;AAcvB,QAAM,gCAAgC;AACtC,MAAI,oBAAoB;AACxB,QAAM,WAAW,OAAO,YAAY,UAAgC;AAClE,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,OAAO,QAAQ,KAAK,SAAS;AAAA,QAC1C,KAAK;AAAA,QACL,QAAQ,WAAW;AAAA,MAAA,CACpB;AACD,0BAAoB;AAAA,IACtB,SAAS,OAAO;AAKd,UAAI,WAAW;AAKb,gBAAQ,KAAK,4CAA4C;AAAA,UACvD;AAAA,UACA;AAAA,QAAA,CACD;AAAA,MACH,WAAW,WAAW,OAAO,SAAS;AAEpC,gBAAQ,QAAQ,oDAAoD;AAAA,UAClE;AAAA,UACA;AAAA,QAAA,CACD;AAAA,MACH,OAAO;AAKL,6BAAqB;AACrB,YAAI,qBAAqB,+BAA+B;AACtD,kBAAQ,KAAK,+CAA+C;AAAA,YAC1D;AAAA,YACA;AAAA,YACA;AAAA,UAAA,CACD;AAAA,QACH,OAAO;AACL,kBAAQ,QAAQ,oCAAoC,EAAE,MAAM,OAAO;AAAA,QACrE;AAAA,MACF;AACA,aAAO;AAAA,IACT;AACA,QAAI,OAAO,aAAa,GAAG;AACzB,aAAO,EAAE,KAAK,gBAAgB,OAAO,QAAQ,IAAI,GAAG,UAAU,KAAA;AAAA,IAChE;AAQA,QAAI,OAAO,WAAW,IAAI;AACxB,cAAQ;AAAA,QACN;AAAA,QACA,EAAE,MAAM,UAAU,OAAO,UAAU,QAAQ,OAAO,OAAA;AAAA,MAAO;AAE3D,aAAO,EAAE,KAAK,gBAAgB,OAAO,QAAQ,IAAI,GAAG,UAAU,MAAA;AAAA,IAChE;AACA,YAAQ,KAAK,6DAA6D;AAAA,MACxE;AAAA,MACA,UAAU,OAAO;AAAA,MACjB,QAAQ,OAAO;AAAA,IAAA,CAChB;AACD,WAAO;AAAA,EACT;AAMA,MAAI,WAAuC;AAK3C,MAAI,qBAAqB;AACzB;AACE,UAAM,OAAO,MAAM,SAAS,IAAI;AAChC,QAAI,MAAM;AACR,iBAAW,KAAK;AAChB,2BAAqB,KAAK;AAAA,IAC5B;AAAA,EACF;AACA,QAAM,QAAQ,EAAE,SAAS,KAAA;AAEzB,QAAM,OAAO,YAA2B;AACtC,QAAI,CAAC,MAAM,QAAS;AACpB,QAAI;AACF,YAAM,OAAO,MAAM,SAAA;AAEnB,UAAI,SAAS,KAAM;AACnB,UAAI,aAAa,MAAM;AAErB,mBAAW,KAAK;AAChB,6BAAqB,KAAK;AAC1B;AAAA,MACF;AACA,UAAI,CAAC,sBAAsB,KAAK,UAAU;AAKxC,gBAAQ;AAAA,UACN;AAAA,UACA,EAAE,KAAA;AAAA,QAAK;AAET,mBAAW,KAAK;AAChB,6BAAqB;AACrB;AAAA,MACF;AAMA,YAAM,OAAO,KAAK,WACd,KAAK,MACL,IAAI,IAAI,CAAC,GAAG,UAAU,GAAG,KAAK,GAAG,CAAC;AACtC,iBAAW,SAAS,cAAc,UAAU,MAAM,KAAK,IAAA,CAAK,GAAG;AAC7D,gBAAQ,KAAK;AAAA,MACf;AACA,iBAAW;AAAA,IACb,SAAS,OAAO;AAEd,cAAQ,QAAQ,8BAA8B,EAAE,MAAM,OAAO;AAAA,IAC/D;AAAA,EACF;AAEA,QAAM,QAAQ,YAAY,MAAM,KAAK,KAAA,GAAQ,UAAU;AAEvD,MAAI,OAAO,MAAM,UAAU,kBAAkB,MAAA;AAE7C,QAAM,OAAO,MAAqB;AAChC,QAAI,MAAM,SAAS;AACjB,YAAM,UAAU;AAChB,oBAAc,KAAK;AACnB,iBAAW,MAAA;AACX,cAAQ,QAAQ,oBAAoB,SAAS,OAAO;AAAA,IACtD;AACA,WAAO,QAAQ,QAAA;AAAA,EACjB;AACA,QAAM,UAAU,MAAY,KAAK,KAAA;AACjC,UAAQ,QAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM;AAGjE,MAAI,QAAQ,QAAQ,QAAS,MAAK,KAAA;AAElC,SAAO,EAAE,KAAA;AACX;AASA,eAAe,aACb,QACA,MACA,QACA,QACkD;AAClD,QAAM,4BAAY,IAAA;AAClB,MAAI,SAAS;AACb,QAAM,OAAO,OAAO,KAAa,WAAmC;AAClE,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,OAAO,GAAG,KAAK,GAAG;AAAA,IACpC,SAAS,OAAO;AAGd,UAAI,OAAQ,UAAS;AACrB,cAAQ,KAAK,yDAAyD;AAAA,QACpE;AAAA,QACA;AAAA,MAAA,CACD;AACD;AAAA,IACF;AACA,eAAW,SAAS,SAAS;AAC3B,UAAI,OAAO,SAAS,MAAM,IAAI,EAAG;AACjC,UAAI,MAAM,SAAS,aAAa,KAAK,MAAM,MAAM,KAAK;AAAA,UACjD,OAAM,IAAI,MAAM,IAAI;AAAA,IAC3B;AAAA,EACF;AACA,QAAM,KAAK,MAAM,IAAI;AACrB,SAAO,EAAE,OAAO,OAAA;AAClB;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@ngrok/ngrok": "^1.0.0",
47
- "@tanstack/ai": "^0.39.1"
47
+ "@tanstack/ai": "^0.41.0"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@ngrok/ngrok": {
@@ -54,7 +54,7 @@
54
54
  "devDependencies": {
55
55
  "@ngrok/ngrok": "^1.7.0",
56
56
  "@vitest/coverage-v8": "4.0.14",
57
- "@tanstack/ai": "0.39.1"
57
+ "@tanstack/ai": "0.41.0"
58
58
  },
59
59
  "scripts": {
60
60
  "build": "vite build",
@@ -0,0 +1,240 @@
1
+ import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'
2
+ import type { InternalLogger } from '@tanstack/ai/adapter-internals'
3
+ import type { SandboxHandle } from './contracts'
4
+
5
+ /** Path relative to the repo/workspace root, POSIX form. */
6
+ function relTo(root: string, path: string): string {
7
+ const prefix = root.endsWith('/') ? root : `${root}/`
8
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path
9
+ }
10
+
11
+ /**
12
+ * POSIX single-quote escape for embedding a value in a shell command.
13
+ */
14
+ function q(value: string): string {
15
+ return `'${value.replace(/'/g, `'\\''`)}'`
16
+ }
17
+
18
+ /**
19
+ * Unified add-patch for a brand-new file, closely following the shape `git
20
+ * diff` produces for an added file (`diff --git` header + `new file mode` +
21
+ * `--- /dev/null` + `+++ b/<rel>`), so synthesized `create` diffs align with
22
+ * the real `git diff` output emitted for `change` events. `rel` must be the
23
+ * repo-root-relative POSIX path (like git's). Reproduces git's `\ No newline
24
+ * at end of file` marker and the header-only form for a zero-byte file, so a
25
+ * consumer applying the patch reconstructs the file byte-for-byte. It is not
26
+ * byte-identical to git — it omits the `index <hash>..<hash>` line and always
27
+ * writes the `+1,N` hunk count (git omits `,1`) — but both are valid
28
+ * unified-diff and accepted by `git apply`/`patch`.
29
+ */
30
+ function synthesizeAddPatch(rel: string, content: string): string {
31
+ const header = `diff --git a/${rel} b/${rel}\nnew file mode 100644\n`
32
+ // A zero-byte new file has no hunk in git's output — just the header.
33
+ if (content === '') return header
34
+ const hasFinalNewline = content.endsWith('\n')
35
+ const lines = content.replace(/\n$/, '').split('\n')
36
+ const body = lines.map((l) => `+${l}`).join('\n')
37
+ return (
38
+ header +
39
+ `--- /dev/null\n` +
40
+ `+++ b/${rel}\n` +
41
+ `@@ -0,0 +1,${lines.length} @@\n` +
42
+ body +
43
+ (hasFinalNewline ? '\n' : '\n\\n')
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
49
+ * the live handle. `baseSha` is the session baseline (`''` when the workspace
50
+ * isn't a git repo). Never throws — every git/fs failure falls back to `''`
51
+ * (or a synthesized add-patch), but is logged first via `logger` so a failure
52
+ * is observable instead of silently becoming empty data.
53
+ */
54
+ export function buildFileHookEvent(
55
+ handle: SandboxHandle,
56
+ root: string,
57
+ baseSha: string,
58
+ event: SandboxFileEvent,
59
+ logger?: InternalLogger,
60
+ ): SandboxFileHookEvent {
61
+ const after = async (): Promise<string> => {
62
+ if (event.type === 'delete') return ''
63
+ try {
64
+ return await handle.fs.read(event.path)
65
+ } catch (error) {
66
+ logger?.warn('sandbox after() failed to read file', {
67
+ path: event.path,
68
+ error,
69
+ })
70
+ return ''
71
+ }
72
+ }
73
+ const before = async (): Promise<string> => {
74
+ if (baseSha === '') return ''
75
+ const rel = relTo(root, event.path)
76
+ try {
77
+ const res = await handle.process.exec(
78
+ `git show ${q(baseSha)}:${q(rel)}`,
79
+ { cwd: root },
80
+ )
81
+ if (res.exitCode === 0) return res.stdout
82
+ // Non-zero exit is EXPECTED when the file didn't exist at the baseline
83
+ // (a newly created file) — git exits 128 with "exists on disk, but not
84
+ // in <sha>". Log under `sandbox` (off by default) rather than warn so a
85
+ // create event's before() doesn't spam a warning on every new file.
86
+ logger?.sandbox('before() git show non-zero exit', {
87
+ path: event.path,
88
+ exitCode: res.exitCode,
89
+ stderr: res.stderr,
90
+ })
91
+ return ''
92
+ } catch (error) {
93
+ logger?.warn('sandbox before() git show failed', {
94
+ path: event.path,
95
+ error,
96
+ })
97
+ return ''
98
+ }
99
+ }
100
+ // Empty `git diff` fallback: `git diff <sha> -- <path>` shows nothing for a
101
+ // file git isn't tracking, so an untracked file (the common agent action —
102
+ // and every subsequent edit to it, which arrives as a `change`, not just the
103
+ // first `create`) yields empty stdout even though it has content. Synthesize
104
+ // an add-patch, but ONLY when the file is genuinely untracked at the
105
+ // baseline — an empty diff for a *tracked* file means "identical to
106
+ // baseline", a real no-op that must stay empty. Presence at `baseSha`
107
+ // distinguishes them, probed below via the `git show` EXIT CODE (NOT
108
+ // `before()`'s `''`, which also means "git show threw" — see the inline note).
109
+ // ponytail: reached only when `git diff` came back empty — the common case
110
+ // for an untracked file (and every edit to it), rare for a tracked one. It
111
+ // spends up to two extra subprocesses (`git check-ignore`, then `git show`)
112
+ // plus one `after()` read per such event; both verdicts are invariant per
113
+ // path across a run, but memoizing them would need cross-event state the
114
+ // per-event accessor doesn't hold. Add a per-path cache in the watcher if
115
+ // edit-burst latency matters.
116
+ const synthesizeIfUntracked = async (rel: string): Promise<string> => {
117
+ const content = await after()
118
+ if (content === '') return '' // deleted / empty / unreadable — nothing to add
119
+ // Don't expose the CONTENTS of a git-ignored file (`.env`, credentials,
120
+ // build artifacts, …) in the diff feed. The file event still fires so
121
+ // consumers are notified it changed — only its diff is withheld. A
122
+ // force-added, ignored-yet-TRACKED file produces a non-empty `git diff`
123
+ // above and never reaches here, so its real diff is unaffected.
124
+ try {
125
+ const ignored = await handle.process.exec(
126
+ `git check-ignore -q -- ${q(rel)}`,
127
+ { cwd: root },
128
+ )
129
+ // check-ignore: exit 0 ⇒ path is ignored; 1 ⇒ not ignored; 128 ⇒ error.
130
+ if (ignored.exitCode === 0) {
131
+ logger?.sandbox('sandbox diff() withheld for git-ignored file', {
132
+ path: event.path,
133
+ })
134
+ return ''
135
+ }
136
+ if (ignored.exitCode !== 1) {
137
+ // Not the expected "not ignored" (1) — a real check-ignore error (128:
138
+ // corrupt repo, bad invocation). Same anomaly class as the throw below,
139
+ // so `warn`. We still fall through and diff, so a broken probe never
140
+ // silently withholds; but log it, or an error here would look exactly
141
+ // like "not ignored" and could expose a would-be-withheld file's diff.
142
+ logger?.warn('sandbox diff() git check-ignore non-zero exit', {
143
+ path: event.path,
144
+ exitCode: ignored.exitCode,
145
+ stderr: ignored.stderr,
146
+ })
147
+ }
148
+ } catch (error) {
149
+ // check-ignore threw (git/exec broken) — same anomaly class as the other
150
+ // git execs here, so `warn`. We fall through and diff as usual rather
151
+ // than withhold, so a broken probe can't hide a legitimate diff.
152
+ logger?.warn('sandbox diff() git check-ignore failed', {
153
+ path: event.path,
154
+ error,
155
+ })
156
+ }
157
+ // Distinguish "absent at the baseline (untracked)" from "present at the
158
+ // baseline" by the git-show EXIT CODE, not by before()'s `''` — which also
159
+ // means "git show threw". Conflating a transient git-show failure with
160
+ // untracked would fabricate a full-file add-patch for an unchanged tracked
161
+ // file the agent never touched.
162
+ try {
163
+ const res = await handle.process.exec(
164
+ `git show ${q(baseSha)}:${q(rel)}`,
165
+ { cwd: root },
166
+ )
167
+ // exit 0 ⇒ tracked; `git diff` was already empty ⇒ identical to baseline
168
+ // ⇒ genuine no-op.
169
+ if (res.exitCode === 0) return ''
170
+ // Non-zero is the EXPECTED "absent at baseline ⇒ untracked" case (exit
171
+ // 128), but a genuine git-show error (bad object, corrupt repo, invalid
172
+ // sha) also exits non-zero and would silently fabricate a full-file
173
+ // add-patch. Log it under `sandbox` (like `before()` does) so a
174
+ // persistent probe error is greppable, then fall back to synthesize.
175
+ logger?.sandbox(
176
+ 'sandbox diff() tracked-ness probe non-zero exit (treating as untracked)',
177
+ { path: event.path, exitCode: res.exitCode, stderr: res.stderr },
178
+ )
179
+ return synthesizeAddPatch(rel, content)
180
+ } catch (error) {
181
+ // Uncertain — don't fabricate a full-file add-patch on a probe failure.
182
+ logger?.warn('sandbox diff() tracked-ness probe failed', {
183
+ path: event.path,
184
+ error,
185
+ })
186
+ return ''
187
+ }
188
+ }
189
+ const diff = async (): Promise<string> => {
190
+ if (baseSha === '') {
191
+ if (event.type === 'delete') return ''
192
+ return synthesizeAddPatch(relTo(root, event.path), await after())
193
+ }
194
+ // Pathspec must be relative to `root` (like `before()` above) — a bare
195
+ // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git
196
+ // against the filesystem root, not the repo root, and fails with
197
+ // "fatal: Invalid path" whenever the real repo root differs from
198
+ // `/workspace` (e.g. every local-process sandbox).
199
+ const rel = relTo(root, event.path)
200
+ try {
201
+ const res = await handle.process.exec(
202
+ `git diff ${q(baseSha)} -- ${q(rel)}`,
203
+ {
204
+ cwd: root,
205
+ },
206
+ )
207
+ if (res.exitCode !== 0) {
208
+ logger?.warn('sandbox diff() git diff non-zero exit', {
209
+ path: event.path,
210
+ exitCode: res.exitCode,
211
+ stderr: res.stderr,
212
+ })
213
+ return ''
214
+ }
215
+ if (res.stdout !== '') return res.stdout
216
+ return synthesizeIfUntracked(rel)
217
+ } catch (error) {
218
+ logger?.warn('sandbox diff() git diff failed', {
219
+ path: event.path,
220
+ error,
221
+ })
222
+ return ''
223
+ }
224
+ }
225
+ return { ...event, before, after, diff }
226
+ }
227
+
228
+ export interface ResolvedFileEvents {
229
+ enabled: boolean
230
+ diff: boolean
231
+ }
232
+
233
+ /** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
234
+ export function resolveFileEvents(
235
+ opt: boolean | { diff?: boolean } | undefined,
236
+ ): ResolvedFileEvents {
237
+ if (opt === false) return { enabled: false, diff: false }
238
+ if (opt === undefined || opt === true) return { enabled: true, diff: false }
239
+ return { enabled: true, diff: opt.diff === true }
240
+ }