@tanstack/ai-sandbox 0.2.3 → 0.3.0

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