@tanstack/ai-sandbox 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. package/src/store.ts +0 -83
@@ -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'\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":"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"}
1
+ {"version":3,"file":"watch.js","names":[],"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"],"mappings":"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"}
@@ -119,7 +119,7 @@ export interface WorkspaceDefinition {
119
119
  /**
120
120
  * Typed secret references. The underlying values are injected into the
121
121
  * sandbox env at create/resume — NEVER written to snapshots, the
122
- * SandboxStore, or the event log.
122
+ * SandboxInstanceStore, or the event log.
123
123
  */
124
124
  secrets?: Secrets;
125
125
  /** Workspace root inside the sandbox. Defaults to `/workspace`. */
@@ -1,42 +1,63 @@
1
+ //#region src/workspace.ts
2
+ /** Clone a git repo into the workspace. `githubRepo` is a convenience wrapper. */
1
3
  function gitSource(input) {
2
- return { type: "git", ...input };
4
+ return {
5
+ type: "git",
6
+ ...input
7
+ };
3
8
  }
4
9
  function githubRepo(input) {
5
- const url = input.repo.startsWith("http") ? input.repo : `https://github.com/${input.repo}.git`;
6
- return {
7
- type: "git",
8
- url,
9
- ref: input.ref,
10
- auth: input.auth,
11
- depth: input.depth
12
- };
10
+ return {
11
+ type: "git",
12
+ url: input.repo.startsWith("http") ? input.repo : `https://github.com/${input.repo}.git`,
13
+ ref: input.ref,
14
+ auth: input.auth,
15
+ depth: input.depth
16
+ };
13
17
  }
14
18
  function localSource(path) {
15
- return { type: "local", path };
19
+ return {
20
+ type: "local",
21
+ path
22
+ };
16
23
  }
24
+ /** Write a file (e.g. CLAUDE.md) into the workspace / harness config. */
17
25
  function fileSkill(input) {
18
- return { kind: "file", ...input };
26
+ return {
27
+ kind: "file",
28
+ ...input
29
+ };
19
30
  }
31
+ /** Reference a named agent skill the harness should load. */
20
32
  function agentSkill(name) {
21
- return { kind: "agent-skill", name };
33
+ return {
34
+ kind: "agent-skill",
35
+ name
36
+ };
22
37
  }
38
+ /** Project an MCP server into the harness. Header values may be SecretRefs. */
23
39
  function mcpSkill(name, config) {
24
- return { kind: "mcp", name, config };
25
- }
40
+ return {
41
+ kind: "mcp",
42
+ name,
43
+ config
44
+ };
45
+ }
46
+ /**
47
+ * Clone a git repository as a workspace skill (e.g. a private skill repo).
48
+ * The clone is performed during bootstrap; `secret` is resolved from the
49
+ * workspace `secrets` registry at that time.
50
+ */
26
51
  function gitSkill(input) {
27
- return { kind: "git", ...input };
52
+ return {
53
+ kind: "git",
54
+ ...input
55
+ };
28
56
  }
