@tanstack/ai-sandbox 0.2.4 → 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
@@ -0,0 +1,230 @@
1
+ import "./journal.js";
2
+ import { alignToStoredLog, isBridgeCustomChunk } from "./align.js";
3
+ import { createCapability } from "@tanstack/ai";
4
+ //#region src/durability.ts
5
+ /**
6
+ * The durability seam for a sandboxed run: the option shape `withSandbox` takes,
7
+ * the capability harness adapters read, and the two guards that keep a
8
+ * "durable" run actually recoverable.
9
+ *
10
+ * A run is durable only when BOTH a `RunStore` and a `StreamDurability` are
11
+ * wired, because either alone is useless: a record with no event log cannot be
12
+ * replayed, and a log with no record cannot be found, claimed, or reaped. So the
13
+ * capability exists or it does not — there is no half-configured state, and
14
+ * every existing app (which wires neither) keeps today's behavior untouched.
15
+ */
16
+ /**
17
+ * Provided by `withSandbox` only when a run is genuinely durable (both stores
18
+ * wired). Harness adapters read it with `getOptional` and treat its absence as
19
+ * "no journaling contract to honour", which is exactly today's behavior.
20
+ */
21
+ var SandboxDurabilityCapability = createCapability()("sandbox-durability");
22
+ /** Destructured accessors, matching `./capabilities`. */
23
+ var [getSandboxDurability, provideSandboxDurability] = SandboxDurabilityCapability;
24
+ /**
25
+ * A durable run was started without a caller-supplied `runId`.
26
+ *
27
+ * Thrown rather than defaulted because the failure is otherwise INVISIBLE: an
28
+ * adapter-generated id (`${name}-${Date.now()}-${Math.random()...}`) produces a
29
+ * journal path at `/tmp/tanstack-runs/<id>.ndjson` that no successor host can
30
+ * recompute, so the run streams normally, records normally, and is silently
31
+ * unrecoverable. A loud failure at the start of `chatStream` is strictly better
32
+ * than a run that only reveals itself as non-durable during an incident.
33
+ */
34
+ var DurableRunIdRequiredError = class extends Error {
35
+ adapter;
36
+ constructor(adapter) {
37
+ super(`${adapter}: a durable sandboxed run requires a caller-supplied \`runId\`. The journal path and the deterministic message-id generator are both derived from it, so a successor host can only resume a run whose \`runId\` it can recompute. Pass \`runId\` to chat({ ... }), or drop \`runs\`/\`durability\` from withSandbox(...) to run non-durably.`);
38
+ this.adapter = adapter;
39
+ this.name = "DurableRunIdRequiredError";
40
+ }
41
+ };
42
+ /**
43
+ * Resolve the `runId` a harness adapter will journal under.
44
+ *
45
+ * Replaces the bare `options.runId ?? this.generateId()` in every harness
46
+ * adapter. The fallback is preserved for non-durable runs — several `chat()`
47
+ * paths pass `runId` as a conditional spread, so `undefined` is reachable and
48
+ * removing the fallback would break them for no benefit.
49
+ *
50
+ * The `durable` check runs BEFORE `fallback()`, and that ordering is load
51
+ * bearing: a generated id must never be minted for a durable run, not even one
52
+ * that is discarded, because the whole point is that no such id can exist.
53
+ */
54
+ function resolveDurableRunId(runId, options) {
55
+ if (runId !== void 0 && runId.length > 0) return runId;
56
+ if (options.durable) throw new DurableRunIdRequiredError(options.adapter);
57
+ return options.fallback();
58
+ }
59
+ /**
60
+ * An ATTACHING durable run was driven without the run record's `threadId`.
61
+ *
62
+ * The sibling of {@link DurableRunIdRequiredError}, for the other id an attach
63
+ * cannot mint for itself. `threadId` lands in EVERY chunk a harness adapter
64
+ * emits (see each package's `stream/translate.ts`), so a replay that generates a
65
+ * fresh one produces a stream that differs from the stored log in its very first
66
+ * chunk. `alignToStoredLog` then fails at index 0 with a
67
+ * `JournalReplayThreadIdMismatchError` — mid-stream, after the takeover has
68
+ * already claimed the run. Refusing up front is strictly better, and mirrors
69
+ * what `resolveDurableRunId` does for an id whose absence is equally fatal.
70
+ *
71
+ * Core already does its part: `startRunDriver` reads the record and hands
72
+ * `active.threadId` to `drive({ runId, threadId, signal })`. This error exists
73
+ * for the one gap it cannot close — application `drive` code that forgets to
74
+ * forward it into `chat()`.
75
+ */
76
+ var DurableThreadIdRequiredError = class extends Error {
77
+ adapter;
78
+ constructor(adapter) {
79
+ super(`${adapter}: an ATTACHING durable sandboxed run requires the run record's \`threadId\`. Every emitted chunk carries \`threadId\`, so an attach that generates a fresh one replays a stream whose first chunk already differs from the stored log, and alignment fails at index 0 (\`JournalReplayThreadIdMismatchError\`) even though the agent behaved identically. Forward the run record's \`threadId\` — the one \`sandboxRunDriver\` passes to \`drive({ runId, threadId, signal })\` — into \`chat({ ... })\` on the attach route. A durable FRESH run needs none: that run is what establishes the \`threadId\`.`);
80
+ this.adapter = adapter;
81
+ this.name = "DurableThreadIdRequiredError";
82
+ }
83
+ };
84
+ /**
85
+ * Resolve the `threadId` a harness adapter will stamp on every chunk.
86
+ *
87
+ * Replaces the bare `options.threadId ?? this.generateId()` in the journaling
88
+ * harness adapters. Only the durable-AND-attaching quadrant throws; the other
89
+ * three keep the generated fallback and are byte-identical to before:
90
+ *
91
+ * | durable | attaching | behavior |
92
+ * | ------- | --------- | --------------------------------------------------- |
93
+ * | no | no | fallback — a plain non-durable run |
94
+ * | no | yes | fallback — not reachable today, and harmless anyway |
95
+ * | yes | no | fallback — the FRESH run that ESTABLISHES the id |
96
+ * | yes | yes | throw {@link DurableThreadIdRequiredError} |
97
+ *
98
+ * The durable-fresh row is the load-bearing one. A fresh durable run legitimately
99
+ * mints its `threadId` (there is no record to reuse one from), so throwing on
100
+ * `durable` alone — the obvious over-simplification — would break every durable
101
+ * run that has ever worked. Only re-entering an existing run has an id it MUST
102
+ * reuse, which is exactly the condition `attach` already expresses.
103
+ *
104
+ * As in `resolveDurableRunId`, the guard runs BEFORE `fallback()`: a generated id
105
+ * must never be minted on this path, not even one that is then discarded.
106
+ */
107
+ function resolveDurableThreadId(threadId, options) {
108
+ if (threadId !== void 0 && threadId.length > 0) return threadId;
109
+ if (options.durable && options.attaching) throw new DurableThreadIdRequiredError(options.adapter);
110
+ return options.fallback();
111
+ }
112
+ /**
113
+ * An ATTACH was driven into a code path that can never replay a run.
114
+ *
115
+ * The third sibling of {@link DurableRunIdRequiredError} and
116
+ * {@link DurableThreadIdRequiredError}, and the one that is not about a missing
117
+ * id: here every id is present and the path itself is the problem.
118
+ *
119
+ * `sandboxRunDriver`'s `drive()` re-invokes `chat()` with `attach: true`. On a
120
+ * JOURNALING path that is genuinely a replay — `spawnNdjson` tails the journal
121
+ * the previous host wrote, `awaitAttachableJournal` refuses a hopeless attach up
122
+ * front, and `alignedIfAttaching` suppresses the prefix already delivered. A
123
+ * protocol path with none of those three has no journal to tail and nothing to
124
+ * align against, so `attach: true` does not resume anything: it starts the agent
125
+ * over from scratch against the workspace the first attempt already mutated, and
126
+ * appends its entire output to a log that still holds the first attempt's.
127
+ *
128
+ * Deliberately NOT a `JournalAttachUnavailableError`. That error means "a
129
+ * journal that should exist has not appeared yet" — retryable, scoped to a wait
130
+ * (`attachWaitMs`). This condition is categorically different: the path cannot
131
+ * attach AT ALL, so telling a caller to wait would point it at something that is
132
+ * never coming. A 5xx/501-shaped refusal, not a 504.
133
+ *
134
+ * `reason` names the missing capability in the adapter's own vocabulary (which
135
+ * protocol, which spawn path), because the fix is always to change how the run
136
+ * is spawned or routed, never to retry.
137
+ */
138
+ var DurableAttachNotSupportedError = class extends Error {
139
+ adapter;
140
+ reason;
141
+ constructor(adapter, reason) {
142
+ super(`${adapter}: this code path cannot ATTACH to an existing durable run (${reason}). It does not journal, so there is no stored output to replay and no alignment to suppress what was already delivered. Proceeding would re-run the agent from scratch against the workspace the previous attempt already modified, and double-append its entire output to the run log. Route the attach through a journaling spawn path, or drop \`runs\`/\`durability\` from withSandbox(...) so the run is never resumed in the first place. This is not a transient condition — unlike \`JournalAttachUnavailableError\`, waiting and retrying can never make it succeed.`);
143
+ this.adapter = adapter;
144
+ this.reason = reason;
145
+ this.name = "DurableAttachNotSupportedError";
146
+ }
147
+ };
148
+ /**
149
+ * Resolve `withSandbox`'s two durability options into the capability payload, or
150
+ * `undefined` when the app has not opted in.
151
+ *
152
+ * BOTH `runs` and `durability` are required. A half-configured app gets
153
+ * `undefined` **silently** rather than a warning: it has not asked for
154
+ * durability, so there is nothing to warn about, and the resulting behavior
155
+ * (destroy on disconnect, no journal) is exactly today's.
156
+ */
157
+ function resolveSandboxDurability(options) {
158
+ const runs = options?.runs;
159
+ const durability = options?.durability;
160
+ if (runs === void 0 || durability === void 0) return void 0;
161
+ return {
162
+ runs,
163
+ adapter: durability.adapter,
164
+ journalDir: durability.journal ?? "/tmp/tanstack-runs",
165
+ attach: durability.attach === true,
166
+ detachOnDisconnect: durability.detachOnDisconnect !== false,
167
+ ...durability.pollIntervalMs === void 0 ? {} : { pollIntervalMs: durability.pollIntervalMs },
168
+ ...durability.attachWaitMs === void 0 ? {} : { attachWaitMs: durability.attachWaitMs }
169
+ };
170
+ }
171
+ /**
172
+ * Build the `spawnNdjson` journal option for a run, or `undefined` when the run
173
+ * is not durable — in which case `spawnNdjson` takes its original, unjournaled
174
+ * path (`isJournaled` tests `options.journal !== undefined`, `runner.ts:70-72`)
175
+ * and behavior is byte-identical to a pre-durability run.
176
+ *
177
+ * `JournalOptions.dir` is optional, but this always supplies it: the resolved
178
+ * durability has already defaulted `journalDir`, and a successor host must
179
+ * recompute the same path rather than re-derive the default independently.
180
+ *
181
+ * `runs` and `attachWaitMs` are carried ONLY when attaching, and that is not a
182
+ * micro-optimization: they exist for `awaitAttachableJournal`, which the reader
183
+ * runs on the attach path alone. A fresh run has no journal yet BY DESIGN (its own
184
+ * `journaledCommand` spawn creates it moments later), so handing it a run store
185
+ * would only invite a future change to gate a path where absence proves nothing.
186
+ */
187
+ function journalOptionsFor(durability, runId) {
188
+ if (durability === void 0) return void 0;
189
+ return {
190
+ runId,
191
+ dir: durability.journalDir,
192
+ attach: durability.attach,
193
+ ...durability.pollIntervalMs === void 0 ? {} : { pollIntervalMs: durability.pollIntervalMs },
194
+ ...durability.attach ? {
195
+ runs: durability.runs,
196
+ ...durability.attachWaitMs === void 0 ? {} : { attachWaitMs: durability.attachWaitMs }
197
+ } : {}
198
+ };
199
+ }
200
+ /**
201
+ * Align a harness stream against the run's stored log — but ONLY on an attach.
202
+ *
203
+ * The `attach` guard is not an optimization, it is a CORRECTNESS requirement.
204
+ * `alignToStoredLog` snapshots the log before the first chunk is pulled and
205
+ * treats everything in that snapshot as "already delivered". On a FRESH run that
206
+ * premise is false: if such a run were aligned against a log that already holds
207
+ * entries — a `runId` collision, a retried request — its own chunks would be
208
+ * matched against those entries and silently SUPPRESSED instead of delivered,
209
+ * which is silent data loss rather than a slow path. Aligning only when
210
+ * re-entering an existing run keeps the transform's premise ("this stream is a
211
+ * replay of what is already stored") actually true.
212
+ *
213
+ * `isBridgeCustomChunk` is passed because the stored log holds the previous
214
+ * host's MERGED output, including live bridged-tool CUSTOM events that a replay
215
+ * cannot reproduce; without it a bridged-tool run could not be taken over at
216
+ * all. Wrap the merge RESULT, never the pre-merge translator, or the comparison
217
+ * is against a stream the log never contained.
218
+ */
219
+ function alignedIfAttaching(chunks, durability, logger) {
220
+ if (durability === void 0 || !durability.attach) return chunks;
221
+ return alignToStoredLog(chunks, {
222
+ durability: durability.adapter,
223
+ isOutOfBand: isBridgeCustomChunk,
224
+ ...logger === void 0 ? {} : { logger }
225
+ });
226
+ }
227
+ //#endregion
228
+ export { DurableAttachNotSupportedError, DurableRunIdRequiredError, DurableThreadIdRequiredError, SandboxDurabilityCapability, alignedIfAttaching, getSandboxDurability, journalOptionsFor, provideSandboxDurability, resolveDurableRunId, resolveDurableThreadId, resolveSandboxDurability };
229
+
230
+ //# sourceMappingURL=durability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durability.js","names":[],"sources":["../../src/durability.ts"],"sourcesContent":["/**\n * The durability seam for a sandboxed run: the option shape `withSandbox` takes,\n * the capability harness adapters read, and the two guards that keep a\n * \"durable\" run actually recoverable.\n *\n * A run is durable only when BOTH a `RunStore` and a `StreamDurability` are\n * wired, because either alone is useless: a record with no event log cannot be\n * replayed, and a log with no record cannot be found, claimed, or reaped. So the\n * capability exists or it does not — there is no half-configured state, and\n * every existing app (which wires neither) keeps today's behavior untouched.\n */\nimport { createCapability } from '@tanstack/ai'\nimport { DEFAULT_JOURNAL_DIR } from './journal'\nimport { alignToStoredLog, isBridgeCustomChunk } from './align'\nimport type { JournalOptions } from './runner'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'\n\n/** `withSandbox(sandbox, { durability })`. */\nexport interface SandboxDurabilityOptions<TOffset extends string = string> {\n /**\n * Delivery-durable event log for the run. Same key and shape as the\n * transport's `durability.adapter`, so one adapter instance can be handed to\n * both `withSandbox` and `toServerSentEventsResponse`.\n *\n * Generic in the offset type, defaulted to `string`, for the same reason\n * {@link SandboxRunDriverOptions} and {@link ReapOptions} are:\n * `StreamDurability` is INVARIANT in `TOffset` (`read` takes an offset in),\n * so a backend that brands its cursors — `@tanstack/ai-durable-stream`'s\n * `durableStream`, the multi-host production backend the sandbox docs point\n * at — is not assignable to `StreamDurability<string>`. Without the parameter\n * the resume route could be wired with it and the route that STARTS the run\n * could not.\n */\n adapter: StreamDurability<TOffset>\n /** Journal directory inside the sandbox. Defaults to `/tmp/tanstack-runs`. */\n journal?: string\n /**\n * Whether a client disconnect DETACHES (leave the agent running) instead of\n * destroying the sandbox. Defaults to `true` whenever durability is wired,\n * because that is the whole point of wiring it.\n *\n * Set `false` to keep today's destroy-on-disconnect cost profile while still\n * getting resumable DELIVERY (a reload replays the log). An explicit cancel\n * destroys either way.\n */\n detachOnDisconnect?: boolean\n /**\n * Read an EXISTING run's journal instead of starting a new agent. Set by the\n * attach route's `drive()` callback, never by an application's POST handler.\n *\n * This is where `attach` lives, and deliberately NOT on `chat()`: `chat()` is\n * core and must not gain sandbox vocabulary, and the provider options are\n * per-model type state, not per-request lifecycle.\n */\n attach?: boolean\n /** Journal poll interval for providers that cannot follow. */\n pollIntervalMs?: number\n /**\n * How long an ATTACH waits for a live run's journal to appear before failing\n * with a `JournalAttachUnavailableError`. Defaults to\n * `DEFAULT_ATTACH_JOURNAL_WAIT_MS` (10s). Only the wait is configurable: an\n * unknown or terminal runId fails immediately regardless, since no amount of\n * waiting changes either verdict.\n */\n attachWaitMs?: number\n}\n\n/**\n * The view of a caller's event log that the capability bus carries.\n *\n * Deliberately NOT the whole `StreamDurability`. `read` is the only member that\n * takes an offset *in*, which is what makes `StreamDurability` invariant in\n * `TOffset` and a branded-cursor backend unassignable to\n * `StreamDurability<string>`. Every other member mentions the offset only in a\n * return position, so this type is a genuine SUPERTYPE of\n * `StreamDurability<TOffset>` for every `TOffset extends string` — which is the\n * one property that lets a single concrete capability instantiation accept a\n * branded backend. `createCapability<T>()` forces exactly one instantiation\n * (the value type is a plain type argument, and TypeScript has no higher-kinded\n * types), so the payload cannot be parameterized the way the *option* above is.\n *\n * Dropping `read` costs nothing, and that is a property of the seam rather than\n * luck: the bus is the JOURNAL/ALIGNMENT seam, and alignment reads the stored\n * prefix through `snapshot()` — never `read()`, which tails an open log forever\n * (see `alignToStoredLog`). Replay *by offset* belongs to the delivery seam,\n * and that seam (`toServerSentEventsResponse`, `sandboxRunDriver`) receives the\n * application's own adapter directly, with its brand intact.\n */\nexport type SandboxDurabilityLog = Omit<StreamDurability, 'read'>\n\n/**\n * Resolved durability, published on the capability bus by `withSandbox`.\n *\n * Deliberately carries NO detached-run TTL. The only actor that enforces one is\n * `reapDetachedRuns`, which runs from a cron with no chat in flight — so it has\n * no `CapabilityContext` and cannot read this bus at all. A TTL published here\n * could therefore only ever be read by nobody, while the sweep took its own\n * `ReapOptions.detachedRunTtlMs`; the two would silently disagree. The reaper's\n * required option is the single source of truth.\n */\nexport interface SandboxRunDurability {\n runs: RunStore\n adapter: SandboxDurabilityLog\n journalDir: string\n attach: boolean\n detachOnDisconnect: boolean\n pollIntervalMs?: number\n attachWaitMs?: number\n}\n\n/**\n * Provided by `withSandbox` only when a run is genuinely durable (both stores\n * wired). Harness adapters read it with `getOptional` and treat its absence as\n * \"no journaling contract to honour\", which is exactly today's behavior.\n */\nexport const SandboxDurabilityCapability =\n createCapability<SandboxRunDurability>()('sandbox-durability')\n\n/** Destructured accessors, matching `./capabilities`. */\nexport const [getSandboxDurability, provideSandboxDurability] =\n SandboxDurabilityCapability\n\n/**\n * A durable run was started without a caller-supplied `runId`.\n *\n * Thrown rather than defaulted because the failure is otherwise INVISIBLE: an\n * adapter-generated id (`${name}-${Date.now()}-${Math.random()...}`) produces a\n * journal path at `/tmp/tanstack-runs/<id>.ndjson` that no successor host can\n * recompute, so the run streams normally, records normally, and is silently\n * unrecoverable. A loud failure at the start of `chatStream` is strictly better\n * than a run that only reveals itself as non-durable during an incident.\n */\nexport class DurableRunIdRequiredError extends Error {\n constructor(readonly adapter: string) {\n super(\n `${adapter}: a durable sandboxed run requires a caller-supplied \\`runId\\`. ` +\n `The journal path and the deterministic message-id generator are both derived from it, ` +\n `so a successor host can only resume a run whose \\`runId\\` it can recompute. ` +\n `Pass \\`runId\\` to chat({ ... }), or drop \\`runs\\`/\\`durability\\` from withSandbox(...) to run non-durably.`,\n )\n this.name = 'DurableRunIdRequiredError'\n }\n}\n\n/**\n * Resolve the `runId` a harness adapter will journal under.\n *\n * Replaces the bare `options.runId ?? this.generateId()` in every harness\n * adapter. The fallback is preserved for non-durable runs — several `chat()`\n * paths pass `runId` as a conditional spread, so `undefined` is reachable and\n * removing the fallback would break them for no benefit.\n *\n * The `durable` check runs BEFORE `fallback()`, and that ordering is load\n * bearing: a generated id must never be minted for a durable run, not even one\n * that is discarded, because the whole point is that no such id can exist.\n */\nexport function resolveDurableRunId(\n runId: string | undefined,\n options: { durable: boolean; adapter: string; fallback: () => string },\n): string {\n if (runId !== undefined && runId.length > 0) return runId\n if (options.durable) throw new DurableRunIdRequiredError(options.adapter)\n return options.fallback()\n}\n\n/**\n * An ATTACHING durable run was driven without the run record's `threadId`.\n *\n * The sibling of {@link DurableRunIdRequiredError}, for the other id an attach\n * cannot mint for itself. `threadId` lands in EVERY chunk a harness adapter\n * emits (see each package's `stream/translate.ts`), so a replay that generates a\n * fresh one produces a stream that differs from the stored log in its very first\n * chunk. `alignToStoredLog` then fails at index 0 with a\n * `JournalReplayThreadIdMismatchError` — mid-stream, after the takeover has\n * already claimed the run. Refusing up front is strictly better, and mirrors\n * what `resolveDurableRunId` does for an id whose absence is equally fatal.\n *\n * Core already does its part: `startRunDriver` reads the record and hands\n * `active.threadId` to `drive({ runId, threadId, signal })`. This error exists\n * for the one gap it cannot close — application `drive` code that forgets to\n * forward it into `chat()`.\n */\nexport class DurableThreadIdRequiredError extends Error {\n constructor(readonly adapter: string) {\n super(\n `${adapter}: an ATTACHING durable sandboxed run requires the run record's \\`threadId\\`. ` +\n `Every emitted chunk carries \\`threadId\\`, so an attach that generates a fresh one replays a stream whose first chunk ` +\n `already differs from the stored log, and alignment fails at index 0 (\\`JournalReplayThreadIdMismatchError\\`) even though ` +\n `the agent behaved identically. Forward the run record's \\`threadId\\` — the one \\`sandboxRunDriver\\` passes to ` +\n `\\`drive({ runId, threadId, signal })\\` — into \\`chat({ ... })\\` on the attach route. ` +\n `A durable FRESH run needs none: that run is what establishes the \\`threadId\\`.`,\n )\n this.name = 'DurableThreadIdRequiredError'\n }\n}\n\n/**\n * Resolve the `threadId` a harness adapter will stamp on every chunk.\n *\n * Replaces the bare `options.threadId ?? this.generateId()` in the journaling\n * harness adapters. Only the durable-AND-attaching quadrant throws; the other\n * three keep the generated fallback and are byte-identical to before:\n *\n * | durable | attaching | behavior |\n * | ------- | --------- | --------------------------------------------------- |\n * | no | no | fallback — a plain non-durable run |\n * | no | yes | fallback — not reachable today, and harmless anyway |\n * | yes | no | fallback — the FRESH run that ESTABLISHES the id |\n * | yes | yes | throw {@link DurableThreadIdRequiredError} |\n *\n * The durable-fresh row is the load-bearing one. A fresh durable run legitimately\n * mints its `threadId` (there is no record to reuse one from), so throwing on\n * `durable` alone — the obvious over-simplification — would break every durable\n * run that has ever worked. Only re-entering an existing run has an id it MUST\n * reuse, which is exactly the condition `attach` already expresses.\n *\n * As in `resolveDurableRunId`, the guard runs BEFORE `fallback()`: a generated id\n * must never be minted on this path, not even one that is then discarded.\n */\nexport function resolveDurableThreadId(\n threadId: string | undefined,\n options: {\n durable: boolean\n attaching: boolean\n adapter: string\n fallback: () => string\n },\n): string {\n if (threadId !== undefined && threadId.length > 0) return threadId\n if (options.durable && options.attaching) {\n throw new DurableThreadIdRequiredError(options.adapter)\n }\n return options.fallback()\n}\n\n/**\n * An ATTACH was driven into a code path that can never replay a run.\n *\n * The third sibling of {@link DurableRunIdRequiredError} and\n * {@link DurableThreadIdRequiredError}, and the one that is not about a missing\n * id: here every id is present and the path itself is the problem.\n *\n * `sandboxRunDriver`'s `drive()` re-invokes `chat()` with `attach: true`. On a\n * JOURNALING path that is genuinely a replay — `spawnNdjson` tails the journal\n * the previous host wrote, `awaitAttachableJournal` refuses a hopeless attach up\n * front, and `alignedIfAttaching` suppresses the prefix already delivered. A\n * protocol path with none of those three has no journal to tail and nothing to\n * align against, so `attach: true` does not resume anything: it starts the agent\n * over from scratch against the workspace the first attempt already mutated, and\n * appends its entire output to a log that still holds the first attempt's.\n *\n * Deliberately NOT a `JournalAttachUnavailableError`. That error means \"a\n * journal that should exist has not appeared yet\" — retryable, scoped to a wait\n * (`attachWaitMs`). This condition is categorically different: the path cannot\n * attach AT ALL, so telling a caller to wait would point it at something that is\n * never coming. A 5xx/501-shaped refusal, not a 504.\n *\n * `reason` names the missing capability in the adapter's own vocabulary (which\n * protocol, which spawn path), because the fix is always to change how the run\n * is spawned or routed, never to retry.\n */\nexport class DurableAttachNotSupportedError extends Error {\n constructor(\n readonly adapter: string,\n readonly reason: string,\n ) {\n super(\n `${adapter}: this code path cannot ATTACH to an existing durable run (${reason}). ` +\n `It does not journal, so there is no stored output to replay and no alignment to suppress what was already delivered. ` +\n `Proceeding would re-run the agent from scratch against the workspace the previous attempt already modified, and double-append its entire output to the run log. ` +\n `Route the attach through a journaling spawn path, or drop \\`runs\\`/\\`durability\\` from withSandbox(...) so the run is never resumed in the first place. ` +\n `This is not a transient condition — unlike \\`JournalAttachUnavailableError\\`, waiting and retrying can never make it succeed.`,\n )\n this.name = 'DurableAttachNotSupportedError'\n }\n}\n\n/**\n * Resolve `withSandbox`'s two durability options into the capability payload, or\n * `undefined` when the app has not opted in.\n *\n * BOTH `runs` and `durability` are required. A half-configured app gets\n * `undefined` **silently** rather than a warning: it has not asked for\n * durability, so there is nothing to warn about, and the resulting behavior\n * (destroy on disconnect, no journal) is exactly today's.\n */\nexport function resolveSandboxDurability<TOffset extends string = string>(\n options:\n | { runs?: RunStore; durability?: SandboxDurabilityOptions<TOffset> }\n | undefined,\n): SandboxRunDurability | undefined {\n const runs = options?.runs\n const durability = options?.durability\n if (runs === undefined || durability === undefined) return undefined\n return {\n runs,\n adapter: durability.adapter,\n journalDir: durability.journal ?? DEFAULT_JOURNAL_DIR,\n attach: durability.attach === true,\n detachOnDisconnect: durability.detachOnDisconnect !== false,\n ...(durability.pollIntervalMs === undefined\n ? {}\n : { pollIntervalMs: durability.pollIntervalMs }),\n ...(durability.attachWaitMs === undefined\n ? {}\n : { attachWaitMs: durability.attachWaitMs }),\n }\n}\n\n/**\n * Build the `spawnNdjson` journal option for a run, or `undefined` when the run\n * is not durable — in which case `spawnNdjson` takes its original, unjournaled\n * path (`isJournaled` tests `options.journal !== undefined`, `runner.ts:70-72`)\n * and behavior is byte-identical to a pre-durability run.\n *\n * `JournalOptions.dir` is optional, but this always supplies it: the resolved\n * durability has already defaulted `journalDir`, and a successor host must\n * recompute the same path rather than re-derive the default independently.\n *\n * `runs` and `attachWaitMs` are carried ONLY when attaching, and that is not a\n * micro-optimization: they exist for `awaitAttachableJournal`, which the reader\n * runs on the attach path alone. A fresh run has no journal yet BY DESIGN (its own\n * `journaledCommand` spawn creates it moments later), so handing it a run store\n * would only invite a future change to gate a path where absence proves nothing.\n */\nexport function journalOptionsFor(\n durability: SandboxRunDurability | undefined,\n runId: string,\n): JournalOptions | undefined {\n if (durability === undefined) return undefined\n return {\n runId,\n dir: durability.journalDir,\n attach: durability.attach,\n ...(durability.pollIntervalMs === undefined\n ? {}\n : { pollIntervalMs: durability.pollIntervalMs }),\n ...(durability.attach\n ? {\n runs: durability.runs,\n ...(durability.attachWaitMs === undefined\n ? {}\n : { attachWaitMs: durability.attachWaitMs }),\n }\n : {}),\n }\n}\n\n/**\n * Align a harness stream against the run's stored log — but ONLY on an attach.\n *\n * The `attach` guard is not an optimization, it is a CORRECTNESS requirement.\n * `alignToStoredLog` snapshots the log before the first chunk is pulled and\n * treats everything in that snapshot as \"already delivered\". On a FRESH run that\n * premise is false: if such a run were aligned against a log that already holds\n * entries — a `runId` collision, a retried request — its own chunks would be\n * matched against those entries and silently SUPPRESSED instead of delivered,\n * which is silent data loss rather than a slow path. Aligning only when\n * re-entering an existing run keeps the transform's premise (\"this stream is a\n * replay of what is already stored\") actually true.\n *\n * `isBridgeCustomChunk` is passed because the stored log holds the previous\n * host's MERGED output, including live bridged-tool CUSTOM events that a replay\n * cannot reproduce; without it a bridged-tool run could not be taken over at\n * all. Wrap the merge RESULT, never the pre-merge translator, or the comparison\n * is against a stream the log never contained.\n */\nexport function alignedIfAttaching(\n chunks: AsyncIterable<StreamChunk>,\n durability: SandboxRunDurability | undefined,\n logger?: InternalLogger,\n): AsyncIterable<StreamChunk> {\n if (durability === undefined || !durability.attach) return chunks\n return alignToStoredLog(chunks, {\n durability: durability.adapter,\n isOutOfBand: isBridgeCustomChunk,\n ...(logger === undefined ? {} : { logger }),\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAoHA,IAAa,8BACX,iBAAuC,CAAC,CAAC,oBAAoB;;AAG/D,IAAa,CAAC,sBAAsB,4BAClC;;;;;;;;;;;AAYF,IAAa,4BAAb,cAA+C,MAAM;CAC9B;CAArB,YAAY,SAA0B;EACpC,MACE,GAAG,QAAQ,6UAIb;EANmB,KAAA,UAAA;EAOnB,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;AAcA,SAAgB,oBACd,OACA,SACQ;CACR,IAAI,UAAU,KAAA,KAAa,MAAM,SAAS,GAAG,OAAO;CACpD,IAAI,QAAQ,SAAS,MAAM,IAAI,0BAA0B,QAAQ,OAAO;CACxE,OAAO,QAAQ,SAAS;AAC1B;;;;;;;;;;;;;;;;;;AAmBA,IAAa,+BAAb,cAAkD,MAAM;CACjC;CAArB,YAAY,SAA0B;EACpC,MACE,GAAG,QAAQ,6kBAMb;EARmB,KAAA,UAAA;EASnB,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,uBACd,UACA,SAMQ;CACR,IAAI,aAAa,KAAA,KAAa,SAAS,SAAS,GAAG,OAAO;CAC1D,IAAI,QAAQ,WAAW,QAAQ,WAC7B,MAAM,IAAI,6BAA6B,QAAQ,OAAO;CAExD,OAAO,QAAQ,SAAS;AAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,IAAa,iCAAb,cAAoD,MAAM;CAE7C;CACA;CAFX,YACE,SACA,QACA;EACA,MACE,GAAG,QAAQ,6DAA6D,OAAO,8iBAKjF;EATS,KAAA,UAAA;EACA,KAAA,SAAA;EAST,KAAK,OAAO;CACd;AACF;;;;;;;;;;AAWA,SAAgB,yBACd,SAGkC;CAClC,MAAM,OAAO,SAAS;CACtB,MAAM,aAAa,SAAS;CAC5B,IAAI,SAAS,KAAA,KAAa,eAAe,KAAA,GAAW,OAAO,KAAA;CAC3D,OAAO;EACL;EACA,SAAS,WAAW;EACpB,YAAY,WAAW,WAAA;EACvB,QAAQ,WAAW,WAAW;EAC9B,oBAAoB,WAAW,uBAAuB;EACtD,GAAI,WAAW,mBAAmB,KAAA,IAC9B,CAAC,IACD,EAAE,gBAAgB,WAAW,eAAe;EAChD,GAAI,WAAW,iBAAiB,KAAA,IAC5B,CAAC,IACD,EAAE,cAAc,WAAW,aAAa;CAC9C;AACF;;;;;;;;;;;;;;;;;AAkBA,SAAgB,kBACd,YACA,OAC4B;CAC5B,IAAI,eAAe,KAAA,GAAW,OAAO,KAAA;CACrC,OAAO;EACL;EACA,KAAK,WAAW;EAChB,QAAQ,WAAW;EACnB,GAAI,WAAW,mBAAmB,KAAA,IAC9B,CAAC,IACD,EAAE,gBAAgB,WAAW,eAAe;EAChD,GAAI,WAAW,SACX;GACE,MAAM,WAAW;GACjB,GAAI,WAAW,iBAAiB,KAAA,IAC5B,CAAC,IACD,EAAE,cAAc,WAAW,aAAa;EAC9C,IACA,CAAC;CACP;AACF;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,mBACd,QACA,YACA,QAC4B;CAC5B,IAAI,eAAe,KAAA,KAAa,CAAC,WAAW,QAAQ,OAAO;CAC3D,OAAO,iBAAiB,QAAQ;EAC9B,YAAY,WAAW;EACvB,aAAa;EACb,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;CAC3C,CAAC;AACH"}
@@ -1,25 +1,29 @@
1
- class UnsupportedCapabilityError extends Error {
2
- provider;
3
- capability;
4
- constructor(provider, capability, hint) {
5
- super(
6
- `Sandbox provider "${provider}" does not support the "${capability}" capability.` + (hint ? ` ${hint}` : "")
7
- );
8
- this.name = "UnsupportedCapabilityError";
9
- this.provider = provider;
10
- this.capability = capability;
11
- }
12
- }
13
- class MissingSandboxError extends Error {
14
- constructor(adapterName) {
15
- super(
16
- `Adapter "${adapterName}" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.`
17
- );
18
- this.name = "MissingSandboxError";
19
- }
20
- }
21
- export {
22
- MissingSandboxError,
23
- UnsupportedCapabilityError
1
+ //#region src/errors.ts
2
+ /**
3
+ * Thrown when code invokes an optional sandbox capability that the active
4
+ * provider does not support. Core/middleware should check
5
+ * `handle.capabilities` BEFORE using an optional capability and degrade
6
+ * gracefully; this error exists so that a direct call to an unsupported
7
+ * optional method fails loud instead of silently no-opping.
8
+ */
9
+ var UnsupportedCapabilityError = class extends Error {
10
+ provider;
11
+ capability;
12
+ constructor(provider, capability, hint) {
13
+ super(`Sandbox provider "${provider}" does not support the "${capability}" capability.` + (hint ? ` ${hint}` : ""));
14
+ this.name = "UnsupportedCapabilityError";
15
+ this.provider = provider;
16
+ this.capability = capability;
17
+ }
24
18
  };
25
- //# sourceMappingURL=errors.js.map
19
+ /** Thrown when a harness adapter requires a sandbox but none was provided. */
20
+ var MissingSandboxError = class extends Error {
21
+ constructor(adapterName) {
22
+ super(`Adapter "${adapterName}" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.`);
23
+ this.name = "MissingSandboxError";
24
+ }
25
+ };
26
+ //#endregion
27
+ export { MissingSandboxError, UnsupportedCapabilityError };
28
+
29
+ //# sourceMappingURL=errors.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sources":["../../src/errors.ts"],"sourcesContent":["/**\n * Thrown when code invokes an optional sandbox capability that the active\n * provider does not support. Core/middleware should check\n * `handle.capabilities` BEFORE using an optional capability and degrade\n * gracefully; this error exists so that a direct call to an unsupported\n * optional method fails loud instead of silently no-opping.\n */\nexport class UnsupportedCapabilityError extends Error {\n readonly provider: string\n readonly capability: string\n\n constructor(provider: string, capability: string, hint?: string) {\n super(\n `Sandbox provider \"${provider}\" does not support the \"${capability}\" capability.` +\n (hint ? ` ${hint}` : ''),\n )\n this.name = 'UnsupportedCapabilityError'\n this.provider = provider\n this.capability = capability\n }\n}\n\n/** Thrown when a harness adapter requires a sandbox but none was provided. */\nexport class MissingSandboxError extends Error {\n constructor(adapterName: string) {\n super(\n `Adapter \"${adapterName}\" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.`,\n )\n this.name = 'MissingSandboxError'\n }\n}\n"],"names":[],"mappings":"AAOO,MAAM,mCAAmC,MAAM;AAAA,EAC3C;AAAA,EACA;AAAA,EAET,YAAY,UAAkB,YAAoB,MAAe;AAC/D;AAAA,MACE,qBAAqB,QAAQ,2BAA2B,UAAU,mBAC/D,OAAO,IAAI,IAAI,KAAK;AAAA,IAAA;AAEzB,SAAK,OAAO;AACZ,SAAK,WAAW;AAChB,SAAK,aAAa;AAAA,EACpB;AACF;AAGO,MAAM,4BAA4B,MAAM;AAAA,EAC7C,YAAY,aAAqB;AAC/B;AAAA,MACE,YAAY,WAAW;AAAA,IAAA;AAEzB,SAAK,OAAO;AAAA,EACd;AACF;"}
1
+ {"version":3,"file":"errors.js","names":[],"sources":["../../src/errors.ts"],"sourcesContent":["/**\n * Thrown when code invokes an optional sandbox capability that the active\n * provider does not support. Core/middleware should check\n * `handle.capabilities` BEFORE using an optional capability and degrade\n * gracefully; this error exists so that a direct call to an unsupported\n * optional method fails loud instead of silently no-opping.\n */\nexport class UnsupportedCapabilityError extends Error {\n readonly provider: string\n readonly capability: string\n\n constructor(provider: string, capability: string, hint?: string) {\n super(\n `Sandbox provider \"${provider}\" does not support the \"${capability}\" capability.` +\n (hint ? ` ${hint}` : ''),\n )\n this.name = 'UnsupportedCapabilityError'\n this.provider = provider\n this.capability = capability\n }\n}\n\n/** Thrown when a harness adapter requires a sandbox but none was provided. */\nexport class MissingSandboxError extends Error {\n constructor(adapterName: string) {\n super(\n `Adapter \"${adapterName}\" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.`,\n )\n this.name = 'MissingSandboxError'\n }\n}\n"],"mappings":";;;;;;;;AAOA,IAAa,6BAAb,cAAgD,MAAM;CACpD;CACA;CAEA,YAAY,UAAkB,YAAoB,MAAe;EAC/D,MACE,qBAAqB,SAAS,0BAA0B,WAAW,kBAChE,OAAO,IAAI,SAAS,GACzB;EACA,KAAK,OAAO;EACZ,KAAK,WAAW;EAChB,KAAK,aAAa;CACpB;AACF;;AAGA,IAAa,sBAAb,cAAyC,MAAM;CAC7C,YAAY,aAAqB;EAC/B,MACE,YAAY,YAAY,oFAC1B;EACA,KAAK,OAAO;CACd;AACF"}
@@ -1,145 +1,161 @@
1
+ //#region src/file-diff.ts
2
+ /** Path relative to the repo/workspace root, POSIX form. */
1
3
  function relTo(root, path) {
2
- const prefix = root.endsWith("/") ? root : `${root}/`;
3
- return path.startsWith(prefix) ? path.slice(prefix.length) : path;
4
+ const prefix = root.endsWith("/") ? root : `${root}/`;
5
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
4
6
  }
7
+ /**
8
+ * POSIX single-quote escape for embedding a value in a shell command.
9
+ */
5
10
  function q(value) {
6
- return `'${value.replace(/'/g, `'\\''`)}'`;
11
+ return `'${value.replace(/'/g, `'\\''`)}'`;
7
12
  }
