@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,359 @@
1
+ /**
2
+ * Read a run's journal, live or after the fact, on one code path.
3
+ *
4
+ * Resume is not a special case: every read is `tail -c +N` for some N, and a
5
+ * fresh run is simply N = 0. That is deliberate — `pid` is `-1` on five of six
6
+ * providers, so re-attaching to an existing reader is impossible and a resumed
7
+ * read always spawns a new `tail` anyway.
8
+ *
9
+ * Two strategies, chosen by capability rather than by provider name:
10
+ *
11
+ * - **follow** (`spawn` + `tail -f`): the default. Streams with no polling cost
12
+ * and is killed when the consumer stops. Its command pipes into nothing — see
13
+ * `journal.ts` rule 2 — so this path re-encodes the provider's decoded text
14
+ * rather than decoding a base64 frame.
15
+ * - **poll** (bounded `exec`, no `-f`): for a provider whose spawned process
16
+ * cannot be stopped. Cloudflare's `kill()` is a documented no-op and it
17
+ * forwards the AbortSignal to neither `exec` nor `spawn`, so a `tail -f`
18
+ * there would run forever inside the container. Every poll command terminates
19
+ * on its own, so nothing needs killing.
20
+ *
21
+ * **Neither strategy may wait forever for its FIRST byte.** This is the bound
22
+ * that used to be missing, and its absence was reachable three ways, one of them
23
+ * self-inflicted:
24
+ *
25
+ * 1. `journalFollowCommand` CREATES the journal before tailing it (`: >> file`),
26
+ * which it must, so a read for a runId whose journal never existed
27
+ * manufactures an empty file and tails it forever. The attach preflight
28
+ * (`attach-preflight.ts`) catches most of those, but it is wired at exactly
29
+ * one call site and only for `attach === true` — the exported
30
+ * {@link readJournal} that `docs/sandbox/journal.md` tells users to write has
31
+ * no preflight, no store, and no runId to look one up with.
32
+ * 2. The preflight's own probe can be unusable, and it deliberately falls through
33
+ * to a bounded wait rather than skipping; a journal that exists but is
34
+ * abandoned still reaches the reader.
35
+ * 3. SIGKILL/OOM of the agent's shell between its last line and its sentinel
36
+ * `printf` leaves a real, non-empty, permanently-silent journal.
37
+ *
38
+ * So a read that receives NO bytes within {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS}
39
+ * raises {@link JournalAttachUnavailableError} with reason `'journal-stalled'`
40
+ * instead of parking. The bound is on the FIRST byte only, deliberately: once the
41
+ * journal is producing, how long the agent thinks between lines is the agent's
42
+ * business and no deadline here may cut a healthy run short. A consumer abort is
43
+ * not a stall — it ends the read quietly, as it always did.
44
+ */
45
+ import {
46
+ DEFAULT_ATTACH_JOURNAL_WAIT_MS,
47
+ JournalAttachUnavailableError,
48
+ } from './attach-preflight'
49
+ import { journalFollowCommand, journalReadCommand } from './journal'
50
+ import {
51
+ decodeBase64Stream,
52
+ encodeUtf8Stream,
53
+ toJournalLines,
54
+ } from './journal-bytes'
55
+ import type { JournalPaths } from './journal'
56
+ import type { JournalLine } from './journal-bytes'
57
+ import type { ProcessOptions, SandboxHandle } from './contracts'
58
+
59
+ /**
60
+ * Poll interval for the bounded-`exec` strategy. Matches the interval
61
+ * `ai-sandbox-cloudflare`'s run-log Durable Object already uses, so the two
62
+ * readers have the same latency profile.
63
+ */
64
+ export const DEFAULT_JOURNAL_POLL_MS = 250
65
+
66
+ export interface ReadJournalOptions {
67
+ paths: JournalPaths
68
+ /**
69
+ * Count of journal bytes already consumed. The read starts at the next byte.
70
+ * Defaults to 0, which is also what a takeover uses: the alignment step, not
71
+ * the reader, decides what has already been delivered.
72
+ */
73
+ fromByte?: number
74
+ /** Stop reading. On the follow strategy this also kills the `tail`. */
75
+ signal?: AbortSignal
76
+ /** Override the capability-derived strategy. Tests and diagnostics only. */
77
+ strategy?: 'follow' | 'poll'
78
+ /** Poll strategy only. Defaults to {@link DEFAULT_JOURNAL_POLL_MS}. */
79
+ pollIntervalMs?: number
80
+ /** Working directory for the read command. Paths are absolute, so rarely needed. */
81
+ cwd?: string
82
+ /**
83
+ * How long to wait for the FIRST byte of the journal before failing with
84
+ * `'journal-stalled'`. Defaults to {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} — the
85
+ * same number that bounds the attach preflight, because it bounds the same
86
+ * question from the other side. `0` or a non-finite value disables the bound;
87
+ * do that only where some OTHER deadline already covers the read, since an
88
+ * unbounded read of an empty journal never returns.
89
+ *
90
+ * Only the first byte is bounded. An agent that streams slowly is never cut
91
+ * off.
92
+ */
93
+ firstByteTimeoutMs?: number
94
+ /**
95
+ * Run id, for the stall error's message only. Defaults to naming the journal
96
+ * path, which is always available and always identifies the run uniquely.
97
+ */
98
+ runId?: string
99
+ }
100
+
101
+ /**
102
+ * Which read strategy a provider supports.
103
+ *
104
+ * Keyed on capabilities, never on `handle.provider`: a BYO provider with the
105
+ * same limitation must get the same treatment, and name-sniffing would silently
106
+ * hand it an unstoppable `tail -f`.
107
+ */
108
+ export function journalReadStrategy(handle: SandboxHandle): 'follow' | 'poll' {
109
+ const { backgroundProcesses, killableProcesses } = handle.capabilities
110
+ return backgroundProcesses && killableProcesses ? 'follow' : 'poll'
111
+ }
112
+
113
+ function processOptions(options: ReadJournalOptions): ProcessOptions {
114
+ return {
115
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
116
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
117
+ }
118
+ }
119
+
120
+ /** Resolution of the abort race in {@link untilAborted}. Never a stream value. */
121
+ const ABORTED = Symbol('journal-read-aborted')
122
+
123
+ /**
124
+ * Iterate `source` but stop the moment `signal` fires, instead of waiting for
125
+ * the stream to close.
126
+ *
127
+ * Without this, aborting a follow read only *asks* the provider to kill `tail`
128
+ * and then blocks on `stdout` until that kill closes the pipe — which is not a
129
+ * guarantee any provider makes. On local-process/Windows, `killTree` falls back
130
+ * to signalling only the `sh` wrapper if `taskkill` is unavailable, leaving the
131
+ * `tail` grandchild holding the stdout pipe open, and the read rides past its
132
+ * own AbortSignal until some outer timeout fires. The signal is the caller's
133
+ * contract with the reader, so the reader honors it itself and treats the kill
134
+ * as best-effort cleanup. (local-process now also verifies the tree is gone and
135
+ * sweeps the MSYS grandchildren `taskkill /T` cannot reach, but that is a
136
+ * provider improving its best effort — not a guarantee this reader may assume of
137
+ * any provider.)
138
+ */
139
+ async function* untilAborted<T>(
140
+ source: AsyncIterable<T>,
141
+ signal: AbortSignal | undefined,
142
+ ): AsyncIterable<T> {
143
+ if (!signal) {
144
+ yield* source
145
+ return
146
+ }
147
+ if (signal.aborted) return
148
+ let onAbort: (() => void) | undefined
149
+ const aborted = new Promise<typeof ABORTED>((resolve) => {
150
+ onAbort = () => resolve(ABORTED)
151
+ signal.addEventListener('abort', onAbort, { once: true })
152
+ })
153
+ const iterator = source[Symbol.asyncIterator]()
154
+ try {
155
+ for (;;) {
156
+ const next = await Promise.race([iterator.next(), aborted])
157
+ if (next === ABORTED || next.done === true) return
158
+ yield next.value
159
+ }
160
+ } finally {
161
+ if (onAbort) signal.removeEventListener('abort', onAbort)
162
+ // NOT awaited. On an async generator, `return()` queues behind the pending
163
+ // `next()` we just abandoned, so awaiting it would block for exactly as
164
+ // long as the stream we gave up waiting for — reintroducing the hang this
165
+ // helper exists to remove. The rejection is swallowed for the same reason
166
+ // `kill` is best-effort below: the source may already be gone.
167
+ void iterator.return?.().catch(() => {})
168
+ }
169
+ }
170
+
171
+ /** Resolution of the first-byte race in {@link withFirstByteDeadline}. */
172
+ const STALLED = Symbol('journal-read-stalled')
173
+
174
+ /** The bound in effect for a read; `undefined` when the caller disabled it. */
175
+ function firstByteTimeout(options: ReadJournalOptions): number | undefined {
176
+ const ms = options.firstByteTimeoutMs ?? DEFAULT_ATTACH_JOURNAL_WAIT_MS
177
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined
178
+ }
179
+
180
+ /**
181
+ * The `'journal-stalled'` failure, shared by both strategies so the two report
182
+ * the same diagnosis for the same state.
183
+ */
184
+ function stalled(
185
+ options: ReadJournalOptions,
186
+ timeoutMs: number,
187
+ ): JournalAttachUnavailableError {
188
+ return new JournalAttachUnavailableError(
189
+ options.runId ?? options.paths.journal,
190
+ 'journal-stalled',
191
+ `its journal (${options.paths.journal}) delivered no bytes within ${timeoutMs}ms. ` +
192
+ `The file exists but nothing is appending to it and no '__exit' sentinel can arrive, ` +
193
+ `so following it would never return: either the read created it itself (a runId with no journal), ` +
194
+ `or the agent's shell was killed before it could write its sentinel.`,
195
+ )
196
+ }
197
+
198
+ /**
199
+ * Pass `source` through unchanged, except that receiving NO value within
200
+ * `timeoutMs` throws.
201
+ *
202
+ * Only the first value is raced. After it, the source is iterated directly, so a
203
+ * long gap between later values costs nothing and cannot fail a healthy read.
204
+ *
205
+ * A source that simply ENDS before the deadline is not a stall — that is the
206
+ * consumer's abort (`untilAborted` returns on abort) or a `tail` that exited —
207
+ * and it returns quietly, preserving the "an abort diagnoses nothing" rule.
208
+ */
209
+ async function* withFirstByteDeadline<T>(
210
+ source: AsyncIterable<T>,
211
+ timeoutMs: number | undefined,
212
+ onStall: () => JournalAttachUnavailableError,
213
+ ): AsyncIterable<T> {
214
+ if (timeoutMs === undefined) {
215
+ yield* source
216
+ return
217
+ }
218
+ const iterator = source[Symbol.asyncIterator]()
219
+ let timer: ReturnType<typeof setTimeout> | undefined
220
+ const expired = new Promise<typeof STALLED>((resolve) => {
221
+ timer = setTimeout(() => resolve(STALLED), timeoutMs)
222
+ })
223
+ try {
224
+ const first = await Promise.race([iterator.next(), expired])
225
+ if (first === STALLED) throw onStall()
226
+ if (first.done === true) return
227
+ yield first.value
228
+ for (;;) {
229
+ const next = await iterator.next()
230
+ if (next.done === true) return
231
+ yield next.value
232
+ }
233
+ } finally {
234
+ clearTimeout(timer)
235
+ // NOT awaited, for the reason `untilAborted` documents: on the stall path the
236
+ // abandoned `next()` is exactly the promise that never settles, so awaiting
237
+ // the `return()` queued behind it would reinstate the hang being reported.
238
+ void iterator.return?.().catch(() => {})
239
+ }
240
+ }
241
+
242
+ async function* followJournal(
243
+ handle: SandboxHandle,
244
+ options: ReadJournalOptions,
245
+ ): AsyncIterable<JournalLine> {
246
+ const fromByte = options.fromByte ?? 0
247
+ const proc = await handle.process.spawn(
248
+ journalFollowCommand(options.paths, fromByte),
249
+ processOptions(options),
250
+ )
251
+ const timeoutMs = firstByteTimeout(options)
252
+ try {
253
+ yield* toJournalLines(
254
+ encodeUtf8Stream(
255
+ withFirstByteDeadline(
256
+ untilAborted(proc.stdout, options.signal),
257
+ timeoutMs,
258
+ // Narrowed by `withFirstByteDeadline` only calling this when the bound
259
+ // is in effect; `?? 0` keeps that provable without an assertion.
260
+ () => stalled(options, timeoutMs ?? 0),
261
+ ),
262
+ ),
263
+ fromByte,
264
+ )
265
+ } finally {
266
+ // The consumer may stop early (client gone, lease lost). Providers whose
267
+ // `kill` is real stop the `tail` here; the signal covers the rest. Guarded
268
+ // because a `finally` that throws would replace the consumer's own reason
269
+ // for stopping.
270
+ try {
271
+ await proc.kill()
272
+ } catch {
273
+ // Best effort: the process may already be gone.
274
+ }
275
+ }
276
+ }
277
+
278
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
279
+ if (ms <= 0) return Promise.resolve()
280
+ return new Promise<void>((resolve) => {
281
+ const timer = setTimeout(finish, ms)
282
+ function finish(): void {
283
+ clearTimeout(timer)
284
+ signal?.removeEventListener('abort', finish)
285
+ resolve()
286
+ }
287
+ signal?.addEventListener('abort', finish, { once: true })
288
+ })
289
+ }
290
+
291
+ async function* singleValue(value: string): AsyncIterable<string> {
292
+ yield value
293
+ }
294
+
295
+ async function* pollJournal(
296
+ handle: SandboxHandle,
297
+ options: ReadJournalOptions,
298
+ ): AsyncIterable<JournalLine> {
299
+ const intervalMs = options.pollIntervalMs ?? DEFAULT_JOURNAL_POLL_MS
300
+ const timeoutMs = firstByteTimeout(options)
301
+ // Same bound as the follow path, expressed the way a polling loop can enforce
302
+ // it: an empty frame every time until the deadline is a stalled journal, and
303
+ // parking here forever is the same defect from the other strategy.
304
+ const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs
305
+ let sawBytes = false
306
+ let position = options.fromByte ?? 0
307
+ while (!options.signal?.aborted) {
308
+ const result = await handle.process.exec(
309
+ journalReadCommand(options.paths, position),
310
+ processOptions(options),
311
+ )
312
+ if (result.stdout.trim() !== '') sawBytes = true
313
+ if (
314
+ !sawBytes &&
315
+ deadline !== undefined &&
316
+ timeoutMs !== undefined &&
317
+ Date.now() >= deadline
318
+ ) {
319
+ throw stalled(options, timeoutMs)
320
+ }
321
+ // Each poll re-reads from `position`, so a line left incomplete by the
322
+ // previous poll is simply re-fetched whole. That is why `position` advances
323
+ // only on a COMPLETE line: advancing on bytes received would strand a
324
+ // partial line's prefix and corrupt every following line.
325
+ for await (const line of toJournalLines(
326
+ decodeBase64Stream(singleValue(result.stdout)),
327
+ position,
328
+ )) {
329
+ yield line
330
+ position = line.endPosition
331
+ }
332
+ if (options.signal?.aborted) return
333
+ await sleep(intervalMs, options.signal)
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Read a run's journal as positioned lines.
339
+ *
340
+ * **This is a public entry point and it CANNOT hang.** It has no `RunStore` in
341
+ * its signature and no runId to look one up with, so it cannot run the
342
+ * `attach-preflight.ts` gate that classifies a stale or mistyped runId as
343
+ * `'unknown-run'`/`'terminal-run'`; what it has instead is the unconditional
344
+ * bound described in the module doc. A runId with no journal therefore fails with
345
+ * {@link JournalAttachUnavailableError} (`reason: 'journal-stalled'`) after
346
+ * {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} rather than tailing an empty file it
347
+ * just created, for ever, with no error and no log line. Callers that DO have a
348
+ * store — `runner.ts` on an attach — run the preflight as well, for the sharper
349
+ * diagnosis.
350
+ */
351
+ export function readJournal(
352
+ handle: SandboxHandle,
353
+ options: ReadJournalOptions,
354
+ ): AsyncIterable<JournalLine> {
355
+ const strategy = options.strategy ?? journalReadStrategy(handle)
356
+ return strategy === 'follow'
357
+ ? followJournal(handle, options)
358
+ : pollJournal(handle, options)
359
+ }