29
57
  function defineWorkspace(definition) {
30
- return definition;
31
- }
32
- export {
33
- agentSkill,
34
- defineWorkspace,
35
- fileSkill,
36
- gitSkill,
37
- gitSource,
38
- githubRepo,
39
- localSource,
40
- mcpSkill
41
- };
42
- //# sourceMappingURL=workspace.js.map
58
+ return definition;
59
+ }
60
+ //#endregion
61
+ export { agentSkill, defineWorkspace, fileSkill, gitSkill, gitSource, githubRepo, localSource, mcpSkill };
62
+
63
+ //# sourceMappingURL=workspace.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"workspace.js","sources":["../../src/workspace.ts"],"sourcesContent":["import type { SetupInput } from './setup-plan'\nimport type { BearerRef, SecretRef, Secrets } from './secrets'\n\n/**\n * Workspace definition — the portable description of what the agent sees\n * inside the sandbox. Each harness adapter PROJECTS this into its own native\n * format via `projectWorkspace()` (e.g. Claude Code → CLAUDE.md + .claude/skills\n * + --mcp-config). The definition itself is provider- and harness-agnostic.\n */\n\n/** Where the working tree comes from. */\nexport type WorkspaceSource =\n | {\n type: 'git'\n url: string\n ref?: string\n auth?: { username?: string; token: string }\n /**\n * Clone depth. Defaults to `1` (shallow). Pass a number for a specific\n * depth, or `'full'` to fetch the entire history.\n */\n depth?: number | 'full'\n }\n | { type: 'local'; path: string }\n | { type: 'none' }\n\n/** Clone a git repo into the workspace. `githubRepo` is a convenience wrapper. */\nexport function gitSource(input: {\n url: string\n ref?: string\n auth?: { username?: string; token: string }\n depth?: number | 'full'\n}): WorkspaceSource {\n return { type: 'git', ...input }\n}\n\nexport function githubRepo(input: {\n repo: string\n ref?: string\n auth?: { username?: string; token: string }\n depth?: number | 'full'\n}): WorkspaceSource {\n const url = input.repo.startsWith('http')\n ? input.repo\n : `https://github.com/${input.repo}.git`\n return {\n type: 'git',\n url,\n ref: input.ref,\n auth: input.auth,\n depth: input.depth,\n }\n}\n\nexport function localSource(path: string): WorkspaceSource {\n return { type: 'local', path }\n}\n\n/**\n * An MCP server config where header names/values may be plain strings or\n * unresolved SecretRef values. Secrets are resolved by each harness projector\n * at projection time — never at definition time.\n */\nexport type McpConfig = {\n headers?: Record<string, string | SecretRef | BearerRef>\n [key: string]: unknown\n}\n\n/** A unit of agent guidance/config projected into the harness's native format. */\nexport type WorkspaceSkill =\n | { kind: 'file'; path: string; content: string }\n | { kind: 'agent-skill'; name: string }\n | { kind: 'mcp'; name: string; config: McpConfig }\n | {\n kind: 'git'\n /** Short `owner/repo` or a full HTTPS URL. */\n repo: string\n /** Optional SecretRef for private-repo authentication. */\n secret?: SecretRef\n /** Absolute path inside the sandbox to clone into. Defaults to a `.tanstack-skills/<repo>` dir under the workspace root. */\n into?: string\n }\n\n/** Write a file (e.g. CLAUDE.md) into the workspace / harness config. */\nexport function fileSkill(input: {\n path: string\n content: string\n}): WorkspaceSkill {\n return { kind: 'file', ...input }\n}\n\n/** Reference a named agent skill the harness should load. */\nexport function agentSkill(name: string): WorkspaceSkill {\n return { kind: 'agent-skill', name }\n}\n\n/** Project an MCP server into the harness. Header values may be SecretRefs. */\nexport function mcpSkill(name: string, config: McpConfig): WorkspaceSkill {\n return { kind: 'mcp', name, config }\n}\n\n/**\n * Clone a git repository as a workspace skill (e.g. a private skill repo).\n * The clone is performed during bootstrap; `secret` is resolved from the\n * workspace `secrets` registry at that time.\n */\nexport function gitSkill(input: {\n repo: string\n secret?: SecretRef\n into?: string\n}): WorkspaceSkill {\n return { kind: 'git', ...input }\n}\n\nexport type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'auto'\n\nexport interface WorkspaceDefinition {\n source: WorkspaceSource\n /** Defaults to `'auto'` — detect from the lockfile after the source lands. */\n packageManager?: PackageManager\n /** Commands run once during bootstrap. Accepts a string array (serial) or a builder function for serial/parallel groups. */\n setup?: SetupInput\n /** Named commands the agent/user can invoke (e.g. { test: 'pnpm test' }). */\n scripts?: Record<string, string>\n /** Guidance/config projected into the harness. */\n skills?: Array<WorkspaceSkill>\n /**\n * Natural-language instructions written to AGENTS.md (and symlinked as\n * CLAUDE.md, GEMINI.md, etc.) inside the sandbox during bootstrap.\n */\n instructions?: string\n /**\n * Harness plugin identifiers installed idempotently by each harness\n * projector (e.g. `['@anthropic/plugin-foo']` for Claude Code).\n */\n plugins?: Array<string>\n /**\n * Typed secret references. The underlying values are injected into the\n * sandbox env at create/resume — NEVER written to snapshots, the\n * SandboxStore, or the event log.\n */\n secrets?: Secrets\n /** Workspace root inside the sandbox. Defaults to `/workspace`. */\n root?: string\n}\n\nexport function defineWorkspace(\n definition: WorkspaceDefinition,\n): WorkspaceDefinition {\n return definition\n}\n"],"names":[],"mappings":"AA2BO,SAAS,UAAU,OAKN;AAClB,SAAO,EAAE,MAAM,OAAO,GAAG,MAAA;AAC3B;AAEO,SAAS,WAAW,OAKP;AAClB,QAAM,MAAM,MAAM,KAAK,WAAW,MAAM,IACpC,MAAM,OACN,sBAAsB,MAAM,IAAI;AACpC,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IACA,KAAK,MAAM;AAAA,IACX,MAAM,MAAM;AAAA,IACZ,OAAO,MAAM;AAAA,EAAA;AAEjB;AAEO,SAAS,YAAY,MAA+B;AACzD,SAAO,EAAE,MAAM,SAAS,KAAA;AAC1B;AA4BO,SAAS,UAAU,OAGP;AACjB,SAAO,EAAE,MAAM,QAAQ,GAAG,MAAA;AAC5B;AAGO,SAAS,WAAW,MAA8B;AACvD,SAAO,EAAE,MAAM,eAAe,KAAA;AAChC;AAGO,SAAS,SAAS,MAAc,QAAmC;AACxE,SAAO,EAAE,MAAM,OAAO,MAAM,OAAA;AAC9B;AAOO,SAAS,SAAS,OAIN;AACjB,SAAO,EAAE,MAAM,OAAO,GAAG,MAAA;AAC3B;AAkCO,SAAS,gBACd,YACqB;AACrB,SAAO;AACT;"}
1
+ {"version":3,"file":"workspace.js","names":[],"sources":["../../src/workspace.ts"],"sourcesContent":["import type { SetupInput } from './setup-plan'\nimport type { BearerRef, SecretRef, Secrets } from './secrets'\n\n/**\n * Workspace definition — the portable description of what the agent sees\n * inside the sandbox. Each harness adapter PROJECTS this into its own native\n * format via `projectWorkspace()` (e.g. Claude Code → CLAUDE.md + .claude/skills\n * + --mcp-config). The definition itself is provider- and harness-agnostic.\n */\n\n/** Where the working tree comes from. */\nexport type WorkspaceSource =\n | {\n type: 'git'\n url: string\n ref?: string\n auth?: { username?: string; token: string }\n /**\n * Clone depth. Defaults to `1` (shallow). Pass a number for a specific\n * depth, or `'full'` to fetch the entire history.\n */\n depth?: number | 'full'\n }\n | { type: 'local'; path: string }\n | { type: 'none' }\n\n/** Clone a git repo into the workspace. `githubRepo` is a convenience wrapper. */\nexport function gitSource(input: {\n url: string\n ref?: string\n auth?: { username?: string; token: string }\n depth?: number | 'full'\n}): WorkspaceSource {\n return { type: 'git', ...input }\n}\n\nexport function githubRepo(input: {\n repo: string\n ref?: string\n auth?: { username?: string; token: string }\n depth?: number | 'full'\n}): WorkspaceSource {\n const url = input.repo.startsWith('http')\n ? input.repo\n : `https://github.com/${input.repo}.git`\n return {\n type: 'git',\n url,\n ref: input.ref,\n auth: input.auth,\n depth: input.depth,\n }\n}\n\nexport function localSource(path: string): WorkspaceSource {\n return { type: 'local', path }\n}\n\n/**\n * An MCP server config where header names/values may be plain strings or\n * unresolved SecretRef values. Secrets are resolved by each harness projector\n * at projection time — never at definition time.\n */\nexport type McpConfig = {\n headers?: Record<string, string | SecretRef | BearerRef>\n [key: string]: unknown\n}\n\n/** A unit of agent guidance/config projected into the harness's native format. */\nexport type WorkspaceSkill =\n | { kind: 'file'; path: string; content: string }\n | { kind: 'agent-skill'; name: string }\n | { kind: 'mcp'; name: string; config: McpConfig }\n | {\n kind: 'git'\n /** Short `owner/repo` or a full HTTPS URL. */\n repo: string\n /** Optional SecretRef for private-repo authentication. */\n secret?: SecretRef\n /** Absolute path inside the sandbox to clone into. Defaults to a `.tanstack-skills/<repo>` dir under the workspace root. */\n into?: string\n }\n\n/** Write a file (e.g. CLAUDE.md) into the workspace / harness config. */\nexport function fileSkill(input: {\n path: string\n content: string\n}): WorkspaceSkill {\n return { kind: 'file', ...input }\n}\n\n/** Reference a named agent skill the harness should load. */\nexport function agentSkill(name: string): WorkspaceSkill {\n return { kind: 'agent-skill', name }\n}\n\n/** Project an MCP server into the harness. Header values may be SecretRefs. */\nexport function mcpSkill(name: string, config: McpConfig): WorkspaceSkill {\n return { kind: 'mcp', name, config }\n}\n\n/**\n * Clone a git repository as a workspace skill (e.g. a private skill repo).\n * The clone is performed during bootstrap; `secret` is resolved from the\n * workspace `secrets` registry at that time.\n */\nexport function gitSkill(input: {\n repo: string\n secret?: SecretRef\n into?: string\n}): WorkspaceSkill {\n return { kind: 'git', ...input }\n}\n\nexport type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'auto'\n\nexport interface WorkspaceDefinition {\n source: WorkspaceSource\n /** Defaults to `'auto'` — detect from the lockfile after the source lands. */\n packageManager?: PackageManager\n /** Commands run once during bootstrap. Accepts a string array (serial) or a builder function for serial/parallel groups. */\n setup?: SetupInput\n /** Named commands the agent/user can invoke (e.g. { test: 'pnpm test' }). */\n scripts?: Record<string, string>\n /** Guidance/config projected into the harness. */\n skills?: Array<WorkspaceSkill>\n /**\n * Natural-language instructions written to AGENTS.md (and symlinked as\n * CLAUDE.md, GEMINI.md, etc.) inside the sandbox during bootstrap.\n */\n instructions?: string\n /**\n * Harness plugin identifiers installed idempotently by each harness\n * projector (e.g. `['@anthropic/plugin-foo']` for Claude Code).\n */\n plugins?: Array<string>\n /**\n * Typed secret references. The underlying values are injected into the\n * sandbox env at create/resume — NEVER written to snapshots, the\n * SandboxInstanceStore, or the event log.\n */\n secrets?: Secrets\n /** Workspace root inside the sandbox. Defaults to `/workspace`. */\n root?: string\n}\n\nexport function defineWorkspace(\n definition: WorkspaceDefinition,\n): WorkspaceDefinition {\n return definition\n}\n"],"mappings":";;AA2BA,SAAgB,UAAU,OAKN;CAClB,OAAO;EAAE,MAAM;EAAO,GAAG;CAAM;AACjC;AAEA,SAAgB,WAAW,OAKP;CAIlB,OAAO;EACL,MAAM;EACN,KALU,MAAM,KAAK,WAAW,MAAM,IACpC,MAAM,OACN,sBAAsB,MAAM,KAAK;EAInC,KAAK,MAAM;EACX,MAAM,MAAM;EACZ,OAAO,MAAM;CACf;AACF;AAEA,SAAgB,YAAY,MAA+B;CACzD,OAAO;EAAE,MAAM;EAAS;CAAK;AAC/B;;AA4BA,SAAgB,UAAU,OAGP;CACjB,OAAO;EAAE,MAAM;EAAQ,GAAG;CAAM;AAClC;;AAGA,SAAgB,WAAW,MAA8B;CACvD,OAAO;EAAE,MAAM;EAAe;CAAK;AACrC;;AAGA,SAAgB,SAAS,MAAc,QAAmC;CACxE,OAAO;EAAE,MAAM;EAAO;EAAM;CAAO;AACrC;;;;;;AAOA,SAAgB,SAAS,OAIN;CACjB,OAAO;EAAE,MAAM;EAAO,GAAG;CAAM;AACjC;AAkCA,SAAgB,gBACd,YACqB;CACrB,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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",
@@ -19,7 +19,8 @@
19
19
  "agent",
20
20
  "coding-agent",
21
21
  "isolation",
22
- "workspace"
22
+ "workspace",
23
+ "tanstack-intent"
23
24
  ],