13
+ /**
14
+ * Unified add-patch for a brand-new file, closely following the shape `git
15
+ * diff` produces for an added file (`diff --git` header + `new file mode` +
16
+ * `--- /dev/null` + `+++ b/<rel>`), so synthesized `create` diffs align with
17
+ * the real `git diff` output emitted for `change` events. `rel` must be the
18
+ * repo-root-relative POSIX path (like git's). Reproduces git's `\ No newline
19
+ * at end of file` marker and the header-only form for a zero-byte file, so a
20
+ * consumer applying the patch reconstructs the file byte-for-byte. It is not
21
+ * byte-identical to git — it omits the `index <hash>..<hash>` line and always
22
+ * writes the `+1,N` hunk count (git omits `,1`) — but both are valid
23
+ * unified-diff and accepted by `git apply`/`patch`.
24
+ */
8
25
  function synthesizeAddPatch(rel, content) {
9
- const header = `diff --git a/${rel} b/${rel}
10
- new file mode 100644
11
- `;
12
- if (content === "") return header;
13
- const hasFinalNewline = content.endsWith("\n");
14
- const lines = content.replace(/\n$/, "").split("\n");
15
- const body = lines.map((l) => `+${l}`).join("\n");
16
- return header + `--- /dev/null
17
- +++ b/${rel}
18
- @@ -0,0 +1,${lines.length} @@
19
- ` + body + (hasFinalNewline ? "\n" : "\n\\n");
26
+ const header = `diff --git a/${rel} b/${rel}\nnew file mode 100644\n`;
27
+ if (content === "") return header;
28
+ const hasFinalNewline = content.endsWith("\n");
29
+ const lines = content.replace(/\n$/, "").split("\n");
30
+ const body = lines.map((l) => `+${l}`).join("\n");
31
+ return header + `--- /dev/null\n+++ b/${rel}\n@@ -0,0 +1,${lines.length} @@\n` + body + (hasFinalNewline ? "\n" : "\n\\n");
20
32
  }
