@tanstack/ai-sandbox 0.2.1 → 0.2.2

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.
@@ -0,0 +1,16 @@
1
+ import { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai';
2
+ import { SandboxHandle } from './contracts.js';
3
+ /**
4
+ * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
5
+ * the live handle. `baseSha` is the session baseline (`''` when the workspace
6
+ * isn't a git repo). Never throws.
7
+ */
8
+ export declare function buildFileHookEvent(handle: SandboxHandle, root: string, baseSha: string, event: SandboxFileEvent): SandboxFileHookEvent;
9
+ export interface ResolvedFileEvents {
10
+ enabled: boolean;
11
+ diff: boolean;
12
+ }
13
+ /** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
14
+ export declare function resolveFileEvents(opt: boolean | {
15
+ diff?: boolean;
16
+ } | undefined): ResolvedFileEvents;
@@ -0,0 +1,67 @@
1
+ function relTo(root, path) {
2
+ const prefix = root.endsWith("/") ? root : `${root}/`;
3
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
4
+ }
5
+ function q(value) {
6
+ return `'${value.replace(/'/g, `'\\''`)}'`;
7
+ }
8
+ function synthesizeAddPatch(path, content) {
9
+ const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
10
+ const body = lines.map((l) => `+${l}`).join("\n");
11
+ return `--- /dev/null
12
+ +++ ${path}
13
+ @@ -0,0 +1,${lines.length} @@
14
+ ${body}${body ? "\n" : ""}`;
15
+ }
16
+ function buildFileHookEvent(handle, root, baseSha, event) {
17
+ const after = async () => {
18
+ if (event.type === "delete") return "";
19
+ try {
20
+ return await handle.fs.read(event.path);
21
+ } catch {
22
+ return "";
23
+ }
24
+ };
25
+ const before = async () => {
26
+ if (baseSha === "") return "";
27
+ const rel = relTo(root, event.path);
28
+ try {
29
+ const res = await handle.process.exec(
30
+ `git show ${q(baseSha)}:${q(rel)}`,
31
+ { cwd: root }
32
+ );
33
+ return res.exitCode === 0 ? res.stdout : "";
34
+ } catch {
35
+ return "";
36
+ }
37
+ };
38
+ const diff = async () => {
39
+ if (baseSha === "") {
40
+ if (event.type === "delete") return "";
41
+ return synthesizeAddPatch(event.path, await after());
42
+ }
43
+ const rel = relTo(root, event.path);
44
+ try {
45
+ const res = await handle.process.exec(
46
+ `git diff ${q(baseSha)} -- ${q(rel)}`,
47
+ {
48
+ cwd: root
49
+ }
50
+ );
51
+ return res.exitCode === 0 ? res.stdout : "";
52
+ } catch {
53
+ return "";
54
+ }
55
+ };
56
+ return { ...event, before, after, diff };
57
+ }
58
+ function resolveFileEvents(opt) {
59
+ if (opt === false) return { enabled: false, diff: false };
60
+ if (opt === void 0 || opt === true) return { enabled: true, diff: false };
61
+ return { enabled: true, diff: opt.diff === true };
62
+ }
63
+ export {
64
+ buildFileHookEvent,
65
+ resolveFileEvents
66
+ };
67
+ //# sourceMappingURL=file-diff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-diff.js","sources":["../../src/file-diff.ts"],"sourcesContent":["import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\n\n/** Path relative to the repo/workspace root, POSIX form. */\nfunction relTo(root: string, path: string): string {\n const prefix = root.endsWith('/') ? root : `${root}/`\n return path.startsWith(prefix) ? path.slice(prefix.length) : path\n}\n\n/**\n * POSIX single-quote escape for embedding a value in a shell command.\n */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/** Minimal unified add-patch for a brand-new file (non-git workspaces). */\nfunction synthesizeAddPatch(path: string, content: string): string {\n const lines = content === '' ? [] : content.replace(/\\n$/, '').split('\\n')\n const body = lines.map((l) => `+${l}`).join('\\n')\n return `--- /dev/null\\n+++ ${path}\\n@@ -0,0 +1,${lines.length} @@\\n${body}${body ? '\\n' : ''}`\n}\n\n/**\n * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to\n * the live handle. `baseSha` is the session baseline (`''` when the workspace\n * isn't a git repo). Never throws.\n */\nexport function buildFileHookEvent(\n handle: SandboxHandle,\n root: string,\n baseSha: string,\n event: SandboxFileEvent,\n): SandboxFileHookEvent {\n const after = async (): Promise<string> => {\n if (event.type === 'delete') return ''\n try {\n return await handle.fs.read(event.path)\n } catch {\n return ''\n }\n }\n const before = async (): Promise<string> => {\n if (baseSha === '') return ''\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git show ${q(baseSha)}:${q(rel)}`,\n { cwd: root },\n )\n return res.exitCode === 0 ? res.stdout : ''\n } catch {\n return ''\n }\n }\n const diff = async (): Promise<string> => {\n if (baseSha === '') {\n if (event.type === 'delete') return ''\n return synthesizeAddPatch(event.path, await after())\n }\n // Pathspec must be relative to `root` (like `before()` above) — a bare\n // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git\n // against the filesystem root, not the repo root, and fails with\n // \"fatal: Invalid path\" whenever the real repo root differs from\n // `/workspace` (e.g. every local-process sandbox).\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git diff ${q(baseSha)} -- ${q(rel)}`,\n {\n cwd: root,\n },\n )\n return res.exitCode === 0 ? res.stdout : ''\n } catch {\n return ''\n }\n }\n return { ...event, before, after, diff }\n}\n\nexport interface ResolvedFileEvents {\n enabled: boolean\n diff: boolean\n}\n\n/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */\nexport function resolveFileEvents(\n opt: boolean | { diff?: boolean } | undefined,\n): ResolvedFileEvents {\n if (opt === false) return { enabled: false, diff: false }\n if (opt === undefined || opt === true) return { enabled: true, diff: false }\n return { enabled: true, diff: opt.diff === true }\n}\n"],"names":[],"mappings":"AAIA,SAAS,MAAM,MAAc,MAAsB;AACjD,QAAM,SAAS,KAAK,SAAS,GAAG,IAAI,OAAO,GAAG,IAAI;AAClD,SAAO,KAAK,WAAW,MAAM,IAAI,KAAK,MAAM,OAAO,MAAM,IAAI;AAC/D;AAKA,SAAS,EAAE,OAAuB;AAChC,SAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,CAAC;AACzC;AAGA,SAAS,mBAAmB,MAAc,SAAyB;AACjE,QAAM,QAAQ,YAAY,KAAK,KAAK,QAAQ,QAAQ,OAAO,EAAE,EAAE,MAAM,IAAI;AACzE,QAAM,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,EAAE,KAAK,IAAI;AAChD,SAAO;AAAA,MAAsB,IAAI;AAAA,aAAgB,MAAM,MAAM;AAAA,EAAQ,IAAI,GAAG,OAAO,OAAO,EAAE;AAC9F;AAOO,SAAS,mBACd,QACA,MACA,SACA,OACsB;AACtB,QAAM,QAAQ,YAA6B;AACzC,QAAI,MAAM,SAAS,SAAU,QAAO;AACpC,QAAI;AACF,aAAO,MAAM,OAAO,GAAG,KAAK,MAAM,IAAI;AAAA,IACxC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,SAAS,YAA6B;AAC1C,QAAI,YAAY,GAAI,QAAO;AAC3B,UAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAClC,QAAI;AACF,YAAM,MAAM,MAAM,OAAO,QAAQ;AAAA,QAC/B,YAAY,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC;AAAA,QAChC,EAAE,KAAK,KAAA;AAAA,MAAK;AAEd,aAAO,IAAI,aAAa,IAAI,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,OAAO,YAA6B;AACxC,QAAI,YAAY,IAAI;AAClB,UAAI,MAAM,SAAS,SAAU,QAAO;AACpC,aAAO,mBAAmB,MAAM,MAAM,MAAM,OAAO;AAAA,IACrD;AAMA,UAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAClC,QAAI;AACF,YAAM,MAAM,MAAM,OAAO,QAAQ;AAAA,QAC/B,YAAY,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;AAAA,QACnC;AAAA,UACE,KAAK;AAAA,QAAA;AAAA,MACP;AAEF,aAAO,IAAI,aAAa,IAAI,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,EAAE,GAAG,OAAO,QAAQ,OAAO,KAAA;AACpC;AAQO,SAAS,kBACd,KACoB;AACpB,MAAI,QAAQ,MAAO,QAAO,EAAE,SAAS,OAAO,MAAM,MAAA;AAClD,MAAI,QAAQ,UAAa,QAAQ,aAAa,EAAE,SAAS,MAAM,MAAM,MAAA;AACrE,SAAO,EAAE,SAAS,MAAM,MAAM,IAAI,SAAS,KAAA;AAC7C;"}
@@ -2,6 +2,7 @@ import { defineChatMiddleware } from "@tanstack/ai";
2
2
  import { getSandboxRuntime } from "@tanstack/ai/adapter-internals";
