@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
@@ -14,14 +14,23 @@ description: >
14
14
  hooks (onFile/onFileCreate/onFileChange/onFileDelete/onReady/onError/
15
15
  onDestroy) + fileEvents flag, chat middleware sandbox group
16
16
  (defineChatMiddleware sandbox hooks), the sandbox debug category,
17
- watchWorkspace as a low-level building block, and the file.changed /
18
- sandbox.file / claude-code.session-id events. Use whenever a harness adapter
19
- needs a sandbox or when building sandbox providers.
17
+ watchWorkspace as a low-level building block, the file.changed /
18
+ sandbox.file / claude-code.session-id events, and the run journal
19
+ (spawnNdjson journal option, runId uniqueness, follow vs bounded-poll
20
+ reading, alignToStoredLog replay alignment, chunkFingerprint,
21
+ createRunScopedIdGen), and takeover of detached runs (withSandbox
22
+ runs+durability as one opt-in, detach vs cancel via requestRunCancel /
23
+ RUN_CANCEL_REASON, sandboxRunDriver on the resume path, single-writer fencing
24
+ of BOTH the event log and the run record, replay-from-zero with
25
+ JournalReplayDivergedError, the distributed LockStore requirement). Use
26
+ whenever a harness adapter needs a sandbox or when building sandbox providers.
20
27
  type: sub-skill
21
28
  library: tanstack-ai
22
- library_version: '0.1.0'
29
+ library_version: '0.2.4'
23
30
  sources:
24
31
  - 'TanStack/ai:docs/sandbox/overview.md'
32
+ - 'TanStack/ai:docs/sandbox/takeover.md'
33
+ - 'TanStack/ai:docs/sandbox/reaping.md'
25
34
  ---
26
35
 
27
36
  # Sandboxes