33
+ /**
34
+ * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
35
+ * the live handle. `baseSha` is the session baseline (`''` when the workspace
36
+ * isn't a git repo). Never throws — every git/fs failure falls back to `''`
37
+ * (or a synthesized add-patch), but is logged first via `logger` so a failure
38
+ * is observable instead of silently becoming empty data.
39
+ */
21
40
  function buildFileHookEvent(handle, root, baseSha, event, logger) {
22
- const after = async () => {
23
- if (event.type === "delete") return "";
24
- try {
25
- return await handle.fs.read(event.path);
26
- } catch (error) {
27
- logger?.warn("sandbox after() failed to read file", {
28
- path: event.path,
29
- error
30
- });
31
- return "";
32
- }
33
- };
34
- const before = async () => {
35
- if (baseSha === "") return "";
36
- const rel = relTo(root, event.path);
37
- try {
38
- const res = await handle.process.exec(
39
- `git show ${q(baseSha)}:${q(rel)}`,
40
- { cwd: root }
41
- );
42
- if (res.exitCode === 0) return res.stdout;
43
- logger?.sandbox("before() git show non-zero exit", {
44
- path: event.path,
45
- exitCode: res.exitCode,
46
- stderr: res.stderr
47
- });
48
- return "";
49
- } catch (error) {
50
- logger?.warn("sandbox before() git show failed", {
51
- path: event.path,
52
- error
53
- });
54
- return "";
55
- }
56
- };
57
- const synthesizeIfUntracked = async (rel) => {
58
- const content = await after();
59
- if (content === "") return "";
60
- try {
61
- const ignored = await handle.process.exec(
62
- `git check-ignore -q -- ${q(rel)}`,
63
- { cwd: root }
64
- );
65
- if (ignored.exitCode === 0) {
66
- logger?.sandbox("sandbox diff() withheld for git-ignored file", {
67
- path: event.path
68
- });
69
- return "";
70
- }
71
- if (ignored.exitCode !== 1) {
72
- logger?.warn("sandbox diff() git check-ignore non-zero exit", {
73
- path: event.path,
74
- exitCode: ignored.exitCode,
75
- stderr: ignored.stderr
76
- });
77
- }
78
- } catch (error) {
79
- logger?.warn("sandbox diff() git check-ignore failed", {
80
- path: event.path,
81
- error
82
- });
83
- }
84
- try {
85
- const res = await handle.process.exec(
86
- `git show ${q(baseSha)}:${q(rel)}`,
87
- { cwd: root }
88
- );
89
- if (res.exitCode === 0) return "";
90
- logger?.sandbox(
91
- "sandbox diff() tracked-ness probe non-zero exit (treating as untracked)",
92
- { path: event.path, exitCode: res.exitCode, stderr: res.stderr }
93
- );
94
- return synthesizeAddPatch(rel, content);
95
- } catch (error) {
96
- logger?.warn("sandbox diff() tracked-ness probe failed", {
97
- path: event.path,
98
- error
99
- });
100
- return "";
101
- }
102
- };
103
- const diff = async () => {
104
- if (baseSha === "") {
105
- if (event.type === "delete") return "";
106
- return synthesizeAddPatch(relTo(root, event.path), await after());
107
- }
108
- const rel = relTo(root, event.path);
109
- try {
110
- const res = await handle.process.exec(
111
- `git diff ${q(baseSha)} -- ${q(rel)}`,
112
- {
113
- cwd: root
114
- }
115
- );
116
- if (res.exitCode !== 0) {
117
- logger?.warn("sandbox diff() git diff non-zero exit", {
118
- path: event.path,
119
- exitCode: res.exitCode,
120
- stderr: res.stderr
121
- });
122
- return "";
123
- }
124
- if (res.stdout !== "") return res.stdout;
125
- return synthesizeIfUntracked(rel);
126
- } catch (error) {
127
- logger?.warn("sandbox diff() git diff failed", {
128
- path: event.path,
129
- error
130
- });
131
- return "";
132
- }
133
- };
134
- return { ...event, before, after, diff };
41
+ const after = async () => {
42
+ if (event.type === "delete") return "";
43
+ try {
44
+ return await handle.fs.read(event.path);
45
+ } catch (error) {
46
+ logger?.warn("sandbox after() failed to read file", {
47
+ path: event.path,
48
+ error
49
+ });
50
+ return "";
51
+ }
52
+ };
53
+ const before = async () => {
54
+ if (baseSha === "") return "";
55
+ const rel = relTo(root, event.path);
56
+ try {
57
+ const res = await handle.process.exec(`git show ${q(baseSha)}:${q(rel)}`, { cwd: root });
58
+ if (res.exitCode === 0) return res.stdout;
59
+ logger?.sandbox("before() git show non-zero exit", {
60
+ path: event.path,
61
+ exitCode: res.exitCode,
62
+ stderr: res.stderr
63
+ });
64
+ return "";
65
+ } catch (error) {
66
+ logger?.warn("sandbox before() git show failed", {
67
+ path: event.path,
68
+ error
69
+ });
70
+ return "";
71
+ }
72
+ };
73
+ const synthesizeIfUntracked = async (rel) => {
74
+ const content = await after();
75
+ if (content === "") return "";
76
+ try {
77
+ const ignored = await handle.process.exec(`git check-ignore -q -- ${q(rel)}`, { cwd: root });
78
+ if (ignored.exitCode === 0) {
79
+ logger?.sandbox("sandbox diff() withheld for git-ignored file", { path: event.path });
80
+ return "";
81
+ }
82
+ if (ignored.exitCode !== 1) logger?.warn("sandbox diff() git check-ignore non-zero exit", {
83
+ path: event.path,
84
+ exitCode: ignored.exitCode,
85
+ stderr: ignored.stderr
86
+ });
87
+ } catch (error) {
88
+ logger?.warn("sandbox diff() git check-ignore failed", {
89
+ path: event.path,
90
+ error
91
+ });
92
+ }
93
+ try {
94
+ const res = await handle.process.exec(`git show ${q(baseSha)}:${q(rel)}`, { cwd: root });
95
+ if (res.exitCode === 0) return "";
96
+ logger?.sandbox("sandbox diff() tracked-ness probe non-zero exit (treating as untracked)", {
97
+ path: event.path,
98
+ exitCode: res.exitCode,
99
+ stderr: res.stderr
100
+ });
101
+ return synthesizeAddPatch(rel, content);
102
+ } catch (error) {
103
+ logger?.warn("sandbox diff() tracked-ness probe failed", {
104
+ path: event.path,
105
+ error
106
+ });
107
+ return "";
108
+ }
109
+ };
110
+ const diff = async () => {
111
+ if (baseSha === "") {
112
+ if (event.type === "delete") return "";
113
+ return synthesizeAddPatch(relTo(root, event.path), await after());
114
+ }
115
+ const rel = relTo(root, event.path);
116
+ try {
117
+ const res = await handle.process.exec(`git diff ${q(baseSha)} -- ${q(rel)}`, { cwd: root });
118
+ if (res.exitCode !== 0) {
119
+ logger?.warn("sandbox diff() git diff non-zero exit", {
120
+ path: event.path,
121
+ exitCode: res.exitCode,
122
+ stderr: res.stderr
123
+ });
124
+ return "";
125
+ }
126
+ if (res.stdout !== "") return res.stdout;
127
+ return synthesizeIfUntracked(rel);
128
+ } catch (error) {
129
+ logger?.warn("sandbox diff() git diff failed", {
130
+ path: event.path,
131
+ error
132
+ });
133
+ return "";
134
+ }
135
+ };
136
+ return {
137
+ ...event,
138
+ before,
139
+ after,
140
+ diff
141
+ };
135
142
  }
143
+ /** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
136
144
  function resolveFileEvents(opt) {
137
- if (opt === false) return { enabled: false, diff: false };
138
- if (opt === void 0 || opt === true) return { enabled: true, diff: false };
139
- return { enabled: true, diff: opt.diff === true };
145
+ if (opt === false) return {
146
+ enabled: false,
147
+ diff: false
148
+ };
149
+ if (opt === void 0 || opt === true) return {
150
+ enabled: true,
151
+ diff: false
152
+ };
153
+ return {
154
+ enabled: true,
155
+ diff: opt.diff === true
156
+ };
140
157
  }
141
- export {
142
- buildFileHookEvent,
143
- resolveFileEvents
144
- };
145
- //# sourceMappingURL=file-diff.js.map
158
+ //#endregion
159
+ export { buildFileHookEvent, resolveFileEvents };
160
+
161
+ //# sourceMappingURL=file-diff.js.map