24
25
  "type": "module",
25
26
  "module": "./dist/esm/index.js",
@@ -32,6 +33,10 @@
32
33
  "./ngrok": {
33
34
  "types": "./dist/esm/ngrok.d.ts",
34
35
  "import": "./dist/esm/ngrok.js"
36
+ },
37
+ "./testkit": {
38
+ "types": "./dist/esm/testkit/conformance.d.ts",
39
+ "import": "./dist/esm/testkit/conformance.js"
35
40
  }
36
41
  },
37
42
  "files": [
@@ -44,24 +49,29 @@
44
49
  },
45
50
  "peerDependencies": {
46
51
  "@ngrok/ngrok": "^1.0.0",
47
- "@tanstack/ai": "^0.41.0"
52
+ "vitest": "^4.1.10",
53
+ "@tanstack/ai": "^0.43.0"
48
54
  },
49
55
  "peerDependenciesMeta": {
50
56
  "@ngrok/ngrok": {
51
57
  "optional": true
58
+ },
59
+ "vitest": {
60
+ "optional": true
52
61
  }
53
62
  },
54
63
  "devDependencies": {
55
64
  "@ngrok/ngrok": "^1.7.0",
56
65
  "@vitest/coverage-v8": "4.0.14",
57
- "@tanstack/ai": "0.41.0"
66
+ "vitest": "^4.1.10",
67
+ "@tanstack/ai": "0.43.0"
58
68
  },
59
69
  "scripts": {
60
70
  "build": "vite build",
61
71
  "clean": "premove ./build ./dist",
62
- "lint:fix": "eslint ./src --fix",
72
+ "lint:fix": "oxlint src --type-aware --fix",
63
73
  "test:build": "publint --strict",
64
- "test:eslint": "eslint ./src",
74
+ "test:oxlint": "oxlint src --type-aware",
65
75
  "test:lib": "vitest",
66
76
  "test:lib:dev": "pnpm test:lib --watch",
67
77
  "test:types": "tsc"