@@ -211,6 +220,41 @@ const policy = defineSandboxPolicy({
211
220
  provider + workspace hash + tenant so changing the repo/setup/image starts
212
221
  fresh. Ensure order: resume running → restore snapshot → create + bootstrap.
213
222
 
223
+ ## Instance durability (durable resume)
224
+
225
+ Resume bookkeeping defaults to in-memory (single-process). For cross-process /
226
+ multi-replica resume, implement a durable `SandboxInstanceStore` (BYO) and pass
227
+ it as `withSandbox(sandbox, { instances })`. Pair multi-replica with a
228
+ distributed lock: either `withLocks` from `@tanstack/ai/locks` (ordered
229
+ **before** `withSandbox`) or the `locks` option.
230
+
231
+ ```typescript
232
+ import { chat } from '@tanstack/ai'
233
+ import { InMemoryLockStore, withLocks } from '@tanstack/ai/locks'
234
+ import { withSandbox } from '@tanstack/ai-sandbox'
235
+ // Production: your BYO store — docs/sandbox/durability.md
236
+ import { instanceStore } from './sandbox-instance-store'
237
+
238
+ chat({
239
+ adapter,
240
+ messages,
241
+ middleware: [
242
+ withLocks(new InMemoryLockStore()), // multi-replica: distributed lock
243
+ withSandbox(sandbox, { instances: instanceStore }),
244
+ ],
245
+ })
246
+ ```
247
+
248
+ The store option takes precedence over an ambient `SandboxInstanceStoreCapability`
249
+ (provided by a platform layer via `provideSandboxInstanceStore`), which in turn
250
+ beats the in-memory fallback.
251
+
252
+ Chat transcript durability (`withPersistence`) is independent — compose both
253
+ when the app needs history _and_ instance reuse. Prove adapters with
254
+ `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`.
255
+ Use `defineSandboxInstanceStore({ get, upsert, delete })` for inline typing of a
256
+ BYO store (same pattern as `defineLock` / `defineMessageStore`).
257
+
214
258
  ## File-event hooks
215
259
 
216
260
  Watch the workspace for create/change/delete events. Provider-agnostic: native
@@ -221,11 +265,10 @@ Declare hooks on `defineSandbox({ hooks })` (sandbox-scoped) or on any chat
221
265
  middleware via the `sandbox` group (run-scoped):
222
266
 
223
267
  ```typescript
224
- import {
225
- defineSandbox,
226
- defineChatMiddleware,
227
- withSandbox,
228
- } from '@tanstack/ai-sandbox'
268
+ import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox'
269
+ // `defineChatMiddleware` is core's, not this package's — `@tanstack/ai-sandbox`
270
+ // consumes it too (see its own `src/middleware.ts`).
271
+ import { defineChatMiddleware } from '@tanstack/ai'
229
272
  import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
230
273
 
231
274
  // Sandbox-scoped hooks (all optional):
@@ -302,15 +345,77 @@ resumable cursor**.
302
345
 
303
346
  Core primitives (`@tanstack/ai-sandbox`, transport- and runtime-agnostic):
304
347
 
305
- - **`RunEventLog` / `InMemoryRunEventLog`** — append-only, `seq`-indexed log of a
306
- run's `StreamChunk`s with replay-then-tail reads. A dropped connection / new
307
- tab / hibernated orchestrator reconnect by passing their last-seen `seq`
308
- (`read({ fromSeq })`). `TerminalRunStatus` = `done | error | aborted`.
309
- - **`pipeToRunLog` / `RunController`** — the run driver. `pipeToRunLog` pumps a
310
- `chat()` stream into a log and **never rejects**: a thrown stream error becomes
311
- a terminal `RUN_ERROR` event, so detached clients always observe failures.
312
- `RunController.start` is fire-and-track; `attach(runId, { fromSeq })` tails;
313
- `drain()` awaits in-flight runs (e.g. in a `waitUntil`).
348
+ - **`pipeToRunLog` / `RunController`** (the run driver), built on two of core's
349
+ (`@tanstack/ai`) durable seams: a `RunStore` for the run's lifecycle record
350
+ (the same store `withPersistence` uses for chat history) and a
351
+ `StreamDurability` for its event log (`memoryStream` or `durableStream`).
352
+ `pipeToRunLog(stream, { runs, durability, runId, threadId, signal, logger })`
353
+ pumps a `chat()` stream into both and is **total**: every store/event-log
354
+ call is individually guarded, so it never throws and never rejects. A
355
+ thrown stream error becomes a terminal `RUN_ERROR` event plus the record's
356
+ `error`, so a detached client always observes failures, and a failing store
357
+ write or a failing durability close is recorded through the optional
358
+ `logger` (same `logger?.errors(...)` contract core uses) rather than
359
+ silently absorbed. `threadId` is required. `RunController` wraps a fixed
360
+ `RunDeps = { runs, durability, logger? }`, where **`durability` is a per-run
361
+ factory `(runId) => StreamDurability`, not an instance**:
362
+
363
+ ```typescript
364
+ import { InMemoryRunStore, memoryStream } from '@tanstack/ai'
365
+ import { RunController } from '@tanstack/ai-sandbox'
366
+ import type { StreamChunk } from '@tanstack/ai'
367
+
368
+ const runs = new InMemoryRunStore()
369
+
370
+ export async function driveOne(
371
+ request: Request,
372
+ runId: string,
373
+ threadId: string,
374
+ stream: AsyncIterable<StreamChunk>,
375
+ ): Promise<void> {
376
+ const controller = new RunController({
377
+ runs,
378
+ // A per-run FACTORY. A `StreamDurability` is bound to ONE run, so the log
379
+ // is resolved FROM the runId rather than handed in pre-bound. Whatever you
380
+ // pass MUST return the same instance for the same runId within a process,
381
+ // or `snapshot()` will not see this host's own appends. `memoryStream`
382
+ // keys its log by the run the request names, so every call for one run
383
+ // shares one log; swap in `durableStream(request, options)` in production.
384
+ durability: () => memoryStream(request),
385
+ })
386
+
387
+ const handle = controller.start({ runId, threadId, stream })
388
+ // handle.runId, handle.done (resolves with the terminal RunRecord)
389
+
390
+ // `attach` takes the runId FIRST, because the log it reads is per-run.
391
+ // fromOffset is an opaque string the durability adapter produced; for
392
+ // memoryStream, '-1' replays from the start. The third `signal` argument is
393
+ // optional and stops tailing when it aborts.
394
+ for await (const { offset, chunk } of controller.attach(runId, '-1')) {
395
+ console.log(offset, chunk.type)
396
+ }
397
+
398
+ await handle.done
399
+ await controller.drain() // await every in-flight run, e.g. inside waitUntil
400
+ }
401
+ ```
402
+
403
+ Terminal statuses are `'completed' | 'failed' | 'aborted'` (core's
404
+ `TerminalRunStatus`); a run may also be `'running'` or `'interrupted'`
405
+ (`RunStatus`). Because the log is resolved from the `runId`, a
406
+ `RunController` **is** safe for concurrent runs: each run appends to its own
407
+ log and no run's `close()` terminalizes another's. Two failures that a
408
+ single pre-bound instance used to make reachable are now unrepresentable —
409
+ writing the lifecycle record under one id and the events under another, and
410
+ parallel runs interleaving chunks into one log. Do not hand back the same
411
+ `StreamDurability` for every `runId` to "simplify" the factory; that
412
+ reintroduces both.
413
+
414
+ For a production takeover, do **not** drive `RunController` /
415
+ `pipeToRunLog` by hand — use `sandboxRunDriver` (see
416
+ [Takeover](#takeover-detached-runs-and-single-writer-safety)), which owns the
417
+ claim, the epoch fence, and the quiescence gate.
418
+
314
419
  - **Transport-agnostic tool-bridge** — `createToolBridgeCore` +
315
420
  `handleBridgeJsonRpc` are the portable core; `startHostToolBridge` is the
316
421
  `node:http` host transport. The `ToolBridgeProvisioner` capability injects the
@@ -331,8 +436,535 @@ Cloudflare runtime (`@tanstack/ai-sandbox-cloudflare`):
331
436
  re-exports. Two models via `mode`: `do-drives` (the DO runs `chat()`) and
332
437
  `colocated` (harness + bridge run in-container; the DO is a thin coordinator,
333
438
  pair with `runInContainerHarness` from `/runner`).
334
- - `DurableObjectRunEventLog` mirrors `InMemoryRunEventLog` over DO storage;
335
- `timingSafeBearerEqualWeb` is the Web-Crypto constant-time bearer check.
439
+ - `DurableObjectRunEventLog` mirrors `InMemoryRunEventLog` (both live in
440
+ `@tanstack/ai-sandbox-cloudflare`, exported from its `/agent` entry) over DO
441
+ storage; `timingSafeBearerEqualWeb` is the Web-Crypto constant-time bearer
442
+ check. That package's own `RunStatus`, `TerminalRunStatus`, `RunRecord`, and
443
+ `RunError` describe its event-log vocabulary, which is deliberately distinct
444
+ from core's run-lifecycle types of the same names; the `/agent` entry
445
+ re-exports them under a `Legacy` prefix (`LegacyRunStatus`,
446
+ `LegacyTerminalRunStatus`, `LegacyRunRecord`, `LegacyRunError`) so an app can
447
+ import both this package's run driver and the Cloudflare event log without a
448
+ name collision. `RunEventLog`, `RunEvent`, and `RunEventLogReadOptions` have
449
+ no equivalent in core and keep their plain names.
450
+
451
+ ## Durable runs (the run journal)
452
+
453
+ A harness adapter's agent CLI (Claude Code, Codex, …) writes its NDJSON stdout
454
+ into a run journal instead of a pipe the host holds open: a shell redirect
455
+ appends every line to `/tmp/tanstack-runs/<runId>.ndjson` inside the sandbox
456
+ (stderr goes to a `<runId>.err` sidecar, never mixed in), so the host can
457
+ return without holding a live process handle, and a reader replays the same
458
+ file from byte 0 at any point, including after the original host has died.
459
+
460
+ ```typescript
461
+ import { spawnNdjson } from '@tanstack/ai-sandbox'
462
+
463
+ for await (const event of spawnNdjson(sandbox, agentCommand, {
464
+ cwd,
465
+ journal: { runId }, // durability is opt-in: pass `journal` to route through it
466
+ })) {
467
+ // parsed NDJSON objects, translated by the harness adapter as usual
468
+ }
469
+ ```
470
+
471
+ **A `runId` MUST be unique per run.** The journal is append-only by design (a
472
+ takeover needs the prefix a previous host already wrote to still be there), so
473
+ reusing a `runId` appends to the previous run's journal file. A reader stops at
474
+ the FIRST `{"__exit":N}` sentinel it encounters, which is the earlier run's, so
475
+ the new run appears to emit nothing, or to fail with the previous run's exit
476
+ code. Uniqueness is therefore the caller's job and is deliberately not
477
+ enforced — refusing to append would break the append-only property a takeover
478
+ depends on.
479
+
480
+ **Absence, unlike reuse, IS enforced.** Every harness adapter routes through
481
+ `resolveDurableRunId(options.runId, { durable, adapter, fallback })`, which
482
+ throws `DurableRunIdRequiredError` when sandbox durability is wired and no
483
+ `runId` was passed — a generated id is never minted for a durable run, not even
484
+ one that is discarded, because no successor host could recompute its journal
485
+ path. The `fallback()` to a generated id survives only for non-durable runs,
486
+ where several `chat()` paths legitimately pass `runId` as a conditional spread.
487
+ The journaling adapters are **Claude Code, Codex, and Grok Build**; ACP and
488
+ OpenCode do not journal and pass `durable: false`, so they keep the fallback
489
+ unconditionally today and inherit the enforcement automatically if either gains
490
+ journaling.
491
+
492
+ ### Reading strategy
493
+
494
+ `readJournal` (and `spawnNdjson`'s journal path, via `readJournalNdjson`)
495
+ picks one of two strategies from the sandbox's advertised capabilities, never
496
+ from the provider's name:
497
+
498
+ - **follow** (`tail -f`, started with `handle.process.spawn`), when
499
+ `capabilities.backgroundProcesses && capabilities.killableProcesses` are
500
+ both true. It streams with no polling cost and is stopped by killing the
501
+ `tail` when the consumer stops reading.
502
+ - **bounded poll** (repeated bounded `exec` reads, `DEFAULT_JOURNAL_POLL_MS`,
503
+ 250ms) otherwise. `killableProcesses` is `false` for a provider like
504
+ Cloudflare, whose `kill()` is a documented no-op and whose Workers RPC
505
+ cannot serialize an `AbortSignal` across the boundary, so a `tail -f`
506
+ started there could never be stopped and the poll path is used instead.
507
+
508
+ The bounded read (`journalReadCommand`) base64-frames its output, because
509
+ `exec` closes the encoder's stdin, which flushes it, so the whole frame
510
+ arrives as one complete result. The follow path (`journalFollowCommand`) does
511
+ **not** base64-frame its output: `base64` fully buffers its stdout when that
512
+ stdout is not a tty, so `tail -f file | base64` would emit nothing until the
513
+ libc stdio buffer fills or `tail -f`'s stdin closes, and that stdin never
514
+ closes until the reader kills it, at which point the consumer has already
515
+ stopped waiting for bytes. Dropping the frame on the follow path is safe
516
+ because the journal is line-delimited JSON and every provider already decodes
517
+ stdout text on this path the same way it decodes an agent's own stdout.
518
+
519
+ ### Alignment: replaying without duplicating
520
+
521
+ `alignToStoredLog` reads a run's already-stored event log with
522
+ `durability.snapshot()` (a bounded, point-in-time read; never `read()`, which
523
+ tails and never resolves against a log a dead producer never closed),
524
+ compares each replayed chunk against the stored one by `chunkFingerprint`, and
525
+ forwards only the remainder past what is already stored. Downstream, that
526
+ remainder is always passed to `append`, never `upsert`: the journal path only
527
+ ever appends, because deciding the append point is exactly what alignment
528
+ does. A replayed chunk that does not match the stored chunk at the same index
529
+ throws `JournalReplayDivergedError` rather than forwarding data that might be
530
+ corrupt.
531
+
532
+ Message ids on the journaled path come from `createRunScopedIdGen(runId)`,
533
+ a per-run counter (`<runId>-0`, `<runId>-1`, …) with no clock and no
534
+ randomness, wired as harness translators' `genId`, so re-translating the same
535
+ journal bytes twice reproduces the same ids. `chunkFingerprint` excludes only
536
+ the `timestamp` field (wall-clock, unreproducible) from the comparison;
537
+ everything else, including nested tool-call arguments, participates.
538
+
539
+ **Determinism is translator-level only.** On `ai-claude-code` and `ai-codex`,
540
+ `mergeChunkStreams(translated, channel.stream)` splices host-tool-bridge
541
+ events from a live tool execution into the middle of the stream; those events
542
+ do not occur again on replay. A run that used a bridged tool can still
543
+ diverge on replay for that reason: alignment guarantees reproducibility of
544
+ the translation step, not of everything that can happen during a run.
545
+
546
+ ### Cleanup
547
+
548
+ Once a run reaches its `{"__exit":N}` sentinel, both journal files are
549
+ deleted. A run that terminates while **detached** (no host reading its
550
+ journal) has no reader to observe the sentinel, so nothing deletes its
551
+ journal on the run's own path. `pruneJournals` bounds that: it walks the
552
+ journal directory, asks the run store about each runId it decodes, deletes
553
+ only the journals whose runs are terminal, and keeps everything it cannot
554
+ prove dead (non-terminal, undecodable, or too young to be an orphan). It runs
555
+ from a cron the application schedules, not from a run, so an abandoned journal
556
+ survives until that sweep — this journal, reader, and alignment primitive do
557
+ not clean it up themselves.
558
+
559
+ The journal, the reader, and `alignToStoredLog` are the primitives a takeover is
560
+ built from. `sandboxRunDriver` is what drives one — see the next section.
561
+
562
+ ## Takeover: detached runs and single-writer safety
563
+
564
+ A tab does not last ten minutes; a sandboxed coding agent does. Without
565
+ durability wired, `withSandbox`'s abort path destroys the sandbox on **every**
566
+ abort, deliberately — closing the agent's IO stream does not kill the agent
567
+ process (a Docker `exec` survives its client), so destroying the container is
568
+ the only reliable way to stop it burning tokens. Correct for a cancel, ruinous
569
+ for a refresh.
570
+
571
+ ### Durability is ONE opt-in, not two
572
+
573
+ `withSandbox(sandbox, { runs, durability })`. A run is durable only when **both**
574
+ are present: a record with no event log cannot be replayed, and a log with no
575
+ record cannot be found, claimed, or reaped. There is no half-configured state —
576
+ **pass one and you silently get exactly today's behavior, with no warning**,
577
+ because you have not asked for durability. This is the single easiest way to
578
+ believe you shipped durable runs and have shipped nothing.
579
+
580
+ Pass the **same** `RunStore` chat persistence uses (`persistence.stores.runs`),
581
+ and hand the **same** `StreamDurability` instance to both `withSandbox` and the
582
+ transport, so one record and one log describe the run.
583
+
584
+ ```typescript
585
+ import { memoryStream, toServerSentEventsResponse } from '@tanstack/ai'
586
+ import { withSandbox } from '@tanstack/ai-sandbox'
587
+ import type { AnyChatMiddleware, RunStore } from '@tanstack/ai'
588
+ import type { SandboxDefinition } from '@tanstack/ai-sandbox'
589
+
590
+ export function durableSandboxMiddleware(
591
+ request: Request,
592
+ sandbox: SandboxDefinition,
593
+ runs: RunStore,
594
+ ): { middleware: AnyChatMiddleware; adapter: ReturnType<typeof memoryStream> } {
595
+ // ONE adapter instance, handed to both the middleware and the transport.
596
+ const adapter = memoryStream(request)
597
+ return {
598
+ adapter,
599
+ middleware: withSandbox(sandbox, {
600
+ runs,
601
+ durability: { adapter },
602
+ }),
603
+ }
604
+ }
605
+ // …then: toServerSentEventsResponse(stream, { durability: { adapter } })
606
+ ```
607
+
608
+ `runId` is also **required** for a durable run: `chatStream` throws
609
+ `DurableRunIdRequiredError` when none is passed, because the journal path and
610
+ the deterministic id generator are both derived from it and a successor host can
611
+ only resume a run whose `runId` it can recompute.
612
+
613
+ ### Detach vs cancel — intent NEVER comes from the disconnect
614
+
615
+ A user pressing Stop and a user closing the tab produce the **identical**
616
+ connection close. There is nothing in the disconnect to tell them apart, so
617
+ never try. Intent arrives out of band, and there are exactly two bands, either
618
+ of which is authoritative:
619
+
620
+ 1. **Durable** — `requestRunCancel(runs, runId)` records `cancelRequested` on
621
+ the run record. This is the only channel that reaches a run being driven by a
622
+ **different** host than the one the cancel landed on, which is the normal
623
+ case for a detached run.
624
+ 2. **In-process** — abort the run's own `AbortController` with
625
+ `RUN_CANCEL_REASON`. Core reads that reason back into `AbortInfo`, so
626
+ `AbortInfo.cancelRequested` is `true` for that abort and `false` for a plain
627
+ disconnect. Fast path only.
628
+
629
+ A cancel endpoint should do **both**. `requestRunCancel` deliberately writes no
630
+ status: recording intent is not the same as the run having stopped, and only the
631
+ driver knows when the agent is dead and the sandbox is gone.
632
+
633
+ ```typescript
634
+ import { RUN_CANCEL_REASON, requestRunCancel } from '@tanstack/ai'
635
+ import type { RunStore } from '@tanstack/ai'
636
+
637
+ /** Runs THIS process drives. A run driven by another replica is absent here. */
638
+ const driving = new Map<string, AbortController>()
639
+
640
+ export async function cancelRun(
641
+ runs: RunStore,
642
+ threadId: string,
643
+ ): Promise<void> {
644
+ const active = await runs.findActiveRun(threadId)
645
+ if (!active) return
646
+ // Band 1: durable, so a remote driver observes it on its next teardown.
647
+ await requestRunCancel(runs, active.runId)
648
+ // Band 2: in-process, so a co-located driver stops immediately.
649
+ driving.get(active.runId)?.abort(RUN_CANCEL_REASON)
650
+ }
651
+ ```
652
+
653
+ On the client, **`chat.stop()` alone is not a cancel.** It aborts a local
654
+ `AbortController` and sends the server nothing, which on a durable run is
655
+ indistinguishable from a refresh — so the agent keeps running and keeps
656
+ spending tokens with nobody watching. Call a cancel endpoint too.
657
+
658
+ What each path writes: a disconnect on a durable run with `detachOnDisconnect`
659
+ on and no cancel recorded keeps the sandbox and writes `detachedSince` +
660
+ `sandboxKey`, while `withPersistence` writes **nothing** (the record stays
661
+ `'running'`). A cancel in either band destroys the sandbox regardless of
662
+ `destroyOnComplete`, and `withPersistence` writes `'aborted'`. `keepAlive` /
663
+ `destroyOnComplete: false` govern _successful completion_ only — they never keep
664
+ a sandbox alive through a cancel.
665
+
666
+ ### `sandboxRunDriver` — the supported way to drive a resumed run
667
+
668
+ Takeover happens in the `GET` handler that already serves resumes. Add a
669
+ `driver` and the same request that replays the log also claims the run and keeps
670
+ driving it. **Do not hand-roll this.** `sandboxRunDriver` owns the claim, the
671
+ epoch fencing, and the quiescence gate; a consumer wiring `pipeToRunLog`
672
+ directly is re-implementing exactly the seam that produced this phase's
673
+ duplicate-write and false-terminal-write bugs.
674
+
675
+ ```typescript
676
+ import { memoryStream, resumeServerSentEventsResponse } from '@tanstack/ai'
677
+ import { sandboxRunDriver } from '@tanstack/ai-sandbox'
678
+ import type { RunStore, StreamChunk } from '@tanstack/ai'
679
+ import type { LockStore } from '@tanstack/ai/locks'
680
+
681
+ /**
682
+ * The claim hands `drive` an `AbortSignal` that fires the moment this host loses
683
+ * ownership; `chat()` takes an `AbortController`. Mirror one onto the other, or
684
+ * a lost claim never stops the drive.
685
+ */
686
+ export function controllerFor(signal: AbortSignal): AbortController {
687
+ const controller = new AbortController()
688
+ const abort = (): void => controller.abort(signal.reason)
689
+ if (signal.aborted) abort()
690
+ else signal.addEventListener('abort', abort, { once: true })
691
+ return controller
692
+ }
693
+
694
+ export function takeoverResponse(
695
+ request: Request,
696
+ runs: RunStore,
697
+ locks: LockStore,
698
+ drive: (input: {
699
+ runId: string
700
+ threadId: string
701
+ signal: AbortSignal
702
+ }) => AsyncIterable<StreamChunk>,
703
+ ): Response {
704
+ return resumeServerSentEventsResponse({
705
+ adapter: memoryStream(request),
706
+ driver: sandboxRunDriver({
707
+ request,
708
+ runs,
709
+ locks,
710
+ // Per-run factory, same shape as `RunDeps.durability`.
711
+ durability: () => memoryStream(request),
712
+ drive,
713
+ // Serverless: pass `waitUntil: (p) => ctx.waitUntil(p)` to keep the
714
+ // background drive alive. `fenceQuietMs` overrides the quiescence window.
715
+ }),
716
+ })
717
+ }
718
+ ```
719
+
720
+ Inside `drive`, run `chat()` with `abortController: controllerFor(input.signal)`
721
+ and `withSandbox(sandbox, { runs, durability: { adapter, attach: true } })`.
722
+ **`attach: true` is the whole difference** — the harness tails the run's
723
+ EXISTING journal instead of starting a second agent. It belongs there and never
724
+ on `chat()` (core has no sandbox vocabulary), and it is set only by an attach
725
+ route, never by a `POST` handler. Load the thread from the message store: the
726
+ client sent no history because it is reconnecting, not asking a question — and
727
+ pass the run record's `threadId`. Forget it and the attach **refuses up front**
728
+ with `DurableThreadIdRequiredError` rather than failing mid-stream: every emitted
729
+ chunk carries `threadId`, so a generated one differs from the stored log in its
730
+ very first chunk. `resolveDurableThreadId` throws only in the durable-AND-
731
+ attaching quadrant — a durable _fresh_ run legitimately mints its `threadId`,
732
+ since it is the run that establishes it. `JournalReplayThreadIdMismatchError` is
733
+ still what surfaces if a mismatched `threadId` reaches `alignToStoredLog` by some
734
+ other route; `sandboxRunDriver` itself forwards `active.threadId` into
735
+ `drive({ runId, threadId, signal })`, so the remaining gap is application `drive`
736
+ code that does not pass it on to `chat()`.
737
+
738
+ The response is byte-identical whether or not you pass `driver`: it still
739
+ replays from the durability log. The drive runs beside it, appending to the
740
+ producer-side log, and the response tails what lands. Everything is total by
741
+ construction — no run id, no record, an already-terminal record, another host
742
+ holding the claim, or a throwing drive all resolve to "serve the log, drive
743
+ nothing", logged server-side.
744
+
745
+ Branchable failures, all barrel-exported:
746
+
747
+ ```typescript
748
+ import {
749
+ RunClaimLostError,
750
+ RunClaimNotAcquiredError,
751
+ RunDriverPipeOutsideClaimError,
752
+ } from '@tanstack/ai-sandbox'
753
+
754
+ export function describeDriveFailure(error: unknown): string {
755
+ if (error instanceof RunClaimNotAcquiredError) {
756
+ // 'terminal' | 'unknown' | 'superseded' — an ordinary contended takeover.
757
+ return `not driving ${error.runId}: ${error.reason}`
758
+ }
759
+ if (error instanceof RunClaimLostError) {
760
+ return `superseded mid-drive at epoch ${error.heldEpoch}`
761
+ }
762
+ if (error instanceof RunDriverPipeOutsideClaimError) {
763
+ // Programming error: the options object was taken apart and `pipe` called
764
+ // outside `claim`, so there is no epoch to fence with.
765
+ return `run ${error.runId}: pipe ran outside its claim`
766
+ }
767
+ throw error
768
+ }
769
+ ```
770
+
771
+ The first two are normal outcomes of a contended takeover and
772
+ `resumeServerSentEventsResponse` already swallows both — expect them in logs,
773
+ not in responses.
774
+
775
+ ### Single-writer safety: BOTH seams are fenced
776
+
777
+ Only one host may write a run. The client has no safety net below its offset
778
+ de-dup: if two hosts each snapshot the log, compute a "remainder", and append
779
+ it, the same logical chunk lands twice under two different offsets, looks new,
780
+ and the stream processor applies text and tool-argument deltas unconditionally
781
+ — doubled prose and `{"a":1}{"a":1}` tool arguments. Takeover is by definition
782
+ two hosts wanting one run, so the exclusion has to be real. Three layers, all
783
+ wired by `sandboxRunDriver`: a per-run **lease** (`LockStore.withLock` around
784
+ the whole drive), an **epoch** (`RunRecord.driverEpoch`, bumped by each
785
+ successful claim and re-read before appends), and **quiescence** (the successor
786
+ waits for the stored log to stop growing before its first append;
787
+ `DEFAULT_FENCE_QUIET_MS` = 5s, override with `fenceQuietMs`).
788
+
789
+ **A run's facts live in two places, and both are fenced.** This is the part a
790
+ reader gets half-right and then builds a broken poller on:
791
+
792
+ - **The event log.** A superseded driver's `append` is **refused**, and the
793
+ first refusal latches the fence permanently shut.
794
+ - **The run record.** A **terminal-status `update` from a lost claim is
795
+ suppressed** — it resolves without writing. Non-terminal writes still pass
796
+ through (a stale `detachedSince` / `sandboxKey` cannot make a live run look
797
+ finished, and the successor overwrites them anyway).
798
+
799
+ Fencing only the log would not remove the harm, it would relocate it:
800
+ `pipeToRunLog` answers a refused append by writing a terminal record, so a dead
801
+ host would mark the successor's healthy run `'failed'`, and every consumer that
802
+ branches on terminal status (`isTerminalRunStatus`, `findActiveRun`, a status
803
+ poller, a reaper) would believe a live run died on the authority of a host that
804
+ no longer owns it. Because both seams are closed, **a terminal status on the
805
+ record is trustworthy and a status poller may believe it.**
806
+
807
+ `close()` is outside both fences, deliberately: it runs on every teardown path
808
+ including the teardown _caused_ by losing the claim, and a fenced `close` would
809
+ wedge the record at `'running'` with every live tailer parked forever — a
810
+ durability `read` only ends when the log closes.
811
+
812
+ This is **not** airtight fencing. A predecessor paused (GC, VM suspend) longer
813
+ than the quiescence window between its last fence check and its append landing
814
+ can still write one batch; closing that needs a compare-and-set
815
+ `StreamDurability.append` does not offer. Mitigate at deployment level: a
816
+ lease-backed distributed `LockStore`, and `fenceQuietMs` above the lease renewal
817
+ interval.
818
+
819
+ ### Replay from zero, and `JournalReplayDivergedError`
820
+
821
+ A takeover does **not** resume the journal where the dead host stopped. It
822
+ re-reads the journal **from byte zero**, re-translates it, and alignment makes
823
+ that safe: the stored log is read once with `snapshot()`, the replay is verified
824
+ against it by `chunkFingerprint`, the matching prefix is suppressed, and only
825
+ the remainder is appended and delivered. The log _is_ the checkpoint, so no
826
+ checkpoint can disagree with it.
827
+
828
+ If the replay produces a different chunk than the log holds at that index,
829
+ `JournalReplayDivergedError` is thrown with the index and both fingerprints:
830
+
831
+ ```typescript
832
+ import {
833
+ JournalReplayDivergedError,
834
+ JournalReplayThreadIdMismatchError,
835
+ } from '@tanstack/ai-sandbox'
836
+
837
+ export function report(error: unknown): string {
838
+ // Check the subclass FIRST — it separates a config mistake from a real
839
+ // determinism bug in one check.
840
+ if (error instanceof JournalReplayThreadIdMismatchError) {
841
+ return 'the attach route drove the run without the record threadId'
842
+ }
843
+ if (error instanceof JournalReplayDivergedError) {
844
+ return `diverged at ${error.index}: stored ${error.stored}, replayed ${error.replayed}`
845
+ }
846
+ throw error
847
+ }
848
+ ```
849
+
850
+ Read it plainly: **translation stopped being deterministic.** Realistic causes
851
+ are a `genId` that is not run-scoped, a translator that consults the clock, or a
852
+ journal that was rewritten (usually a reused `runId`). **Treat it as a bug to
853
+ fix, not a condition to recover from.** Do not catch it and continue: the log is
854
+ authoritative and already went to the client, so forwarding past a mismatch
855
+ delivers a stream whose prefix and suffix disagree about message identity. Log
856
+ the index and both fingerprints, let the run fail, and check `runId` uniqueness
857
+ first.
858
+
859
+ One tolerance exists: on adapters that splice host-tool-bridge events into
860
+ their output (`@tanstack/ai-claude-code`, `@tanstack/ai-codex`), the log holds
861
+ `CUSTOM` chunks fired by _live_ tool execution that a replay runs no tools to
862
+ reproduce. Alignment skips those as out-of-band, up to
863
+ `DEFAULT_MAX_OUT_OF_BAND_SKIP` (64) consecutive entries. The bound is what keeps
864
+ this a tolerance rather than a forward search for any fingerprint that happens
865
+ to match.
866
+
867
+ ### A real `LockStore` is required
868
+
869
+ `InMemoryLockStore` **cannot** coordinate across hosts: it serializes claims
870
+ within one process, and the signal it hands out is a fresh
871
+ `AbortController().signal` that is never aborted, so the lease can never report
872
+ a loss. Two replicas then drive one run and duplicate its log. `withSandbox`
873
+ emits a warning when durability is wired over an in-memory lock — **including
874
+ when no lock is wired at all**, because `defineSandbox`'s `ensure` falls back to
875
+ a process-lifetime `InMemoryLockStore`, which is the most in-memory case, not an
876
+ exempt one. Wire a distributed store with `withLocks` from `@tanstack/ai/locks`
877
+ (ordered **before** `withSandbox`) or the `locks` option.
878
+
879
+ Also required: a `RunStore` whose `update` round-trips `status`, `finishedAt`,
880
+ `error`, `usage`, `sandboxKey`, `detachedSince`, `cancelRequested`, and
881
+ `driverEpoch`. The last four are what a hand-written backend tends to omit, and
882
+ each omission breaks one mechanism: no `driverEpoch` → no fencing; no
883
+ `cancelRequested` → Stop cannot reach a remote driver; no
884
+ `detachedSince`/`sandboxKey` → nothing can reclaim the sandbox. `findActiveRun`
885
+ and `listReclaimable` are optional (feature-detect them), but you need the first
886
+ to rejoin by thread and the second for `reapDetachedRuns` to have anything to
887
+ sweep — a store without it cannot be reaped at all.
888
+
889
+ ### The reaper ships as a function, not a scheduler
890
+
891
+ `reapDetachedRuns` (with `sandboxReclaimer` for the sandbox teardown and
892
+ `pruneJournals` for the journal directory) is what closes out a detached run, but
893
+ nothing in the framework calls it: the application must, from its own cron route,
894
+ queue consumer, Durable Object `alarm()`, or `waitUntil`. Wiring `durability` and
895
+ never scheduling it leaves detached delivery logs open forever — every attached
896
+ tailer parks, the TTL is inert, and sandboxes bill indefinitely.
897
+
898
+ **`hasFinished` is a REQUIRED option, not a nicety.** The sweep must never drive a
899
+ run to find out whether it finished: `pipeToRunLog` is total, so it always writes a
900
+ terminal status and always calls `close()`, which on a live run means a false
901
+ transcript, every tailer's stream ended, and a record that has left
902
+ `listReclaimable` forever (so the sandbox can never be reclaimed). So the sentinel
903
+ is detected **out of band**, and neither the delivery log (frozen at the last
904
+ delivered chunk once the viewer left) nor this package (`SandboxInstanceStore` has
905
+ no `list`) can answer it. `probeRunExit` is the shipped implementation; only your
906
+ application can map a `sandboxKey` to a live handle for it. Anything it cannot
907
+ answer must be `unknown`, never `finished`:
908
+
909
+ ```typescript
910
+ import {
911
+ probeRunExit,
912
+ reapDetachedRuns,
913
+ sandboxReclaimer,
914
+ } from '@tanstack/ai-sandbox'
915
+ import type { RunRecord } from '@tanstack/ai'
916
+ import type { ReapResult, RunExitProbe } from '@tanstack/ai-sandbox'
917
+
918
+ async function hasFinished(record: RunRecord): Promise<RunExitProbe> {
919
+ if (record.sandboxKey === undefined) return { state: 'unknown' }
920
+ try {
921
+ const instance = await instances.get(record.sandboxKey)
922
+ if (instance === null) return { state: 'unknown' }
923
+ const handle = await sandbox.provider.resume({
924
+ id: instance.providerSandboxId,
925
+ })
926
+ if (handle === null) return { state: 'unknown' }
927
+ return await probeRunExit({ handle, runId: record.runId })
928
+ } catch (error) {
929
+ return { state: 'unknown', error }
930
+ }
931
+ }
932
+
933
+ export function sweepDetachedRuns(): Promise<ReapResult> {
934
+ return reapDetachedRuns({
935
+ runs, // the SAME RunStore the chat routes use
936
+ locks, // the same distributed LockStore withSandbox gets
937
+ durability: durabilityFor, // per-run factory resolving the SAME log
938
+ hasFinished,
939
+ drive: driveRun, // the same `drive` the attach route passes sandboxRunDriver
940
+ now: Date.now(),
941
+ detachedRunTtlMs: 30 * 60 * 1000,
942
+ reclaim: sandboxReclaimer({ provider: sandbox.provider, instances }),
943
+ })
944
+ }
945
+ ```
946
+
947
+ `reapDetachedRuns` resolves rather than rejects; read its `outcomes` tally. Note
948
+ that `'producing'`, `'unknown'`, and `'not-claimed'` mean the run was left
949
+ untouched, whereas `'budget-exceeded'` is the opposite — the record IS terminal,
950
+ the log IS closed, and `reclaim` fired; it flags a run the probe said had finished
951
+ that would not replay in time, i.e. a misbehaving journal read, translation, or
952
+ log. `'reclaim-failed'` means the transcript saved but the sandbox is still up, and
953
+ no later sweep will retry it — the shipped `sandboxReclaimer` **rejects** (with
954
+ `SandboxReclaimFailedError`) when the provider's `destroy` throws, which is what
955
+ makes that outcome reachable at all, so a custom `reclaim` must reject too rather
956
+ than logging and resolving. It overwrites `'budget-exceeded'` when a run hit both;
957
+ `ReapRunEntry.terminalizedAnyway` is set if and only if the budget anomaly
958
+ happened and is what keeps that second diagnostic on the entry.
959
+
960
+ **`ReapOptions.detachedRunTtlMs` is the ONLY detached-run TTL.** It is required,
961
+ passed directly to `reapDetachedRuns`, and nothing derives it from `withSandbox`
962
+ — there is no TTL option on `durability`, and no other config to keep it in sync
963
+ with.
964
+
965
+ Full reaper wiring — every outcome, `pruneJournals`' keep/delete table, and the
966
+ scheduling shapes — is in `docs/sandbox/reaping.md`. The attach/takeover half,
967
+ including the client `joinRun` side, is in `docs/sandbox/takeover.md`.
336
968
 
337
969
  ## Events
338
970
 
@@ -361,6 +993,12 @@ Cloudflare runtime (`@tanstack/ai-sandbox-cloudflare`):
361
993
  (Bash/Edit/Read/…). The host bridge binds on the host; the sandbox reaches it
362
994
  (localhost, or `host.docker.internal` for Docker), gated by a per-run bearer
363
995
  token.
996
+ - **Durable runs are one opt-in.** `withSandbox(sandbox, { runs, durability })`
997
+ needs BOTH; pass one and you silently get today's non-durable behavior. Drive
998
+ a resumed run with `sandboxRunDriver`, never by hand-wiring `pipeToRunLog` —
999
+ it owns the claim, the epoch fence (over the log **and** the run record), and
1000
+ the quiescence gate. A durable deploy needs a distributed `LockStore`;
1001
+ `InMemoryLockStore` (or no lock at all) warns and cannot fence.
364
1002
  - Use `localProcessSandbox()` only in trusted/dev contexts (no isolation).
365
1003
  - Skills/plugins that a CLI lacks (e.g. `agentSkill` on Codex, `plugins` on
366
1004
  Codex) warn and skip — they do not throw.