@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,542 @@
1
+ /**
2
+ * The agent output journal: an append-only NDJSON file INSIDE the sandbox that
3
+ * the agent's stdout is redirected to, and that the host tails.
4
+ *
5
+ * This module is pure string composition — no I/O — so every shell fragment the
6
+ * feature depends on is unit-testable without a sandbox, and a successor host
7
+ * derives byte-identical commands from the `runId` alone.
8
+ *
9
+ * Three rules are encoded here and must not be relaxed:
10
+ *
11
+ * 1. **No pipe from the agent.** The agent's stdout is *redirected*, never
12
+ * piped. `agent | tee file` gives the agent a reader whose disappearance
13
+ * SIGPIPEs it — precisely the host-death failure this feature exists to
14
+ * prevent. Redirection leaves nothing to break.
15
+ * 2. **Every read silences stderr; only the BOUNDED read base64-frames its
16
+ * output.** `2>/dev/null` is on both: Daytona's `exec` folds stderr into
17
+ * stdout (`stderr: ''`, by contract) and Sprites' fast path does too, so a
18
+ * `tail` diagnostic would otherwise splice itself into the event bytes.
19
+ * Silencing it inside the sandbox means there is nothing left to fold.
20
+ *
21
+ * base64, however, is only on {@link journalReadCommand}. It cannot be on
22
+ * {@link journalFollowCommand}: `base64` fully buffers its stdout when that
23
+ * is a pipe rather than a tty, so `tail -f file | base64` emits NOTHING
24
+ * until the ~4KB libc stdio buffer fills or `base64`'s stdin closes — and
25
+ * `tail -f`'s stdin never closes until the reader kills it, by which point
26
+ * the consumer has stopped reading. Measured on GNU coreutils 8.32 `base64`
27
+ * (0 bytes delivered over 12s) and on busybox 1.36.1 `base64` in Alpine
28
+ * (identical), so it is a property of stdio, not of a provider or an OS.
29
+ * `stdbuf -o0` does not fix it portably (absent from busybox entirely) and
30
+ * re-`exec`ing `base64` per line costs a fork per journal event.
31
+ *
32
+ * Dropping it from the follow path is safe because the bounded read keeps
33
+ * every property base64 was chosen for where that path needs them, and the
34
+ * follow path needs none of them: `2>/dev/null` already prevents the
35
+ * stderr splice, the journal is line-delimited JSON (a raw newline can only
36
+ * ever be a record separator — inside a JSON string it is `\n`), and
37
+ * `journal-bytes.ts` reassembles bytes across chunk boundaries and yields
38
+ * only newline-terminated lines. The follow path therefore consumes
39
+ * `SpawnHandle.stdout` exactly as `runner.ts` already consumes the agent's
40
+ * own stdout, i.e. it relies on the same provider decoding contract the
41
+ * package already depends on rather than a stricter one.
42
+ * 3. **The journal is touched ONLY through the shell.** On local-process,
43
+ * `fs.write` resolves `/tmp` under the sandbox root while a shell redirect
44
+ * hits the real host `/tmp`. Both halves agree with each other only as long
45
+ * as nothing uses `fs.*` here — hence {@link journalExistsCommand} rather
46
+ * than `handle.fs.exists`.
47
+ *
48
+ * The composed commands below are handed to two different execution
49
+ * mechanisms depending on provider, not always `sh -c`: daytona hands the raw
50
+ * string to `executeCommand` with an `export`-prefixed env, and cloudflare
51
+ * hands it to a Durable Object RPC. Redirection, `mkdir -p`, `tail`, and
52
+ * `base64` all still work because both paths are shell-interpreted
53
+ * downstream — the doc comment intentionally does not claim every provider
54
+ * wraps the command in `sh -c` itself.
55
+ */
56
+ /** Default journal directory. `/tmp` is the convention the harness adapters already use. */
57
+ export declare const DEFAULT_JOURNAL_DIR = "/tmp/tanstack-runs";
58
+ /**
59
+ * Key of the sentinel object the journaled command appends after the agent
60
+ * exits. It tells a *new* host the agent finished, with no pid probe and no
61
+ * provider-specific liveness API — which matters because `pid` is `-1` on five
62
+ * of six providers.
63
+ */
64
+ export declare const EXIT_SENTINEL_KEY = "__exit";
65
+ /**
66
+ * Key carrying the per-run sentinel nonce that makes the sentinel
67
+ * DISTINGUISHABLE from agent output.
68
+ *
69
+ * **Why the nonce exists.** `journaledCommand` redirects the agent's stdout and
70
+ * the sentinel `printf` into the SAME file with no framing, so on the wire an
71
+ * agent's own line is indistinguishable from the shell's. Without a nonce, any
72
+ * agent that ever prints a JSON object carrying `__exit` — echoing a fixture,
73
+ * `cat`-ing a file, dumping diagnostics — makes {@link parseJournalExit} report a
74
+ * MID-FLIGHT run as finished, and `reapOne` then drives that run to terminal and
75
+ * reclaims its sandbox out from under a live agent. A confident wrong answer is
76
+ * strictly worse than the `'unknown'` every other failure on that path returns.
77
+ *
78
+ * **What the nonce is.** A domain-separated SHA-256 of the runId (see
79
+ * {@link journalPaths}), NOT process-random. It has to be recomputable by a
80
+ * SUCCESSOR host from the run record alone — that is this module's stated
81
+ * contract ("a successor host derives byte-identical commands from the `runId`
82
+ * alone"), and the reaper's probe runs in a different process from the one that
83
+ * composed the command, with nothing but the runId to go on. A process-random
84
+ * nonce would make every journal written by a dead host unreadable.
85
+ *
86
+ * **The residual, stated honestly.** Because it is derived rather than secret,
87
+ * an agent that knows its own runId AND reimplements this derivation could still
88
+ * emit a matching line. What the nonce removes is the entire accidental class —
89
+ * which is the class that actually occurs — and it removes it completely. Closing
90
+ * the deliberate case needs a secret the successor host can also read, i.e. a
91
+ * nonce persisted on the run record; that is a `RunStore` schema change, not a
92
+ * change to this pure-composition module. Two further mitigations narrow the
93
+ * deliberate case: {@link parseJournalExit} takes the LAST matching sentinel in
94
+ * the window rather than the first (the shell always writes the real one after
95
+ * the agent's own output), and a matching sentinel whose code is not an integer
96
+ * is refused rather than coerced to 0.
97
+ */
98
+ export declare const EXIT_SENTINEL_NONCE_KEY = "__nonce";
99
+ /** Absolute in-sandbox paths for one run's journal. */
100
+ export interface JournalPaths {
101
+ /** Directory both files live in; created by {@link journaledCommand}. */
102
+ dir: string;
103
+ /** Append-only NDJSON file the agent's stdout is redirected to. */
104
+ journal: string;
105
+ /** Separate file the agent's stderr goes to; NEVER mixed into the journal. */
106
+ stderr: string;
107
+ /**
108
+ * Per-run nonce the exit sentinel carries, so agent stdout cannot forge it.
109
+ * See {@link EXIT_SENTINEL_NONCE_KEY}. Carried alongside the paths because
110
+ * every producer and every reader of the sentinel already threads a
111
+ * `JournalPaths` through, and the two must agree or the run reads as
112
+ * unterminated.
113
+ */
114
+ nonce: string;
115
+ }
116
+ /**
117
+ * The exact sentinel LINE (no trailing newline) `journaledCommand` appends for
118
+ * `exitCode`.
119
+ *
120
+ * Exported because a test or a fake host that seeds a journal by hand has to
121
+ * write the same bytes the shell would; hand-writing `{"__exit":0}` produces a
122
+ * line the reader now correctly refuses. Key order matches the `printf` format
123
+ * below, and both are asserted against each other in `journal.test.ts`.
124
+ */
125
+ export declare function exitSentinelLine(paths: JournalPaths, exitCode: number): string;
126
+ /**
127
+ * Map a runId to a filename-safe token that is INJECTIVE: distinct runIds
128
+ * must never produce the same token, because the journal is looked up by
129
+ * this token alone and a collision means two runs would share one journal —
130
+ * one run's takeover replaying another run's transcript.
131
+ *
132
+ * Encoding rather than rejecting keeps the mapping total: a client may choose
133
+ * any `runId`, and a run that cannot be journaled would be a run that cannot be
134
+ * made durable. The encoding is a pure function of the input, which is what lets
135
+ * a successor host recompute the same path from the run record alone.
136
+ *
137
+ * The scheme is a straightforward escaping over `_`: any character matching
138
+ * `[A-Za-z0-9.-]` passes through literally; everything else — INCLUDING a
139
+ * literal `_` — is replaced by `_` followed by two lowercase hex digits per
140
+ * UTF-8 byte. Because `_` itself is never a safe (pass-through) character,
141
+ * every `_` in the output unambiguously starts a two-hex-digit escape; a
142
+ * left-to-right scan can always tell literal from escape. That is what makes
143
+ * the mapping injective: two different inputs can never parse to the same
144
+ * output, because the (unimplemented, but well-defined) decoder is
145
+ * deterministic — if it were not injective, running that decoder on a shared
146
+ * output would have to yield both original strings, which is impossible for a
147
+ * deterministic function.
148
+ *
149
+ * This is a DELIBERATE change from a prior scheme that also treated `_` as
150
+ * safe. That made the encoding non-injective: `_` doubled as both a literal
151
+ * and the escape prefix, so an escaped byte could read back as a literal
152
+ * escape sequence typed by someone else. Concretely, under the old scheme
153
+ * `encodeRunId('@')` and `encodeRunId('_40')` both produced `'_40'` — `@` is
154
+ * `0x40` and gets escaped to `_40`, while the literal characters `_`, `4`, `0`
155
+ * were all "safe" and passed through unchanged. This change breaks that
156
+ * collision by escaping `_` like any other unsafe character.
157
+ *
158
+ * BREAKING CHANGE for existing journals: a journal file written under the
159
+ * old scheme (where a literal `_` in the runId was left unescaped) will not
160
+ * be found by this scheme, because a runId containing `_` now encodes
161
+ * differently. Durability has not shipped publicly yet (this repo has no
162
+ * released version with `encodeRunId` in it), so there is no compatibility
163
+ * obligation and no changeset is warranted — there is nothing in the wild to
164
+ * migrate.
165
+ *
166
+ * EXPORTED for adapters that derive their OWN in-sandbox paths from a `runId`
167
+ * (`ai-codex`'s prompt file and MCP bridge config, `ai-claude-code`'s prompt
168
+ * file). Durability makes `runId` caller-chosen, so an unencoded interpolation
169
+ * lets a `/` produce a directory-bearing path, `..` escape the workdir, and an
170
+ * over-long id fail the spawn with `ENAMETOOLONG` — the same hazards
171
+ * {@link journalPaths} already routes through here. Reuse this rather than
172
+ * writing a second encoder: a divergent copy would reintroduce the
173
+ * non-injectivity documented above.
174
+ */
175
+ export declare function encodeRunId(runId: string): string;
176
+ /**
177
+ * Reverse of {@link encodeRunId}, for a filename as `ls -1` reports it.
178
+ *
179
+ * `runId` on the success arm is a STORE KEY, never a path component. The
180
+ * encoding is total over client-chosen strings, so a perfectly valid decode can
181
+ * be `'..'`, `'.hidden'`, or `'a/b'` — a caller that interpolates it into a
182
+ * path would escape the journal directory. Look it up in the run store; do not
183
+ * join it onto anything.
184
+ */
185
+ export type DecodedJournalRunId =
186
+ /** The name decoded to exactly one runId. */
187
+ {
188
+ kind: 'runId';
189
+ runId: string;
190
+ }
191
+ /**
192
+ * The name is length-capped output of {@link encodeRunId}, whose truncating
193
+ * branch is LOSSY. The original runId is unrecoverable — KEEP the file.
194
+ */
195
+ | {
196
+ kind: 'truncated';
197
+ }
198
+ /** Not output this module could have produced. KEEP the file. */
199
+ | {
200
+ kind: 'malformed';
201
+ };
202
+ /**
203
+ * Recover the `runId` behind a journal filename — FAIL CLOSED.
204
+ *
205
+ * The consumer of this function DELETES files, so every arm that is not a
206
+ * proven-correct decode must be one the caller keeps. There is no "probably
207
+ * fine" arm.
208
+ *
209
+ * `name` is the filename as {@link journalListCommand} reports it, extension
210
+ * included. The extension is required, not optional: `.` is a pass-through-safe
211
+ * character, so a runId of `'x.ndjson'` encodes to the token `x.ndjson` and the
212
+ * file `x.ndjson.ndjson`. A function that stripped an extension only "if
213
+ * present" could not tell those two strings apart. Requiring it keeps that
214
+ * sharp edge here instead of in every caller that would otherwise reach for
215
+ * `name.split('.')[0]`.
216
+ *
217
+ * **Why `truncated` is a distinct refusal and not a decode.** `encodeRunId`
218
+ * caps its output at {@link MAX_ENCODED_NAME_LENGTH} by replacing the tail with
219
+ * `-` plus a SHA-256 prefix. That branch discards bytes, so the encoding is not
220
+ * invertible there — and because `-` is itself a pass-through-safe character,
221
+ * the truncated form is syntactically indistinguishable from a legitimately
222
+ * encoded id. Decoding it anyway would yield a plausible but WRONG runId; the
223
+ * store would not recognise it, a sweep would read that as "no such run", and
224
+ * it would delete the journal of a run that may still be mid-flight. So any
225
+ * name that *could* be the truncated form is refused, at the cost of never
226
+ * sweeping journals of runIds long enough to hash — a bounded leak, versus
227
+ * data loss on a live run.
228
+ *
229
+ * The truncation check runs BEFORE the character scan on purpose: truncating at
230
+ * a fixed byte offset can cut an `_hh` escape in half, so a truncated name may
231
+ * also be malformed, and the more specific diagnosis is the useful one.
232
+ *
233
+ * The rest is the inverse of the escaping scheme: `[A-Za-z0-9.-]` is a literal
234
+ * ASCII byte, `_` must be followed by EXACTLY two hex digits (either case),
235
+ * and anything else — a bare `_`, a one-digit escape, `/`, `\`, a space — is
236
+ * malformed. The resulting bytes go through a `fatal: true` `TextDecoder`, so
237
+ * an escape sequence that is not valid UTF-8 is a refusal rather than a string
238
+ * silently peppered with U+FFFD (which would be a *different* runId than any
239
+ * encoder input, i.e. exactly the wrong-runId deletion this guards against).
240
+ */
241
+ export declare function decodeJournalRunId(name: string): DecodedJournalRunId;
242
+ /**
243
+ * Derive both journal paths for a run. Pure; no I/O.
244
+ *
245
+ * **`runId` MUST be unique per run.** The journal is append-only by design (a
246
+ * takeover depends on a prefix a previous host delivered still being there), and
247
+ * {@link DEFAULT_JOURNAL_DIR} is a fixed absolute path that outlives any single
248
+ * sandbox, test, or process. So a reused `runId` does not start a fresh journal
249
+ * — it appends to the old one, behind the old run's `{"__exit":N}` sentinel. A
250
+ * streaming reader stops at the first sentinel it reaches — and a reused runId
251
+ * derives the SAME nonce, so the old run's sentinel matches — meaning the new run
252
+ * appears to emit nothing at all, or to fail with the previous run's exit code.
253
+ * (The nonce is per-run, not per-attempt: it defends against the AGENT forging a
254
+ * sentinel, not against a caller reusing an id.) This is not
255
+ * enforced here on purpose: refusing to append would break the takeover the
256
+ * append-only rule exists for. Callers derive `runId` from something unique
257
+ * (the adapters use a timestamp plus a random suffix); a test that hardcodes a
258
+ * literal `runId` will observe a stale run's journal on its second execution.
259
+ */
260
+ export declare function journalPaths(runId: string, dir?: string): JournalPaths;
261
+ /**
262
+ * Wrap an agent command so its stdout lands in the journal, its stderr lands in
263
+ * the sidecar file, and an `{"__exit":N,"__nonce":"…"}` sentinel is appended once
264
+ * it exits.
265
+ *
266
+ * The nonce is what keeps the sentinel apart from the agent's own stdout, which
267
+ * lands in the very same file with no framing — see
268
+ * {@link EXIT_SENTINEL_NONCE_KEY}. It is interpolated as a bare hex token inside
269
+ * a single-quoted `printf` FORMAT string, which is safe by construction:
270
+ * {@link deriveExitSentinelNonce} emits `[0-9a-f]` only, so there is no quote to
271
+ * escape and no `%` for `printf` to interpret.
272
+ *
273
+ * `command` is interpolated raw: callers build real shell text (the Claude Code
274
+ * and Codex adapters append `< promptFile`, for instance), so quoting it would
275
+ * break them. Every path this module contributes IS quoted.
276
+ *
277
+ * `>>` rather than `>` on purpose: truncating would let a stray re-spawn destroy
278
+ * a prefix a previous host already translated and delivered.
279
+ */
280
+ export declare function journaledCommand(command: string, paths: JournalPaths): string;
281
+ /**
282
+ * Following read, for `process.spawn` only. Never pass this to `exec`:
283
+ * `ProcessOptions` has no timeout, so a following `exec` blocks until the
284
+ * sandbox or the RPC times out.
285
+ *
286
+ * Deliberately pipes into NOTHING. `tail -f` flushes each append as it sees it,
287
+ * so it is the one stage in this pipeline that streams; adding any filter puts
288
+ * that filter's stdio buffer between the agent and the host and the follow
289
+ * strategy stops following (see rule 2 in the module doc for the measurements).
290
+ * The host turns these raw bytes into positioned lines with
291
+ * `journal-bytes.ts`.
292
+ *
293
+ * It also creates the journal before tailing it, because `tail -f` on a path
294
+ * that does not exist yet prints a diagnostic and EXITS rather than waiting —
295
+ * so the reader would deliver zero lines for a run whose journal simply had not
296
+ * been created yet. The reader and the agent are two independent spawns and
297
+ * nothing orders them, so that race is the normal case, not the unlucky one.
298
+ * `: >> file` is a builtin no-op plus an O_CREAT|O_APPEND open: it creates the
299
+ * file when absent and, critically, does NOT truncate one that already has a
300
+ * prefix a previous host already delivered. `;` rather than `&&` throughout, so
301
+ * a prep step that fails still lets the `tail` run and fail the way it used to
302
+ * rather than turning a read into a silent no-op. (`tail -F` would also retry,
303
+ * but `-F` is a GNU/busybox extension, not POSIX, and this file only emits
304
+ * POSIX shell.)
305
+ */
306
+ export declare function journalFollowCommand(paths: JournalPaths, fromByte: number): string;
307
+ /**
308
+ * Bounded read: `-f` dropped so it always terminates, and base64-framed because
309
+ * it can be — `exec` closes `base64`'s stdin, which flushes it, and the whole
310
+ * result arrives as one already-complete `ExecResult.stdout` string. This is the
311
+ * Cloudflare path, whose `spawn` cannot be killed and whose `exec` drops the
312
+ * AbortSignal, making a following read unstoppable there.
313
+ */
314
+ export declare function journalReadCommand(paths: JournalPaths, fromByte: number): string;
315
+ /**
316
+ * Existence probe. A shell `test -f`, not `handle.fs.exists`: see rule 3 in the
317
+ * module doc — on local-process the two resolve `/tmp` differently.
318
+ */
319
+ export declare function journalExistsCommand(paths: Pick<JournalPaths, 'journal'>): string;
320
+ /**
321
+ * Bounded read of the stderr SIDECAR (not the journal), so a non-zero exit can
322
+ * carry the agent's own diagnostics instead of a bare exit code.
323
+ *
324
+ * `exec`-only, like {@link journalReadCommand}, and base64-framed for the same
325
+ * reason: `exec` closes the encoder's stdin so it flushes, and the frame keeps a
326
+ * provider that folds stderr into stdout from splicing its own text into the
327
+ * bytes. Unlike the journal, the sidecar is NOT line-delimited JSON — an agent
328
+ * writes whatever it likes there, including partial lines and raw control bytes
329
+ * — so framing is what makes it safe to hand to a single `ExecResult.stdout`.
330
+ *
331
+ * `tail -c -N` (the LAST N bytes) rather than the first: the read has to be
332
+ * bounded, because a runaway agent's sidecar can be arbitrarily large and this
333
+ * runs on the host, and a crash's cause is at the end of stderr, not the start.
334
+ * The cost is that the first character can be a truncated UTF-8 sequence; the
335
+ * caller decodes lossily rather than failing, since this text is diagnostic.
336
+ */
337
+ export declare function journalStderrReadCommand(paths: JournalPaths, maxBytes?: number): string;
338
+ /**
339
+ * Delete both of a run's journal files.
340
+ *
341
+ * **Ordering is the whole contract here, not the `rm`.** This may only run once
342
+ * the run is TERMINAL — i.e. after the `{"__exit":N}` sentinel has been observed
343
+ * — and must never run on an abort. The three claims that make the deletion safe:
344
+ *
345
+ * 1. **Terminal means the event log holds the whole run.** The journal exists so
346
+ * a successor host can replay a run from byte 0 and re-derive the chunks a
347
+ * dead host never got to append. Once the sentinel has been read and the
348
+ * replay has been forwarded, the log — not the journal — is the record. A late
349
+ * takeover therefore aligns against the log: `align.ts`'s `alignToStoredLog`
350
+ * takes a `StreamDurability` and an `AsyncIterable<StreamChunk>`, has no
351
+ * `SandboxHandle` and no {@link JournalPaths} in its signature, and reads the
352
+ * prefix with `durability.snapshot()`. It *cannot* read the journal, so
353
+ * deleting one that is terminal cannot break it.
354
+ * 2. **A non-zero exit is terminal too.** `{"__exit":7}` is as final as
355
+ * `{"__exit":0}`; the run failed, it is not resumable, and the failure is
356
+ * already on its way to the client as a `RUN_ERROR`. Keeping a failed run's
357
+ * journal would leak exactly the runs most likely to be numerous.
358
+ * 3. **An abort is NOT terminal.** A consumer that stops early (lease lost,
359
+ * client gone, host shutting down) may be handing the run off to a successor
360
+ * host that still needs every byte, so an aborted read must leave both files
361
+ * alone.
362
+ *
363
+ * Shell `rm`, never `handle.fs.remove`: rule 3 in the module doc. On
364
+ * local-process `/tmp` resolves under the sandbox root through `fs.*` but to the
365
+ * host's real `/tmp` through the shell, so an `fs.remove` would delete a
366
+ * different path than the one `journaledCommand` wrote — i.e. nothing, silently.
367
+ *
368
+ * `-f` so a journal that is already gone (a provider that reaped `/tmp`, a
369
+ * successor that cleaned up first) is a success, not an error. Callers treat the
370
+ * whole thing as best effort regardless: a failed cleanup must never fail a run
371
+ * that has already completed.
372
+ *
373
+ * **What this does NOT bound:** a run that reaches its sentinel while DETACHED
374
+ * has no host reading its journal, so nothing ever observes the sentinel and
375
+ * nothing calls this. Bounding it is `pruneJournals`' job (`journal-sweep.ts`):
376
+ * a sweep over {@link DEFAULT_JOURNAL_DIR} that deletes only the journals whose
377
+ * runs the store says are terminal. It runs from a cron the application
378
+ * schedules, not from a run, so such a journal survives until that sweep — on a
379
+ * `keepAlive` sandbox, indefinitely without one.
380
+ */
381
+ export declare function journalCleanupCommand(paths: JournalPaths): string;
382
+ /**
383
+ * List the journal directory, one entry per line.
384
+ *
385
+ * **`2>/dev/null` is load-bearing, not tidiness.** Daytona's `exec` folds
386
+ * stderr into stdout by contract and the Sprites fast path does the same, so on
387
+ * a directory that does not exist yet — the normal state before the first run —
388
+ * an `ls: cannot access '/tmp/tanstack-runs': No such file or directory`
389
+ * diagnostic would arrive as if it were a LINE OF OUTPUT. The sweep would then
390
+ * hand that sentence to {@link decodeJournalRunId} and, if it decoded, delete
391
+ * whatever it named. Silencing it inside the sandbox means a missing directory
392
+ * produces zero lines, which is the truth.
393
+ *
394
+ * `-1` so one entry occupies one line: `ls` only defaults to columns on a tty,
395
+ * but `exec`'s stdout is not always a pipe on every provider and the flag costs
396
+ * nothing.
397
+ *
398
+ * **Dot-files are not listed**, by `ls` default. A runId beginning with `.`
399
+ * encodes to a hidden filename (`.` passes through the encoder), so its journal
400
+ * is invisible to a sweep and leaks rather than being deleted. That is the safe
401
+ * direction of the two and the reason this is documented rather than fixed with
402
+ * `-a`, which would also introduce `.` and `..` as entries.
403
+ */
404
+ export declare function journalListCommand(dir?: string): string;
405
+ /** One listed journal file with its modification time. */
406
+ export interface JournalDirEntry {
407
+ /** Filename as listed, extension included; feed to {@link decodeJournalRunId}. */
408
+ name: string;
409
+ /** Modification time in milliseconds since the epoch. */
410
+ mtimeMs: number;
411
+ }
412
+ /**
413
+ * Outcome of {@link parseJournalMtimeListing}. Deliberately NOT an array: see
414
+ * that function's doc for why an empty list must not be the failure value.
415
+ */
416
+ export type JournalMtimeListing =
417
+ /** The mechanism ran. `entries` is complete — possibly, and meaningfully, empty. */
418
+ {
419
+ kind: 'listed';
420
+ entries: Array<JournalDirEntry>;
421
+ }
422
+ /**
423
+ * The listing did not run (no `stat -c`, or the directory is absent). Nothing
424
+ * is known about the directory's contents — in particular NOT that it is
425
+ * empty, and NOT that anything in it is old.
426
+ */
427
+ | {
428
+ kind: 'unavailable';
429
+ };
430
+ /**
431
+ * List the journal directory WITH modification times, so a sweep can leave
432
+ * recently-touched journals alone.
433
+ *
434
+ * **Neither `find -newermt` nor `find -printf` may be used here.** Both are GNU
435
+ * extensions, absent from BusyBox 1.37 — the `alpine:3` shell every docker-
436
+ * provider journal test runs in — and absent from MINGW64's `find`. Measured
437
+ * working on BusyBox 1.37, GNU coreutils, and MINGW64: `stat -c "%Y %n"`, which
438
+ * is what this emits. (`touch -d <ts> ref` plus `find ! -newer ref` also works
439
+ * on all three, but it needs a writable reference file OUTSIDE the journal
440
+ * directory — inside, `ls -1` would report the reference as an entry — and a
441
+ * write is a side effect this pure-composition module has no business having.)
442
+ *
443
+ * **The directory is passed as its own first operand on purpose.** It is a
444
+ * self-witness. `stat` reports every operand it can and only *then* exits
445
+ * non-zero, so:
446
+ *
447
+ * - populated directory → witness line + one line per file, exit 0
448
+ * - EMPTY directory → witness line only, exit 1 (the unexpanded glob is an
449
+ * operand `stat` cannot stat)
450
+ * - `stat` without `-c` support → NO output at all, exit 1
451
+ *
452
+ * That is what makes "no files" distinguishable from "the mechanism is
453
+ * unavailable", and it has to be distinguishable because BusyBox exits 1 with
454
+ * EMPTY stdout on an unrecognised flag. A caller that ignored the exit code and
455
+ * took an empty parse as an empty directory would conclude every journal is
456
+ * absent; one that then inferred "therefore nothing is recent" would delete the
457
+ * whole directory. Hence {@link parseJournalMtimeListing} returns
458
+ * `{ kind: 'unavailable' }` rather than `[]`, and the exit code is not consulted
459
+ * at all — the witness line, not the status, is the evidence.
460
+ *
461
+ * Note the glob shares `ls`'s dot-file blindness (same fail-safe consequence),
462
+ * and that `stat` cannot distinguish a file from a subdirectory here; a stray
463
+ * subdirectory is caught downstream, because its name will not decode.
464
+ */
465
+ export declare function journalMtimeListCommand(dir?: string): string;
466
+ /**
467
+ * Parse {@link journalMtimeListCommand}'s stdout.
468
+ *
469
+ * Line-based, space-split parsing is unambiguous here: an encoded filename can
470
+ * only contain `[A-Za-z0-9.-]` and `_hh` escapes (see {@link encodeRunId}), so
471
+ * it can never contain a space or a newline, and `%Y` is digits. A line that
472
+ * does not fit the shape — including a directory prefix that is not `dir` — is
473
+ * dropped rather than guessed at.
474
+ */
475
+ export declare function parseJournalMtimeListing(text: string, dir?: string): JournalMtimeListing;
476
+ /**
477
+ * Bounded read of the END of a run's journal, purely to learn whether the agent
478
+ * reached its `{"__exit":N}` sentinel.
479
+ *
480
+ * **This exists so a reaper does not have to drive the run to find out.**
481
+ * Entering `pipeToRunLog` to check writes a terminal status and calls
482
+ * `durability.close()` on every path, including for a healthy mid-flight run —
483
+ * recording it as `'completed'`, which drops it out of `listReclaimable`
484
+ * forever. This probe is read-only and provider-neutral, and it is what makes a
485
+ * reclaim candidate safe to drive.
486
+ *
487
+ * The command is the byte-identical idiom to {@link journalStderrReadCommand},
488
+ * pointed at the journal instead of the sidecar: `tail -c -N` (the LAST N
489
+ * bytes, because the sentinel is at the end), `2>/dev/null` so a missing
490
+ * journal cannot splice a diagnostic into the bytes on a provider that folds
491
+ * stderr into stdout, and base64 framing. Verified on BusyBox 1.37.
492
+ *
493
+ * base64 is correct HERE and forbidden on {@link journalFollowCommand} for the
494
+ * reason rule 2 in the module doc measures: the encoder fully buffers a piped
495
+ * stdout, which is harmless when `exec` closes its stdin and fatal when the
496
+ * producer is `tail -f`. This read is bounded and terminates, so it never
497
+ * streams.
498
+ */
499
+ export declare function journalExitProbeCommand(paths: JournalPaths, maxBytes?: number): string;
500
+ /**
501
+ * Is ONE journal line this run's genuine exit sentinel? The exit code if so,
502
+ * `null` for anything else — including a line that carries
503
+ * {@link EXIT_SENTINEL_KEY} but not this run's nonce, which is agent output and
504
+ * nothing more.
505
+ *
506
+ * FAIL CLOSED at every step, because the consumers of a non-`null` answer stop
507
+ * the run and reclaim its sandbox:
508
+ *
509
+ * - not JSON, or not an object → `null`. This is also what absorbs the partial
510
+ * first line a byte-bounded `tail -c -N` can start in the middle of.
511
+ * - no `__nonce`, or a `__nonce` that is not exactly `paths.nonce` → `null`. An
512
+ * agent line cannot be told from the shell's without this (see
513
+ * {@link EXIT_SENTINEL_NONCE_KEY}).
514
+ * - a matching nonce but a non-integer `__exit` → `null`, NOT `0`. The old code
515
+ * coerced a non-number to `0`, which turned a garbled sentinel into a reported
516
+ * SUCCESS. Nothing that reaches here legitimately can be non-integer: the only
517
+ * writer is `printf '…%d…' "$?"`.
518
+ *
519
+ * Exported so the streaming reader (`runner.ts`) applies exactly the same test,
520
+ * line by line, that the reaper's bounded tail probe applies — one definition of
521
+ * "the run ended", not two that can drift.
522
+ */
523
+ export declare function parseExitSentinel(line: string, paths: JournalPaths): number | null;
524
+ /**
525
+ * Find the exit sentinel in a decoded journal tail; `null` when it is absent,
526
+ * which is the mid-flight (or never-started) case.
527
+ *
528
+ * **Scanned from the END, and the nonce is REQUIRED.** Both matter, and both are
529
+ * corrections:
530
+ *
531
+ * - The shell appends the real sentinel AFTER the command's own output, so the
532
+ * genuine one is always the last matching line in the window. Taking the first
533
+ * match let an agent line that happened to look like a sentinel win over the
534
+ * truth that followed it.
535
+ * - `paths.nonce` must match, or the line is not a sentinel at all. Without that,
536
+ * a mid-flight run whose agent printed any JSON object containing `__exit` read
537
+ * as `finished`, and the reaper destroyed a live sandbox on the strength of it.
538
+ *
539
+ * `paths` rather than a bare nonce string so callers pass the object they already
540
+ * hold and cannot pair a tail with another run's nonce.
541
+ */
542
+ export declare function parseJournalExit(text: string, paths: JournalPaths): number | null;