3
3
  import { SandboxStoreCapability, LocksCapability, SandboxCapability, provideSandbox, provideSandboxPolicy } from "./capabilities.js";
4
4
  import { computeWorkspaceHash } from "./key.js";
5
+ import { resolveFileEvents, buildFileHookEvent } from "./file-diff.js";
5
6
  import { ProjectionCapability, provideWorkspaceProjection } from "./projection.js";
6
7
  import { resolveSecret } from "./secrets.js";
7
8
  import { watchWorkspace } from "./watch.js";
@@ -53,6 +54,15 @@ function withSandbox(definition) {
53
54
  const handle = await definition.ensure(ensureCtx);
54
55
  provideSandbox(ctx, handle);
55
56
  if (definition.policy) provideSandboxPolicy(ctx, definition.policy);
57
+ const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT;
58
+ let baseSha = "";
59
+ try {
60
+ const shaRes = await handle.process.exec("git rev-parse HEAD", {
61
+ cwd: watchRoot
62
+ });
63
+ if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim();
64
+ } catch {
65
+ }
56
66
  const workspace = definition.workspace;
57
67
  if (workspace !== void 0) {
58
68
  const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT;
@@ -76,24 +86,45 @@ function withSandbox(definition) {
76
86
  }
77
87
  const hooks = definition.hooks;
78
88
  await hooks?.onReady?.(handle);
89
+ const fe = resolveFileEvents(definition.fileEvents);
90
+ const pendingDiffs = [];
79
91
  let watcher;
80
- if (definition.fileEvents !== false) {
92
+ if (fe.enabled) {
81
93
  const runtime = getSandboxRuntime(ctx, { optional: true });
82
94
  watcher = await watchWorkspace(handle, {
83
95
  onEvent: (event) => {
84
- void dispatchDefinitionHooks(hooks, event);
85
- runtime?.emit(event);
96
+ const enriched = buildFileHookEvent(
97
+ handle,
98
+ watchRoot,
99
+ baseSha,
100
+ event
101
+ );
102
+ void dispatchDefinitionHooks(hooks, enriched);
103
+ runtime?.emit(enriched);
104
+ if (fe.diff) {
105
+ pendingDiffs.push(
106
+ enriched.diff().then((diff) => {
107
+ runtime?.emitFileDiff({ path: event.path, diff });
108
+ }).catch(() => void 0)
109
+ );
110
+ }
86
111
  },
87
112
  ...ctx.signal !== void 0 ? { signal: ctx.signal } : {}
88
113
  });
89
114
  }
90
- runState.set(ctx, { handle, ensureCtx, ...watcher ? { watcher } : {} });
115
+ runState.set(ctx, {
116
+ handle,
117
+ ensureCtx,
118
+ pendingDiffs,
119
+ ...watcher ? { watcher } : {}
120
+ });
91
121
  },
92
122
  async onFinish(ctx) {
93
123
  const state = runState.get(ctx);
94
124
  if (!state) return;
95
125
  const { handle, ensureCtx } = state;
96
126
  await state.watcher?.stop();
127
+ await Promise.allSettled(state.pendingDiffs);
97
128
  const lifecycle = definition.lifecycle;
98
129
  if (lifecycle?.snapshot === "after-run" && handle.capabilities.snapshots && handle.snapshot) {
99
130
  const snapshot = await handle.snapshot(`after-run-${ctx.runId}`);
@@ -119,6 +150,7 @@ function withSandbox(definition) {
119
150
  const state = runState.get(ctx);
120
151
  if (!state) return;
121
152
  await state.watcher?.stop();
153
+ await Promise.allSettled(state.pendingDiffs);
122
154
  await definition.destroy(state.ensureCtx);
123
155
  await definition.hooks?.onDestroy?.();
124
156
  },
@@ -126,6 +158,7 @@ function withSandbox(definition) {
126
158
  const state = runState.get(ctx);
127
159
  if (!state) return;
128
160
  await state.watcher?.stop();
161
+ await Promise.allSettled(state.pendingDiffs);
129
162
  await definition.hooks?.onError?.(info.error);
130
163
  if (definition.lifecycle?.destroyOnComplete) {
131
164
  await definition.destroy(state.ensureCtx);
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the optional SandboxStore/Locks\n * capabilities when a persistence middleware supplied them (in-memory\n * fallback otherwise). If `fileEvents` is not false, starts a watcher\n * that dispatches to sandbox-scoped hooks and forwards to the runtime sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { getSandboxRuntime } from '@tanstack/ai/adapter-internals'\nimport {\n LocksCapability,\n SandboxCapability,\n SandboxStoreCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport { computeWorkspaceHash } from './key'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n SandboxFileEvent,\n} from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n handle: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\nfunction buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store: ctx.getOptional(SandboxStoreCapability),\n locks: ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run.\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileEvent,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch {\n // swallowed — one bad hook must not break the run\n }\n }\n}\n\nexport function withSandbox(\n definition: SandboxDefinition,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`.\n optionalRequires: [SandboxStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx)\n const handle = await definition.ensure(ensureCtx)\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n let watcher: SandboxWatchHandle | undefined\n if (definition.fileEvents !== false) {\n const runtime = getSandboxRuntime(ctx, { optional: true })\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n void dispatchDefinitionHooks(hooks, event)\n runtime?.emit(event)\n },\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n })\n }\n\n runState.set(ctx, { handle, ensureCtx, ...(watcher ? { watcher } : {}) })\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n await state.watcher?.stop()\n\n const lifecycle = definition.lifecycle\n\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, _info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"middleware.js","sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the optional SandboxStore/Locks\n * capabilities when a persistence middleware supplied them (in-memory\n * fallback otherwise). If `fileEvents` is not false, starts a watcher\n * that dispatches to sandbox-scoped hooks and forwards to the runtime sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { getSandboxRuntime } from '@tanstack/ai/adapter-internals'\nimport {\n LocksCapability,\n SandboxCapability,\n SandboxStoreCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n handle: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\nfunction buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store: ctx.getOptional(SandboxStoreCapability),\n locks: ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run.\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch {\n // swallowed — one bad hook must not break the run\n }\n }\n}\n\nexport function withSandbox(\n definition: SandboxDefinition,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`.\n optionalRequires: [SandboxStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx)\n const handle = await definition.ensure(ensureCtx)\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim()\n } catch {\n // non-git workspace / exec rejects → baseSha stays '' (accessors fall back)\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n const pendingDiffs: Array<Promise<void>> = []\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n const runtime = getSandboxRuntime(ctx, { optional: true })\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n )\n void dispatchDefinitionHooks(hooks, enriched)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch(() => undefined),\n )\n }\n },\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n })\n }\n\n runState.set(ctx, {\n handle,\n ensureCtx,\n pendingDiffs,\n ...(watcher ? { watcher } : {}),\n })\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n\n const lifecycle = definition.lifecycle\n\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, _info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { SandboxFileEvent } from '@tanstack/ai';
1
+ import { SandboxFileHookEvent } from '@tanstack/ai';
2
2
  import { SandboxHandle, SandboxProvider } from './contracts.js';
3
3
  import { LockStore, SandboxStore } from './store.js';
4
4
  import { SandboxPolicy } from './policy.js';
@@ -8,10 +8,10 @@ import { WorkspaceDefinition } from './workspace.js';
8
8
  * create/change/delete during a chat run; lifecycle hooks fire server-side.
9
9
  */
10
10
  export interface SandboxHooks {
11
- onFile?: (e: SandboxFileEvent) => void | Promise<void>;
12
- onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>;
13
- onFileChange?: (e: SandboxFileEvent) => void | Promise<void>;
14
- onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>;
11
+ onFile?: (e: SandboxFileHookEvent) => void | Promise<void>;
12
+ onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>;
13
+ onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>;
14
+ onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>;
15
15
  onReady?: (handle: SandboxHandle) => void | Promise<void>;
16
16
  onError?: (err: unknown) => void | Promise<void>;
17
17
  onDestroy?: () => void | Promise<void>;
@@ -42,8 +42,11 @@ export interface SandboxConfig {
42
42
  lifecycle?: SandboxLifecycle;
43
43
  /** Sandbox-scoped file/lifecycle hooks. */
44
44
  hooks?: SandboxHooks;
45
- /** Watch the workspace for file events (default true). Set false to disable. */
46
- fileEvents?: boolean;
45
+ /** Watch the workspace for file events (default true). `false` disables the
46
+ * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
47
+ fileEvents?: boolean | {
48
+ diff?: boolean;
49
+ };
47
50
  }
48
51
  /** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
49
52
  export interface SandboxEnsureContext {
@@ -67,8 +70,11 @@ export interface SandboxDefinition {
67
70
  readonly lifecycle?: SandboxLifecycle;
68
71
  /** Sandbox-scoped file/lifecycle hooks. */
69
72
  readonly hooks?: SandboxHooks;
70
- /** Watch the workspace for file events (default true). Set false to disable. */
71
- readonly fileEvents?: boolean;
73
+ /** Watch the workspace for file events (default true). `false` disables the
74
+ * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
75
+ readonly fileEvents?: boolean | {
76
+ diff?: boolean;
77
+ };
72
78
  /** Compound instance key for a given run context. */
73
79
  key: (ctx: SandboxEnsureContext) => string;
74
80
  /** Resume-or-create the sandbox for this thread/run. */
@@ -1 +1 @@
1
- {"version":3,"file":"sandbox.js","sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore, InMemorySandboxStore } from './store'\nimport type { SandboxFileEvent } from '@tanstack/ai'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { LockStore, SandboxStore } from './store'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). Set false to disable. */\n fileEvents?: boolean\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). Set false to disable. */\n readonly fileEvents?: boolean\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxStore()\nconst fallbackLocks = new InMemoryLockStore()\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"names":[],"mappings":";;;;AAoGA,SAAS,cAAc,OAA+C;AACpE,MAAI,UAAU,OAAW,QAAO;AAChC,QAAM,YAAY,WAAW,KAAK,KAAK;AACvC,MAAI,kBAAkB,OAAO,UAAU,CAAC,CAAC,IAAI,KAAK,KAAK;AACvD,QAAM,cAAc,WAAW,KAAK,KAAK;AACzC,MAAI,YAAa,QAAO,OAAO,YAAY,CAAC,CAAC,IAAI,KAAK;AACtD,SAAO;AACT;AAIA,MAAM,gBAAgB,IAAI,qBAAA;AAC1B,MAAM,gBAAgB,IAAI,kBAAA;AAEnB,SAAS,cAAc,QAA0C;AACtE,QAAM,cAAc,CAAC,SAAgD;AAAA,IACnE,UACE,OAAO,WAAW,UAAU,SACxB,GAAG,IAAI,QAAQ,IAAI,IAAI,KAAK,KAC5B,IAAI;AAAA,IACV,WAAW,OAAO;AAAA,IAClB,cAAc,OAAO,SAAS;AAAA,IAC9B,WAAW,OAAO;AAAA,IAClB,QAAQ,IAAI;AAAA,EAAA;AAGd,QAAM,SAAS,OAAO,QAAsD;AAC1E,UAAM,QAAQ,IAAI,SAAS;AAC3B,UAAM,QAAQ,IAAI,SAAS;AAC3B,UAAM,MAAM,kBAAkB,YAAY,GAAG,CAAC;AAC9C,UAAM,OAAO,OAAO,SAAS,aAAA;AAE7B,WAAO,MAAM,SAAS,WAAW,GAAG,IAAI,YAAY;AAClD,YAAM,oBACJ,OAAO,WAAW,aAAa,KAAK,YAAY,gBAAgB;AAClE,YAAM,WAAW,cAAc,OAAO,WAAW,cAAc;AAE/D,YAAM,WAAW,MAAM,MAAM,IAAI,GAAG;AACpC,UAAI,UAAU;AAGZ,cAAM,SACJ,aAAa,UAAa,KAAK,QAAQ,SAAS,YAAY;AAE9D,YAAI,CAAC,QAAQ;AAEX,gBAAM,UAAU,MAAM,OAAO,SAAS,OAAO;AAAA,YAC3C,IAAI,SAAS;AAAA,YACb,QAAQ,IAAI;AAAA,UAAA,CACb;AACD,cAAI,SAAS;AACX,kBAAM,MAAM,OAAO;AAAA,cACjB,GAAG;AAAA,cACH,aAAa,IAAI;AAAA,cACjB,WAAW,KAAK,IAAA;AAAA,YAAI,CACrB;AACD,mBAAO;AAAA,UACT;AAEA,cACE,SAAS,oBACT,KAAK,aACL,OAAO,SAAS,iBAChB;AACA,kBAAM,WAAW,MAAM,OAAO,SAAS,gBAAgB;AAAA,cACrD,YAAY,SAAS;AAAA,cACrB,WAAW,OAAO;AAAA,cAClB,QAAQ,OAAO;AAAA,cACf,KACE,OAAO,WAAW,YAAY,SAC1B,kBAAkB,OAAO,UAAU,OAAO,IAC1C;AAAA,cACN,QAAQ,IAAI;AAAA,YAAA,CACb;AACD,kBAAM,MAAM,OAAO;AAAA,cACjB,GAAG;AAAA,cACH,mBAAmB,SAAS;AAAA,cAC5B,aAAa,IAAI;AAAA,cACjB,WAAW,KAAK,IAAA;AAAA,YAAI,CACrB;AACD,mBAAO;AAAA,UACT;AAAA,QACF;AAAA,MAIF;AAEA,YAAM,UAAU,MAAM,OAAO,SAAS,OAAO;AAAA;AAAA;AAAA,QAG3C,IAAI;AAAA,QACJ,WAAW,OAAO;AAAA,QAClB,QAAQ,OAAO;AAAA,QACf,KACE,OAAO,WAAW,YAAY,SAC1B,kBAAkB,OAAO,UAAU,OAAO,IAC1C;AAAA,QACN,QAAQ,IAAI;AAAA,MAAA,CACb;AAED,UAAI,OAAO,WAAW;AACpB,YAAI;AACF,gBAAM,mBAAmB,SAAS,OAAO,WAAW;AAAA,YAClD,QAAQ,IAAI;AAAA,UAAA,CACb;AAAA,QACH,SAAS,OAAO;AAId,gBAAM,QAAQ,UAAU,MAAM,MAAM;AAAA,UAAC,CAAC;AACtC,gBAAM;AAAA,QACR;AAAA,MACF;AAEA,UAAI;AACJ,UACE,sBAAsB,iBACtB,KAAK,aACL,QAAQ,UACR;AACA,4BAAoB,MAAM,QAAQ,SAAS,aAAa,GAAG;AAAA,MAC7D;AAEA,YAAM,MAAM,OAAO;AAAA,QACjB;AAAA,QACA,UAAU,OAAO,SAAS;AAAA,QAC1B,mBAAmB,QAAQ;AAAA,QAC3B;AAAA,QACA,UAAU,IAAI;AAAA,QACd,aAAa,IAAI;AAAA,QACjB,WAAW,KAAK,IAAA;AAAA,MAAI,CACrB;AACD,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAEA,QAAM,UAAU,OAAO,QAA6C;AAClE,UAAM,QAAQ,IAAI,SAAS;AAC3B,UAAM,MAAM,kBAAkB,YAAY,GAAG,CAAC;AAC9C,UAAM,WAAW,MAAM,MAAM,IAAI,GAAG;AACpC,QAAI,CAAC,SAAU;AACf,UAAM,OAAO,SAAS,QAAQ;AAAA,MAC5B,IAAI,SAAS;AAAA,MACb,QAAQ,IAAI;AAAA,IAAA,CACb;AACD,UAAM,MAAM,OAAO,GAAG;AAAA,EACxB;AAEA,SAAO;AAAA,IACL,IAAI,OAAO;AAAA,IACX,UAAU,OAAO;AAAA,IACjB,WAAW,OAAO;AAAA,IAClB,QAAQ,OAAO;AAAA,IACf,WAAW,OAAO;AAAA,IAClB,OAAO,OAAO;AAAA,IACd,YAAY,OAAO;AAAA,IACnB,KAAK,CAAC,QAAQ,kBAAkB,YAAY,GAAG,CAAC;AAAA,IAChD;AAAA,IACA;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"sandbox.js","sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore, InMemorySandboxStore } from './store'\nimport type { SandboxFileHookEvent } from '@tanstack/ai'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { LockStore, SandboxStore } from './store'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n fileEvents?: boolean | { diff?: boolean }\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n readonly fileEvents?: boolean | { diff?: boolean }\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxStore()\nconst fallbackLocks = new InMemoryLockStore()\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"names":[],"mappings":"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"}
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;
@@ -81,14 +83,14 @@ async function startNativeWatch(handle, options) {
81
83
  }
82
84
  async function startPollWatch(handle, options) {
83
85
  const { onEvent, root, ignore, intervalMs } = options;
84
- const command = buildFindCommand(root, ignore);
86
+ const command = buildFindCommand(ignore);
85
87
  const controller = new AbortController();
86
88
  const snapshot = async () => {
87
89
  const result = await handle.process.exec(command, {
88
90
  cwd: root,
89
91
  signal: controller.signal
90
92
  });
91
- return result.exitCode === 0 ? parseFindOutput(result.stdout) : /* @__PURE__ */ new Map();
93
+ return result.exitCode === 0 ? parseFindOutput(result.stdout, root) : /* @__PURE__ */ new Map();
92
94
  };
93
95
  let previous = await snapshot();
94
96
  const state = { running: true };
@@ -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'\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/**\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 } = 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(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, root)\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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"}
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.2",
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.40.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.40.0"
58
58
  },
59
59
  "scripts": {
60
60
  "build": "vite build",
@@ -0,0 +1,94 @@
1
+ import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'
2
+ import type { SandboxHandle } from './contracts'
3
+
4
+ /** Path relative to the repo/workspace root, POSIX form. */
5
+ function relTo(root: string, path: string): string {
6
+ const prefix = root.endsWith('/') ? root : `${root}/`
7
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path
8
+ }
9
+
10
+ /**
11
+ * POSIX single-quote escape for embedding a value in a shell command.
12
+ */
13
+ function q(value: string): string {
14
+ return `'${value.replace(/'/g, `'\\''`)}'`
15
+ }
16
+
17
+ /** Minimal unified add-patch for a brand-new file (non-git workspaces). */
18
+ function synthesizeAddPatch(path: string, content: string): string {
19
+ const lines = content === '' ? [] : content.replace(/\n$/, '').split('\n')
20
+ const body = lines.map((l) => `+${l}`).join('\n')
21
+ return `--- /dev/null\n+++ ${path}\n@@ -0,0 +1,${lines.length} @@\n${body}${body ? '\n' : ''}`
22
+ }
23
+
24
+ /**
25
+ * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
26
+ * the live handle. `baseSha` is the session baseline (`''` when the workspace
27
+ * isn't a git repo). Never throws.
28
+ */
29
+ export function buildFileHookEvent(
30
+ handle: SandboxHandle,
31
+ root: string,
32
+ baseSha: string,
33
+ event: SandboxFileEvent,
34
+ ): SandboxFileHookEvent {
35
+ const after = async (): Promise<string> => {
36
+ if (event.type === 'delete') return ''
37
+ try {
38
+ return await handle.fs.read(event.path)
39
+ } catch {
40
+ return ''
41
+ }
42
+ }
43
+ const before = async (): Promise<string> => {
44
+ if (baseSha === '') return ''
45
+ const rel = relTo(root, event.path)
46
+ try {
47
+ const res = await handle.process.exec(
48
+ `git show ${q(baseSha)}:${q(rel)}`,
49
+ { cwd: root },
50
+ )
51
+ return res.exitCode === 0 ? res.stdout : ''
52
+ } catch {
53
+ return ''
54
+ }
55
+ }
56
+ const diff = async (): Promise<string> => {
57
+ if (baseSha === '') {
58
+ if (event.type === 'delete') return ''
59
+ return synthesizeAddPatch(event.path, await after())
60
+ }
61
+ // Pathspec must be relative to `root` (like `before()` above) — a bare
62
+ // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git
63
+ // against the filesystem root, not the repo root, and fails with
64
+ // "fatal: Invalid path" whenever the real repo root differs from
65
+ // `/workspace` (e.g. every local-process sandbox).
66
+ const rel = relTo(root, event.path)
67
+ try {
68
+ const res = await handle.process.exec(
69
+ `git diff ${q(baseSha)} -- ${q(rel)}`,
70
+ {
71
+ cwd: root,
72
+ },
73
+ )
74
+ return res.exitCode === 0 ? res.stdout : ''
75
+ } catch {
76
+ return ''
77
+ }
78
+ }
79
+ return { ...event, before, after, diff }
80
+ }
81
+
82
+ export interface ResolvedFileEvents {
83
+ enabled: boolean
84
+ diff: boolean
85
+ }
86
+
87
+ /** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
88
+ export function resolveFileEvents(
89
+ opt: boolean | { diff?: boolean } | undefined,
90
+ ): ResolvedFileEvents {
91
+ if (opt === false) return { enabled: false, diff: false }
92
+ if (opt === undefined || opt === true) return { enabled: true, diff: false }
93
+ return { enabled: true, diff: opt.diff === true }
94
+ }
package/src/middleware.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  provideSandboxPolicy,
25
25
  } from './capabilities'
26
26
  import { computeWorkspaceHash } from './key'
27
+ import { buildFileHookEvent, resolveFileEvents } from './file-diff'
27
28
  import { ProjectionCapability, provideWorkspaceProjection } from './projection'
28
29
  import { resolveSecret } from './secrets'
29
30
  import { watchWorkspace } from './watch'
@@ -33,6 +34,7 @@ import type {
33
34
  ChatMiddlewareContext,
34
35
  DefinedChatMiddleware,
35
36
  SandboxFileEvent,
37
+ SandboxFileHookEvent,
36
38
  } from '@tanstack/ai'
37
39
  import type { SandboxHandle } from './contracts'
38
40
  import type {
@@ -47,6 +49,10 @@ interface SandboxRunState {
47
49
  handle: SandboxHandle
48
50
  ensureCtx: SandboxEnsureContext
49
51
  watcher?: SandboxWatchHandle
52
+ /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`
53
+ * watcher callback, awaited before teardown so a pending diff isn't
54
+ * dropped when the run finishes/aborts/errors mid-computation. */
55
+ pendingDiffs: Array<Promise<void>>
50
56
  }
51
57
 
52
58
  const runState = new WeakMap<object, SandboxRunState>()
@@ -81,7 +87,7 @@ function buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {
81
87
  */
82
88
  async function dispatchDefinitionHooks(
83
89
  hooks: SandboxHooks | undefined,
84
- event: SandboxFileEvent,
90
+ event: SandboxFileHookEvent,
85
91
  ): Promise<void> {
86
92
  if (!hooks) return
87
93
  const typed = (
@@ -122,6 +128,17 @@ export function withSandbox(
122
128
  provideSandbox(ctx, handle)
123
129
  if (definition.policy) provideSandboxPolicy(ctx, definition.policy)
124
130
 
131
+ const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT
132
+ let baseSha = ''
133
+ try {
134
+ const shaRes = await handle.process.exec('git rev-parse HEAD', {
135
+ cwd: watchRoot,
136
+ })
137
+ if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim()
138
+ } catch {
139
+ // non-git workspace / exec rejects → baseSha stays '' (accessors fall back)
140
+ }
141
+
125
142
  const workspace = definition.workspace
126
143
  if (workspace !== undefined) {
127
144
  const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT
@@ -149,19 +166,42 @@ export function withSandbox(
149
166
  const hooks = definition.hooks
150
167
  await hooks?.onReady?.(handle)
151
168
 
169
+ const fe = resolveFileEvents(definition.fileEvents)
170
+ const pendingDiffs: Array<Promise<void>> = []
152
171
  let watcher: SandboxWatchHandle | undefined
153
- if (definition.fileEvents !== false) {
172
+ if (fe.enabled) {
154
173
  const runtime = getSandboxRuntime(ctx, { optional: true })
155
174
  watcher = await watchWorkspace(handle, {
156
175
  onEvent: (event: SandboxFileEvent) => {
157
- void dispatchDefinitionHooks(hooks, event)
158
- runtime?.emit(event)
176
+ const enriched = buildFileHookEvent(
177
+ handle,
178
+ watchRoot,
179
+ baseSha,
180
+ event,
181
+ )
182
+ void dispatchDefinitionHooks(hooks, enriched)
183
+ runtime?.emit(enriched)
184
+ if (fe.diff) {
185
+ pendingDiffs.push(
186
+ enriched
187
+ .diff()
188
+ .then((diff) => {
189
+ runtime?.emitFileDiff({ path: event.path, diff })
190
+ })
191
+ .catch(() => undefined),
192
+ )
193
+ }
159
194
  },
160
195
  ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
161
196
  })
162
197
  }
163
198
 
164
- runState.set(ctx, { handle, ensureCtx, ...(watcher ? { watcher } : {}) })
199
+ runState.set(ctx, {
200
+ handle,
201
+ ensureCtx,
202
+ pendingDiffs,
203
+ ...(watcher ? { watcher } : {}),
204
+ })
165
205
  },
166
206
 
167
207
  async onFinish(ctx) {
@@ -170,6 +210,7 @@ export function withSandbox(
170
210
  const { handle, ensureCtx } = state
171
211
 
172
212
  await state.watcher?.stop()
213
+ await Promise.allSettled(state.pendingDiffs)
173
214
 
174
215
  const lifecycle = definition.lifecycle
175
216
 
@@ -204,6 +245,7 @@ export function withSandbox(
204
245
  if (!state) return
205
246
 
206
247
  await state.watcher?.stop()
248
+ await Promise.allSettled(state.pendingDiffs)
207
249
 
208
250
  // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.
209
251
  // The in-sandbox agent process is not killed by closing its IO stream
@@ -220,6 +262,7 @@ export function withSandbox(
220
262
  if (!state) return
221
263
 
222
264
  await state.watcher?.stop()
265
+ await Promise.allSettled(state.pendingDiffs)
223
266
  await definition.hooks?.onError?.(info.error)
224
267
 
225
268
  // On failure, only tear down when the lifecycle says so; otherwise leave
package/src/sandbox.ts CHANGED
@@ -10,7 +10,7 @@ import { bootstrapWorkspace } from './bootstrap'
10
10
  import { resolveAllSecrets } from './secrets'
11
11
  import { computeSandboxKey } from './key'
12
12
  import { InMemoryLockStore, InMemorySandboxStore } from './store'
13
- import type { SandboxFileEvent } from '@tanstack/ai'
13
+ import type { SandboxFileHookEvent } from '@tanstack/ai'
14
14
  import type { SandboxHandle, SandboxProvider } from './contracts'
15
15
  import type { SandboxKeyInput } from './key'
16
16
  import type { LockStore, SandboxStore } from './store'
@@ -22,10 +22,10 @@ import type { WorkspaceDefinition } from './workspace'
22
22
  * create/change/delete during a chat run; lifecycle hooks fire server-side.
23
23
  */
24
24
  export interface SandboxHooks {
25
- onFile?: (e: SandboxFileEvent) => void | Promise<void>
26
- onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>
27
- onFileChange?: (e: SandboxFileEvent) => void | Promise<void>
28
- onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>
25
+ onFile?: (e: SandboxFileHookEvent) => void | Promise<void>
26
+ onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>
27
+ onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>
28
+ onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>
29
29
  onReady?: (handle: SandboxHandle) => void | Promise<void>
30
30
  onError?: (err: unknown) => void | Promise<void>
31
31
  onDestroy?: () => void | Promise<void>
@@ -59,8 +59,9 @@ export interface SandboxConfig {
59
59
  lifecycle?: SandboxLifecycle
60
60
  /** Sandbox-scoped file/lifecycle hooks. */
61
61
  hooks?: SandboxHooks
62
- /** Watch the workspace for file events (default true). Set false to disable. */
63
- fileEvents?: boolean
62
+ /** Watch the workspace for file events (default true). `false` disables the
63
+ * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
64
+ fileEvents?: boolean | { diff?: boolean }
64
65
  }
65
66
 
66
67
  /** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
@@ -83,8 +84,9 @@ export interface SandboxDefinition {
83
84
  readonly lifecycle?: SandboxLifecycle
84
85
  /** Sandbox-scoped file/lifecycle hooks. */
85
86
  readonly hooks?: SandboxHooks
86
- /** Watch the workspace for file events (default true). Set false to disable. */
87
- readonly fileEvents?: boolean
87
+ /** Watch the workspace for file events (default true). `false` disables the
88
+ * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
89
+ readonly fileEvents?: boolean | { diff?: boolean }
88
90
  /** Compound instance key for a given run context. */
89
91
  key: (ctx: SandboxEnsureContext) => string
90
92
  /** Resume-or-create the sandbox for this thread/run. */
package/src/watch.ts CHANGED
@@ -75,16 +75,28 @@ export function diffSnapshots(
75
75
  return events
76
76
  }
77
77
 
78
- /** Build the `find` command that prints `mtime\tsize\tpath` for every file. */
79
- function buildFindCommand(root: string, ignore: Array<string>): string {
78
+ /**
79
+ * Build the `find` command that prints `mtime\tsize\tpath` for every file.
80
+ * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a
81
+ * provider's `exec` maps only `cwd` onto the real filesystem, not literal path
82
+ * arguments, so `find <virtual-root>` would look at a non-existent host path on
83
+ * mapped-root providers (e.g. local-process). Emitted `%p` values are
84
+ * root-normalized in {@link parseFindOutput}.
85
+ */
86
+ function buildFindCommand(ignore: Array<string>): string {
80
87
  const prunes = ignore
81
88
  .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)
82
89
  .join(' ')
83
- return `find ${q(root)} -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`
90
+ return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`
84
91
  }
85
92
 
86
- /** Parse `find -printf` output into a `Map<path, signature>`. */
87
- function parseFindOutput(stdout: string): Map<string, string> {
93
+ /**
94
+ * Parse `find -printf` output into a `Map<path, signature>`. `find .` prints
95
+ * paths like `./sub/file`; map them back under `root` so event paths match the
96
+ * native-watch shape (`<root>/sub/file`).
97
+ */
98
+ function parseFindOutput(stdout: string, root: string): Map<string, string> {
99
+ const base = root.replace(/\/+$/, '')
88
100
  const snapshot = new Map<string, string>()
89
101
  for (const line of stdout.split('\n')) {
90
102
  if (line === '') continue
@@ -93,7 +105,8 @@ function parseFindOutput(stdout: string): Map<string, string> {
93
105
  if (firstTab === -1 || secondTab === -1) continue
94
106
  const mtime = line.slice(0, firstTab)
95
107
  const size = line.slice(firstTab + 1, secondTab)
96
- const path = line.slice(secondTab + 1)
108
+ const rel = line.slice(secondTab + 1).replace(/^\.\/?/, '')
109
+ const path = rel === '' ? base : `${base}/${rel}`
97
110
  snapshot.set(path, `${mtime}\t${size}`)
98
111
  }
99
112
  return snapshot
@@ -180,7 +193,7 @@ async function startPollWatch(
180
193
  },
181
194
  ): Promise<SandboxWatchHandle> {
182
195
  const { onEvent, root, ignore, intervalMs } = options
183
- const command = buildFindCommand(root, ignore)
196
+ const command = buildFindCommand(ignore)
184
197
  const controller = new AbortController()
185
198
 
186
199
  const snapshot = async (): Promise<Map<string, string>> => {
@@ -189,7 +202,7 @@ async function startPollWatch(
189
202
  signal: controller.signal,
190
203
  })
191
204
  return result.exitCode === 0
192
- ? parseFindOutput(result.stdout)
205
+ ? parseFindOutput(result.stdout, root)
193
206
  : new Map<string, string>()
194
207
  }
195
208