@dynamicagents/plugins 0.11.1 → 0.13.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 (134) hide show
  1. package/README.md +59 -79
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +53 -0
  5. package/dist/claude-code/cards.js +272 -0
  6. package/dist/claude-code/config.d.ts +73 -31
  7. package/dist/claude-code/config.js +0 -2
  8. package/dist/claude-code/credentials.d.ts +21 -9
  9. package/dist/claude-code/credentials.js +43 -9
  10. package/dist/claude-code/egress.d.ts +15 -14
  11. package/dist/claude-code/egress.js +51 -14
  12. package/dist/claude-code/events.d.ts +153 -9
  13. package/dist/claude-code/events.js +289 -58
  14. package/dist/claude-code/github.d.ts +39 -0
  15. package/dist/claude-code/github.js +82 -0
  16. package/dist/claude-code/index.d.ts +24 -80
  17. package/dist/claude-code/index.js +19 -234
  18. package/dist/claude-code/model.d.ts +152 -0
  19. package/dist/claude-code/model.js +342 -0
  20. package/dist/claude-code/run.d.ts +283 -92
  21. package/dist/claude-code/run.js +620 -114
  22. package/dist/claude-code/session.d.ts +131 -0
  23. package/dist/claude-code/session.js +285 -0
  24. package/dist/claude-code/spec.d.ts +46 -0
  25. package/dist/claude-code/spec.js +83 -0
  26. package/dist/claude-code/verified.d.ts +15 -0
  27. package/dist/claude-code/verified.js +15 -0
  28. package/dist/computer/context.d.ts +19 -0
  29. package/dist/computer/context.js +222 -0
  30. package/dist/computer/file-lock.d.ts +5 -0
  31. package/dist/computer/file-lock.js +40 -0
  32. package/dist/computer/gate.d.ts +12 -12
  33. package/dist/computer/gate.js +12 -12
  34. package/dist/computer/index.d.ts +57 -255
  35. package/dist/computer/index.js +98 -1039
  36. package/dist/computer/paths.d.ts +47 -22
  37. package/dist/computer/paths.js +64 -49
  38. package/dist/computer/proxy.d.ts +45 -0
  39. package/dist/computer/proxy.js +234 -0
  40. package/dist/computer/render.d.ts +27 -38
  41. package/dist/computer/render.js +32 -65
  42. package/dist/computer/tools-exec.d.ts +3 -0
  43. package/dist/computer/tools-exec.js +268 -0
  44. package/dist/computer/tools-file.d.ts +12 -0
  45. package/dist/computer/tools-file.js +64 -0
  46. package/dist/computer/tools-grep.d.ts +3 -0
  47. package/dist/computer/tools-grep.js +129 -0
  48. package/dist/repo/checkout.d.ts +35 -6
  49. package/dist/repo/checkout.js +64 -6
  50. package/dist/repo/context.d.ts +135 -0
  51. package/dist/repo/context.js +582 -0
  52. package/dist/repo/index.d.ts +79 -57
  53. package/dist/repo/index.js +118 -1112
  54. package/dist/repo/tools-checks.d.ts +3 -0
  55. package/dist/repo/tools-checks.js +112 -0
  56. package/dist/repo/tools-clone.d.ts +4 -0
  57. package/dist/repo/tools-clone.js +192 -0
  58. package/dist/repo/tools-pr.d.ts +14 -0
  59. package/dist/repo/tools-pr.js +196 -0
  60. package/dist/repo/tools-review.d.ts +12 -0
  61. package/dist/repo/tools-review.js +393 -0
  62. package/dist/repo/tools-worktree.d.ts +4 -0
  63. package/dist/repo/tools-worktree.js +305 -0
  64. package/dist/repo/tools-worktrees.d.ts +12 -0
  65. package/dist/repo/tools-worktrees.js +48 -0
  66. package/dist/repo/url.d.ts +2 -2
  67. package/dist/repo/url.js +7 -7
  68. package/dist/scratch/index.d.ts +4 -4
  69. package/dist/scratch/index.js +12 -11
  70. package/dist/{computer → workspace}/advisory.d.ts +33 -20
  71. package/dist/{computer → workspace}/advisory.js +32 -7
  72. package/dist/workspace/ca-trust.d.ts +95 -0
  73. package/dist/workspace/ca-trust.js +312 -0
  74. package/dist/workspace/container-deps.d.ts +54 -0
  75. package/dist/workspace/container-deps.js +224 -0
  76. package/dist/workspace/container-fault.d.ts +38 -0
  77. package/dist/workspace/container-fault.js +73 -0
  78. package/dist/workspace/exec.d.ts +94 -0
  79. package/dist/workspace/exec.js +153 -0
  80. package/dist/workspace/format.d.ts +36 -0
  81. package/dist/workspace/format.js +57 -0
  82. package/dist/workspace/git-host.d.ts +79 -0
  83. package/dist/workspace/git-host.js +226 -0
  84. package/dist/workspace/git-identity.d.ts +57 -0
  85. package/dist/workspace/git-identity.js +147 -0
  86. package/dist/workspace/index.d.ts +43 -47
  87. package/dist/workspace/index.js +52 -112
  88. package/dist/workspace/install-job.d.ts +121 -0
  89. package/dist/workspace/install-job.js +866 -0
  90. package/dist/{computer → workspace}/install.d.ts +15 -19
  91. package/dist/{computer → workspace}/install.js +8 -11
  92. package/dist/workspace/object.d.ts +502 -0
  93. package/dist/workspace/object.js +1427 -0
  94. package/dist/workspace/open.d.ts +95 -0
  95. package/dist/workspace/open.js +63 -0
  96. package/dist/workspace/read.d.ts +16 -0
  97. package/dist/workspace/read.js +22 -0
  98. package/dist/workspace/shell.d.ts +45 -0
  99. package/dist/workspace/shell.js +80 -0
  100. package/dist/workspace/sync.d.ts +111 -0
  101. package/dist/workspace/sync.js +124 -0
  102. package/dist/workspace/wake.d.ts +19 -0
  103. package/dist/workspace/wake.js +10 -0
  104. package/package.json +28 -42
  105. package/dist/arc-agi/analysis.d.ts +0 -336
  106. package/dist/arc-agi/analysis.js +0 -829
  107. package/dist/arc-agi/client.d.ts +0 -82
  108. package/dist/arc-agi/client.js +0 -113
  109. package/dist/arc-agi/game-tools.d.ts +0 -25
  110. package/dist/arc-agi/game-tools.js +0 -26
  111. package/dist/arc-agi/index.d.ts +0 -42
  112. package/dist/arc-agi/index.js +0 -175
  113. package/dist/arc-agi/main-agent.d.ts +0 -27
  114. package/dist/arc-agi/main-agent.js +0 -65
  115. package/dist/arc-agi/recipe.d.ts +0 -51
  116. package/dist/arc-agi/recipe.js +0 -100
  117. package/dist/arc-agi/scorecard.d.ts +0 -118
  118. package/dist/arc-agi/scorecard.js +0 -163
  119. package/dist/arc-agi/soul.d.ts +0 -40
  120. package/dist/arc-agi/soul.js +0 -90
  121. package/dist/arc-agi/store.d.ts +0 -154
  122. package/dist/arc-agi/store.js +0 -216
  123. package/dist/arc-agi/tools.d.ts +0 -36
  124. package/dist/arc-agi/tools.js +0 -576
  125. package/dist/arc-agi/types.d.ts +0 -212
  126. package/dist/arc-agi/types.js +0 -19
  127. package/dist/claude-code/recipe.d.ts +0 -53
  128. package/dist/claude-code/recipe.js +0 -100
  129. package/dist/computer/read.d.ts +0 -137
  130. package/dist/computer/read.js +0 -203
  131. package/dist/recall/index.d.ts +0 -99
  132. package/dist/recall/index.js +0 -171
  133. package/dist/triage/index.d.ts +0 -355
  134. package/dist/triage/index.js +0 -302
@@ -1,35 +1,41 @@
1
1
  import { shellQuote } from "@cloudflare/computer";
2
- import { parseStream, toProgress } from "./events.js";
2
+ import { GITHUB_ENV_KEYS, githubEnv } from "./github.js";
3
+ import { parseStream, toProgress, RATE_LIMIT_OK } from "./events.js";
3
4
  import { DEFAULT_PERMISSION_MODE } from "./config.js";
4
5
  /** Namespace for every session exec id in a workspace. */
5
6
  export const CLAUDE_EXEC_PREFIX = "claude-code-run";
6
7
  /**
7
8
  * The exec id one session occupies.
8
9
  *
9
- * **Per subtask, not fixed**, and the difference is load-bearing. A workspace is
10
- * one Durable Object and one container, but subtasks are a flat *concurrent*
11
- * fan-out — two `claude-code` subtasks for the same caller and repository run at
12
- * the same time, against the same workspace. Under a single shared id they
13
- * would spawn over one another, each drain would attach to whichever exec won,
14
- * and `killRun` would stop somebody else's session. That is the displacement bug
15
- * the coder's install guard exists to prevent, in a new place; here the answer
16
- * is simply not to share the id.
10
+ * **Per run, not fixed**, and the difference is load-bearing. A workspace is
11
+ * one Durable Object and one container, but runs are concurrent — sessions in a
12
+ * scratchpad share their parent's container — so under a single shared id they would spawn
13
+ * over one another, each drain would attach to whichever exec won, and
14
+ * `killRun` would stop somebody else's session. That is the displacement bug the
15
+ * coder's install guard exists to prevent, in a new place; here the answer is
16
+ * simply not to share the id.
17
17
  *
18
- * Fixed *per subtask*, though, because the point is still to find it again: an
18
+ * Fixed *per run*, though, because the point is still to find it again: an
19
19
  * isolate that dies mid-drain leaves the session running in the container, and
20
- * `getExec` is how the next chunk re-attaches instead of starting a second one.
20
+ * `getExec` is how the next drain re-attaches instead of starting a second one.
21
21
  */
22
- export function execIdFor(subtaskId) {
23
- return `${CLAUDE_EXEC_PREFIX}:${subtaskId}`;
22
+ export function execIdFor(runId) {
23
+ return `${CLAUDE_EXEC_PREFIX}:${runId}`;
24
+ }
25
+ /**
26
+ * The id a run's follow-up turn runs under — its own, because the session's exec
27
+ * has already finished under {@link execIdFor} and an id names one process.
28
+ */
29
+ export function followUpExecIdFor(runId) {
30
+ return `${execIdFor(runId)}:follow-up`;
24
31
  }
25
32
  /**
26
33
  * What the container is given instead of a credential.
27
34
  *
28
- * Claude Code does not validate it locally — proven in the Phase 0c spike, where
29
- * a run with this exact value succeeded and the proxy log confirmed the
30
- * container only ever sent the placeholder. The egress gateway swaps in the real
31
- * credential on the way out, so a `postinstall` script that dumps the
32
- * environment learns this and nothing else.
35
+ * Claude Code does not validate it locally: a run with this exact value
36
+ * succeeds, and the container only ever sends the placeholder. The egress
37
+ * gateway swaps in the real credential on the way out, so a `postinstall` script
38
+ * that dumps the environment learns this and nothing else.
33
39
  *
34
40
  * Shaped like a real token deliberately: something that looks obviously fake
35
41
  * invites a future reader to "fix" it by putting the real one there.
@@ -45,41 +51,96 @@ export const CREDENTIAL_PLACEHOLDER = "sk-ant-oat01-" + "0".repeat(24);
45
51
  * is refused rather than overridden.
46
52
  */
47
53
  const RESERVED_ENV_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
54
+ /**
55
+ * The workspace mount, `/workspace` in every image this plugin runs in.
56
+ * Whatever a session writes under it is synced into the workspace's Durable
57
+ * Object; whatever it writes anywhere else dies with the container.
58
+ */
59
+ const WORKSPACE_MOUNT = "/workspace";
60
+ /**
61
+ * Where every session keeps Claude Code's own state — its `CLAUDE_CONFIG_DIR`.
62
+ *
63
+ * The whole config directory moves, created if absent: the transcripts under
64
+ * `projects/<cwd-with-/-as-->/<session-id>.jsonl`, the session index,
65
+ * `.claude.json` and the auto-memory directory. None of it is a credential.
66
+ *
67
+ * This is what decides how long a session stays resumable. The CLI's own
68
+ * `$HOME/.claude` is on the container's disk, so a conversation there dies with
69
+ * the container — which a deploy, a rollout and the workspace's own idle timer
70
+ * all end. Under the workspace mount it is the Durable Object's storage, and the
71
+ * conversation outlives the container that held it; a later run can continue it
72
+ * from that same workspace object, and from no other.
73
+ *
74
+ * **Fixed, and never a host's to choose.** A transcript is found only where it
75
+ * was written, so every second place a session could write one is a resume that
76
+ * cannot find it. `env` cannot move it either: {@link buildLaunch} refuses the
77
+ * key.
78
+ *
79
+ * The directory is named {@link file://../computer/paths.ts SESSION_STATE_DIR},
80
+ * which is the name the file tools' walk steps over: a transcript records every
81
+ * line the session read, so a walk that descended into it would answer the
82
+ * parent's `grep` for a line of source with the session that quoted it. The
83
+ * name is written out here rather than imported, because `/claude-code` does
84
+ * not reach `/computer` — a spec holds the two to the same spelling.
85
+ */
86
+ export const SESSION_CONFIG_DIR = `${WORKSPACE_MOUNT}/.claude-sessions`;
87
+ /** The environment key {@link SESSION_CONFIG_DIR} travels as. */
88
+ const CONFIG_DIR_ENV_KEY = "CLAUDE_CONFIG_DIR";
89
+ /**
90
+ * Git's identity as an *environment*, not a `git config`.
91
+ *
92
+ * The session's cwd is one checkout, and what it commits in is not: a
93
+ * superproject's submodules, a scratch clone, anything it initialises are all
94
+ * separate repositories with separate configs, and `/repo` only ever configured
95
+ * the one it cloned. A config write would have to find each of them; the
96
+ * environment is inherited by every git the session starts, and it outranks all
97
+ * four config levels — so a checkout carrying a name from whenever it was
98
+ * created no longer decides who commits today.
99
+ *
100
+ * Both pairs, because they answer different questions: an amend or a rebase
101
+ * keeps the original author and stamps a fresh committer, and with only
102
+ * `GIT_AUTHOR_*` set that commit ends up half attributed.
103
+ */
104
+ function gitIdentityEnv(author) {
105
+ if (!author)
106
+ return {};
107
+ return {
108
+ GIT_AUTHOR_NAME: author.name,
109
+ GIT_AUTHOR_EMAIL: author.email,
110
+ GIT_COMMITTER_NAME: author.name,
111
+ GIT_COMMITTER_EMAIL: author.email
112
+ };
113
+ }
48
114
  /**
49
115
  * Build the command and environment for one session.
50
116
  *
51
117
  * `--output-format stream-json` with `--verbose`, because the stream is the only
52
- * way to report progress before the run ends and a run legitimately lasts longer
53
- * than any single chunk. `--verbose` is required: without it Claude Code emits
118
+ * way to report progress before the run ends, and a run lasts for minutes.
119
+ * `--verbose` is required: without it Claude Code emits
54
120
  * only the final result even in stream mode.
55
121
  *
56
- * Note what is **absent**. No `--bare`, no `--settings` override, no
57
- * `CLAUDE_CONFIG_DIR`: a cloned repository's `CLAUDE.md`, skills and hooks are
58
- * exactly the material that makes the agent good at that repository, and the
59
- * container is already an arbitrary-code-execution environment by design — the
60
- * install runs the repo's `postinstall`, the agent runs its test suite. Stripping
61
- * one door while the others stand open buys nothing and costs the agent its
62
- * context. (§4 of the design plan, cancelled 2026-08-21.)
122
+ * Note what is **absent**. No `--bare` and no `--settings` override: a cloned
123
+ * repository's `CLAUDE.md`, skills and hooks are exactly the material that makes
124
+ * the agent good at that repository, and the container is already an
125
+ * arbitrary-code-execution environment by design — the install runs the repo's
126
+ * `postinstall`, the agent runs its test suite. Stripping one door while the
127
+ * others stand open buys nothing and costs the agent its context.
128
+ * `CLAUDE_CONFIG_DIR` is not that kind of flag, and every session gets it: it
129
+ * moves where the *client* keeps its own state, touching nothing a repository
130
+ * ships — see {@link SESSION_CONFIG_DIR}.
63
131
  *
64
- * No `ANTHROPIC_BASE_URL` either, and that one is a genuine simplification over
65
- * the spike: `http-gateway` egress intercepts transparently, so the client talks
66
- * to the real hostname and the gateway sees it. Nothing has to be told to use a
67
- * proxy, which means nothing in the container can be told *not* to.
132
+ * No `ANTHROPIC_BASE_URL` either: `http-gateway` egress intercepts
133
+ * transparently, so the client talks to the real hostname and the gateway sees
134
+ * it. Nothing has to be told to use a proxy, which means nothing in the
135
+ * container can be told *not* to.
68
136
  *
69
137
  * ## `--permission-mode`, and the root guard behind it
70
138
  *
71
- * `-p` is headless. There is no terminal, so there is nobody to answer a
72
- * permission prompt — and Claude Code's headless path does not wait for one, it
73
- * **auto-denies**. Left unset the mode is `default`, and `default` gates Write,
74
- * Edit and every Bash command. A session in that state reads the repository
75
- * perfectly and cannot change one byte of it, while reporting prose that reads
76
- * like considered reluctance rather than a blocked tool. That cost this
77
- * deployment a day of "the container is fixed but nothing lands".
78
- *
79
- * So the mode is passed explicitly, and {@link DEFAULT_PERMISSION_MODE} is
80
- * `bypassPermissions` — see
81
- * {@link file://./config.ts ClaudeCodeConfig.permissionMode} for why the
82
- * permissive value is the correct default here rather than a concession.
139
+ * The mode is always passed explicitly, never left to the CLI's `default` — see
140
+ * {@link file://./config.ts ClaudeCodeConfig.permissionMode} for what an
141
+ * unset mode does to a headless session, and why
142
+ * {@link DEFAULT_PERMISSION_MODE} is the permissive value rather than a
143
+ * concession.
83
144
  *
84
145
  * **`IS_SANDBOX=1` ships in the same branch as the flag, and separating the two
85
146
  * breaks a session harder than passing no flag at all.** The container runs as
@@ -115,14 +176,59 @@ export function buildLaunch(options) {
115
176
  "hand it to every process in the container, including a cloned " +
116
177
  "repository's install scripts. Pass it as `credential` instead.");
117
178
  }
179
+ /**
180
+ * Refused for the same reason: a host that set it meant its sessions to keep
181
+ * their state somewhere else, and a transcript written anywhere but the one
182
+ * place is a conversation no later run will find.
183
+ */
184
+ if (options.env && CONFIG_DIR_ENV_KEY in options.env) {
185
+ throw new Error(`claude-code: ${CONFIG_DIR_ENV_KEY} cannot be set through \`env\`. Every ` +
186
+ `session keeps its state in ${SESSION_CONFIG_DIR}, which is where a ` +
187
+ "later run looks for the conversation it continues.");
188
+ }
189
+ /**
190
+ * Refused rather than dropped, both of them: dropped, a caller that asked to
191
+ * continue or fork a conversation gets a session that started one of its own,
192
+ * and reports it as the continuation of whatever it was told to continue. An
193
+ * empty id is the same state {@link file://./session.ts followUp} refuses,
194
+ * and refusing it here covers every caller of this option rather than that
195
+ * one path.
196
+ */
197
+ if (options.resume === "") {
198
+ throw new Error("claude-code: `resume` is the session id of the conversation to " +
199
+ "continue, so it cannot be empty. Launch without it to start one.");
200
+ }
201
+ if (options.fork && !options.resume) {
202
+ throw new Error("claude-code: `fork` forks a resumed conversation, so it needs the " +
203
+ "`resume` session id it is forking.");
204
+ }
205
+ /**
206
+ * Refused beside `github`: a host value would either hand the session a real
207
+ * token or point git's credential at something other than the placeholder.
208
+ */
209
+ const githubKey = options.github
210
+ ? GITHUB_ENV_KEYS.find((key) => options.env && key in options.env)
211
+ : undefined;
212
+ if (githubKey) {
213
+ throw new Error(`claude-code: ${githubKey} cannot be set through \`env\` when \`githubToken\` ` +
214
+ "is configured. The session presents a placeholder and the egress " +
215
+ "gateway swaps in the GitHub token on the way out.");
216
+ }
118
217
  const permissionMode = options.permissionMode ?? DEFAULT_PERMISSION_MODE;
119
218
  const argv = ["claude", "-p", shellQuote(options.prompt)];
219
+ if (options.resume)
220
+ argv.push("--resume", shellQuote(options.resume));
221
+ if (options.fork)
222
+ argv.push("--fork-session");
120
223
  argv.push("--output-format", "stream-json", "--verbose");
121
224
  argv.push("--permission-mode", permissionMode);
122
225
  if (options.model)
123
226
  argv.push("--model", shellQuote(options.model));
124
- if (options.maxTurns !== undefined)
125
- argv.push("--max-turns", String(options.maxTurns));
227
+ if (options.effort)
228
+ argv.push("--effort", options.effort);
229
+ if (options.jsonSchema) {
230
+ argv.push("--json-schema", shellQuote(JSON.stringify(options.jsonSchema)));
231
+ }
126
232
  const env = {
127
233
  // Pinned image; an autoupdate would move the wire shape the gateway and the
128
234
  // parser are both written against, mid-run and without a deploy.
@@ -138,16 +244,26 @@ export function buildLaunch(options) {
138
244
  env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS = String(options.maxConcurrentSubagents);
139
245
  return {
140
246
  command: argv.join(" "),
141
- // The last two are applied **after** the host's environment rather than
142
- // before it. For the placeholder that makes the guard above a second line
143
- // rather than the only one; for `IS_SANDBOX` it means a host cannot unset
247
+ // Everything below `options.env` is applied **after** the host's environment
248
+ // rather than before it. For the placeholder and the config directory that
249
+ // makes the guards above a second line rather than the only one; for
250
+ // `IS_SANDBOX` it means a host cannot unset
144
251
  // the variable its own permission mode depends on — see the note above.
252
+ //
253
+ // For the identity it is what keeps the four keys one answer. Spread before
254
+ // `env`, a host that set `GIT_AUTHOR_NAME` there and `author` here would
255
+ // get the author from one and the committer from the other — a commit
256
+ // attributed to two people, which is the exact failure `author` exists to
257
+ // end. Last, `author` is simply the answer whenever a host gives one.
145
258
  env: {
146
259
  ...env,
147
260
  ...options.env,
148
261
  ...(permissionMode === "bypassPermissions"
149
262
  ? { IS_SANDBOX: "1" }
150
263
  : undefined),
264
+ ...gitIdentityEnv(options.author),
265
+ ...(options.github ? githubEnv() : undefined),
266
+ [CONFIG_DIR_ENV_KEY]: SESSION_CONFIG_DIR,
151
267
  [RESERVED_ENV_KEY]: CREDENTIAL_PLACEHOLDER
152
268
  }
153
269
  };
@@ -156,22 +272,125 @@ export function buildLaunch(options) {
156
272
  export function freshCursor(execId) {
157
273
  return { execId, seq: 0, carry: "", emitted: 0 };
158
274
  }
159
- /** Distinguishes "the window ran out" from a real stream event in the race below. */
160
- const WINDOW_EXPIRED = Symbol("window-expired");
275
+ /**
276
+ * Whether a bucket reading says anything the last one did not.
277
+ *
278
+ * Every field, because each is separately actionable: the status decides whether
279
+ * a credential is worth using, `resetsAt` is when it recovers, the type says
280
+ * which bucket is talking, and the overage pair says whether spending past it is
281
+ * allowed and why not. Comparing only the status would hold a rollover — a new
282
+ * `resetsAt` under an unchanged `allowed` — out of the logs entirely.
283
+ *
284
+ * **Over the keys rather than a written-out list**, which is the difference
285
+ * between one bug and a class of them: a field added to
286
+ * {@link RateLimitInfo} and forgotten here does not make a smaller log, it makes
287
+ * a change to the reading that never reaches a log at all — the one failure this
288
+ * function exists to prevent, and a silent one. Shallow is exact because the
289
+ * reading is flat and every value on it is a primitive; a nested field would
290
+ * need this revisited, and the parser is where that would be decided.
291
+ */
292
+ function changedReading(previous, next) {
293
+ if (previous === undefined)
294
+ return true;
295
+ const before = { ...previous };
296
+ const after = { ...next };
297
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].some((key) => before[key] !== after[key]);
298
+ }
299
+ /** Distinguishes "the caller stopped the drain" from a stream event in the race below. */
300
+ const STOPPED = Symbol("stopped");
301
+ /**
302
+ * How rarely a drain offers a cursor to persist.
303
+ *
304
+ * A checkpoint is a Durable Object storage write, and the stream can carry many
305
+ * events a second while a session reads files — so this is a floor on the writes,
306
+ * not a schedule for them. Sized against what it is protecting: the loss it
307
+ * bounds is re-drained in seconds, so buying a smaller bound with a write per
308
+ * event would cost more than the bound is worth.
309
+ */
310
+ const CHECKPOINT_MIN_MS = 30_000;
161
311
  /** Whether a thrown value is the runtime refusing to reuse a live exec id. */
162
- function isExecBusy(err) {
312
+ export function isExecBusy(err) {
163
313
  return err?.code === "EEXEC_BUSY";
164
314
  }
315
+ /**
316
+ * Whether a thrown value means the session this cursor names is gone for good.
317
+ *
318
+ * `EEXEC_LOST` is the runtime saying the container that held the execution was
319
+ * replaced. The session died with it, and no attachment will ever find it — so
320
+ * unlike `EEXEC_BUSY`, which is a live session reached the wrong way, there is
321
+ * nothing to recover and retrying only discovers that again.
322
+ */
323
+ export function isExecLost(err) {
324
+ return err?.code === "EEXEC_LOST";
325
+ }
326
+ /**
327
+ * Whether a thrown value is the *previous* drain's attachment, not yet released.
328
+ *
329
+ * An exec's event stream admits one subscriber at a time. A drain resumed after
330
+ * an eviction, or a follow-up asked for the moment a session ends, can ask for
331
+ * the next one while the last is still being released — and the release on the
332
+ * far side is asynchronous and unobservable from here: the workspace client
333
+ * carries `Symbol.dispose`, not `Symbol.asyncDispose`, so there is nothing a
334
+ * caller can await to know the attachment is gone. {@link attachRun} answers by
335
+ * waiting rather than by failing.
336
+ *
337
+ * **Matched on the message, not on a code, and that is not laziness.** This one
338
+ * is raised inside the container image rather than by the runtime client: the
339
+ * string appears nowhere in the Worker bundle, and it arrives here as a plain
340
+ * `Error` with no `code` — unlike `EEXEC_BUSY` and `EEXEC_LOST`
341
+ * above, which the client throws and codes. The code is checked first anyway, so
342
+ * the day the container starts sending one this keeps working and the string
343
+ * test becomes the fallback it should have been.
344
+ */
345
+ function isExecSubscribed(err) {
346
+ const e = err;
347
+ if (e?.code === "EEXEC_SUBSCRIBED")
348
+ return true;
349
+ return (typeof e?.message === "string" &&
350
+ e.message.includes("already has a live subscriber"));
351
+ }
352
+ /**
353
+ * How long {@link attachRun} waits out a subscriber that has not been released.
354
+ *
355
+ * Bounded, because what it is waiting on may not be a teardown at all: an
356
+ * isolate that is genuinely wedged holding the stream releases it when it dies,
357
+ * and nothing here can tell that apart from one that is a moment from
358
+ * finishing. Exceeding this rethrows, so the turn fails and Think's recovery
359
+ * retries it on a fresh isolate — which is the right escalation, and the one
360
+ * this whole mechanism exists to stop being the *first* resort.
361
+ *
362
+ * Sized well above what the race costs in practice. The race normally clears
363
+ * on the first or second look, and a deployment where it took fifteen seconds is
364
+ * what this is sized to outlast.
365
+ */
366
+ const ATTACH_MAX_MS = 30_000;
367
+ /** The first gap between attach attempts, doubled up to {@link ATTACH_CAP_MS}. */
368
+ const ATTACH_BACKOFF_MS = 250;
369
+ /**
370
+ * The ceiling on that doubling.
371
+ *
372
+ * Without it the last gap before {@link ATTACH_MAX_MS} would be most of the
373
+ * budget, so a subscriber released early in it would still be waited out to the
374
+ * end. What this buys is that the wait ends soon after the release, rather than
375
+ * at the next power of two.
376
+ */
377
+ const ATTACH_CAP_MS = 2_000;
165
378
  /**
166
379
  * Start a session, detached.
167
380
  *
168
381
  * **Falls back to attaching when the id is already live**, which is not a
169
- * nicety. `start` spawns and then drains for minutes, so a chunk that fails
170
- * anywhere after the spawn is retried by the Workflow with no cursor to resume
171
- * from — and `@cloudflare/computer` refuses to reuse an id whose execution is
172
- * still running (`EEXEC_BUSY`). Without this the retry throws, every later retry
382
+ * nicety. `start` spawns and then drains for minutes, so a turn cut anywhere
383
+ * after the spawn is recovered with no cursor stored yet — and
384
+ * `@cloudflare/computer` refuses to reuse an id whose execution is still
385
+ * running (`EEXEC_BUSY`). Without this the recovery throws, every later one
173
386
  * throws the same way, and a perfectly healthy session in the container becomes
174
387
  * unreachable.
388
+ *
389
+ * **The sync stays on the default, and `defer` is not an option here.** A
390
+ * deferred exec skips the post-command pull entirely and settles the outcome
391
+ * from the cursor instead — and its `cancel` reads the stream to the end first,
392
+ * so stopping a drain would block until the session exits. Both halves are
393
+ * exactly what this drain is built not to do.
175
394
  */
176
395
  export async function startRun(runtime, options) {
177
396
  const { command, env } = buildLaunch(options);
@@ -188,51 +407,188 @@ export async function startRun(runtime, options) {
188
407
  if (!isExecBusy(err))
189
408
  throw err;
190
409
  console.info("[claude-code] a session is already running under this id — attaching", { execId: options.execId });
191
- return await attachRun(runtime, freshCursor(options.execId));
410
+ return await attachRun(runtime, freshCursor(options.execId), {
411
+ signal: options.signal
412
+ });
192
413
  }
193
414
  }
194
- /** Re-attach to a session this isolate did not start. */
195
- export async function attachRun(runtime, cursor) {
196
- return await runtime.getExec(cursor.execId, {
197
- encoding: "utf8",
198
- // `0` is a legal seq and also the beginning, so a fresh cursor resumes from
199
- // the start either way. Later chunks name their own place.
200
- resume: cursor.seq
201
- });
415
+ /**
416
+ * Re-attach to a session this isolate did not start.
417
+ *
418
+ * **Waits out a subscriber the previous drain has not released yet**, and that
419
+ * wait is the point of this function rather than a refinement of it. The
420
+ * alternative is not "fail fast" — it is spending a recovery to discover a
421
+ * condition that clears on its own in a second, when recoveries are what a
422
+ * deploy, a severed stub and a network drop have to be covered out of. A
423
+ * deployment lost a twenty-one-minute session exactly that way — consecutive
424
+ * attach races used the retries up, and the redeploy that followed had no
425
+ * attempt left to be retried on. See {@link isExecSubscribed}.
426
+ *
427
+ * Everything else is rethrown on the first look, `EEXEC_LOST` above all: it is
428
+ * the runtime saying the container was replaced, `resume` turns it into a report
429
+ * for the model, and a retry loop would sit on it for {@link ATTACH_MAX_MS}
430
+ * learning nothing.
431
+ */
432
+ export async function attachRun(runtime, cursor, options = {}) {
433
+ const now = options.now ?? Date.now;
434
+ const wait = options.wait ?? sleep;
435
+ const deadline = now() + ATTACH_MAX_MS;
436
+ let backoff = ATTACH_BACKOFF_MS;
437
+ for (;;) {
438
+ try {
439
+ return await runtime.getExec(cursor.execId, {
440
+ encoding: "utf8",
441
+ // `0` is a legal seq and also the beginning, so a fresh cursor resumes
442
+ // from the start either way. A stored cursor names its own place.
443
+ resume: cursor.seq
444
+ });
445
+ }
446
+ catch (err) {
447
+ // Rethrown rather than retried once the budget is gone, so the step still
448
+ // fails on a subscriber that is never coming back — just not first.
449
+ if (!isExecSubscribed(err) ||
450
+ now() >= deadline ||
451
+ options.signal?.aborted)
452
+ throw err;
453
+ // Clamped to what is left, so the last gap lands *on* the deadline rather
454
+ // than past it. Uncapped, a schedule whose final doubling straddles the
455
+ // bound would take one more look on the far side of it — and the bound
456
+ // would be a number in a comment rather than one the code keeps.
457
+ const waitMs = Math.min(backoff, deadline - now());
458
+ // One line per wait, not per attempt: this is the condition whose
459
+ // frequency is worth watching, and it went unnamed in the logs for as
460
+ // long as retries were absorbing it one at a time.
461
+ console.info("[claude-code] the previous drain is still attached — waiting", { execId: cursor.execId, waitMs });
462
+ await wait(waitMs);
463
+ backoff = Math.min(backoff * 2, ATTACH_CAP_MS);
464
+ }
465
+ }
202
466
  }
203
467
  /**
204
- * Drain a session for one bounded window.
468
+ * Drain a session to its end.
205
469
  *
206
- * Returns `done: false` when the window expired with the process still running —
207
- * the caller checkpoints the cursor and comes back — or `done: true` once the
208
- * stream has finished.
470
+ * Returns `done: true` once the stream has finished, or `done: false` when the
471
+ * caller's signal stopped the drain with the process still running.
209
472
  */
210
- export async function drainRun(handle, cursor, options) {
473
+ export async function drainRun(handle, cursor, options = {}) {
211
474
  const now = options.now ?? Date.now;
212
- const deadline = now() + options.windowMs;
213
475
  const reader = handle.getReader();
214
- const events = [];
476
+ /**
477
+ * The drain's notes, in order — built as the stream is parsed rather than
478
+ * from a list of events at the end, so there is one place a note is numbered
479
+ * and the sink below cannot disagree with what is returned.
480
+ */
481
+ const progress = [];
482
+ let emitted = cursor.emitted;
215
483
  let buffer = cursor.carry;
484
+ let thinkingFrom = cursor.thinkingFrom;
216
485
  let seq = cursor.seq;
217
486
  let result = cursor.result;
487
+ // This drain's reading only — see `DrainOutcome`. Starting undefined also
488
+ // means the first reading of each drain reaches the log, which is what keeps
489
+ // a resumed session's bucket visible without carrying a stale one forward.
490
+ let rateLimit;
218
491
  let stderr = cursor.stderr ?? "";
492
+ let sessionId = cursor.sessionId;
493
+ /**
494
+ * Set when this drain is the one that learned the session id, and cleared by
495
+ * the checkpoint it lets through. See {@link DrainCursor.sessionId}.
496
+ */
497
+ let newHandle = false;
219
498
  let exitCode;
499
+ let checkpointedAt = now();
500
+ /**
501
+ * The sink's calls, chained rather than awaited.
502
+ *
503
+ * Ordering matters — these are sentences in a conversation — and awaiting
504
+ * one inside the read loop would stall the drain for as long as the session
505
+ * keeps talking. A rejected note does not stop the drain — a note is
506
+ * best-effort — but it is logged, and it ends the checkpoints: see
507
+ * `DrainOptions.onCheckpoint`. Settled before the drain returns or throws, so
508
+ * nothing is cut short when it unwinds, and nothing lands behind a drain that
509
+ * re-attaches after it.
510
+ */
511
+ let sunk = Promise.resolve();
512
+ /** Set once a note is rejected — see `DrainOptions.onCheckpoint`. */
513
+ let rejected = false;
514
+ /**
515
+ * The last position every note behind which reached the sink: where this
516
+ * drain began, then each checkpoint the chain reaches unrejected.
517
+ */
518
+ let safe = cursor;
220
519
  /**
221
520
  * Parse whatever complete lines the buffer now holds.
222
521
  *
223
522
  * Called after **every** stdout event rather than once at the end. Claude
224
- * Code's stream carries whole tool results, so a chunk that only parsed on the
225
- * way out would hold an eight-minute transcript of a noisy build in a Durable
226
- * Object's memory; parsing eagerly keeps only `carry`, which is at most one
227
- * incomplete line.
523
+ * Code's stream carries whole tool results, so a drain that only parsed on
524
+ * the way out would hold a whole session's transcript of a noisy build in a
525
+ * Durable Object's memory; parsing eagerly keeps only `carry`, which is at
526
+ * most one incomplete line.
228
527
  */
229
528
  const absorb = () => {
230
- const parsed = parseStream(buffer);
529
+ const parsed = parseStream(buffer, {
530
+ now: now(),
531
+ ...(thinkingFrom === undefined ? {} : { thinkingFrom })
532
+ });
231
533
  buffer = parsed.carry;
534
+ thinkingFrom = parsed.thinkingFrom;
232
535
  for (const event of parsed.events) {
233
- events.push(event);
234
536
  if (event.kind === "result")
235
537
  result = event.result;
538
+ // The first one only, and it is the id of whatever this drain is actually
539
+ // draining: a resumed conversation's own, or the new one a fork starts.
540
+ if (event.kind === "init" && !sessionId) {
541
+ sessionId = event.sessionId;
542
+ newHandle = true;
543
+ }
544
+ if (event.kind === "rateLimit") {
545
+ /**
546
+ * Logged when the reading **changes**, not per line — and a change is
547
+ * any field of it, not just the status: a bucket rolling over moves
548
+ * `resetsAt` while saying `allowed` throughout, and the new reset is
549
+ * the useful half of that line.
550
+ *
551
+ * The client repeats itself for as long as a session runs, so a log per
552
+ * event would be the noisiest thing this package writes and every copy
553
+ * after the first would say the same thing. A change is the whole signal:
554
+ * the first reading names the bucket and when it refills, and the only
555
+ * other one that can happen is the bucket saying no — which is the line
556
+ * nobody has captured yet, and the one the pool needs before it can be
557
+ * trusted to act on a status rather than on a refused request.
558
+ */
559
+ if (changedReading(rateLimit, event.info)) {
560
+ const at = { execId: cursor.execId, ...event.info };
561
+ // An unrecognised status is **not** reported as a refusal. Nothing
562
+ // here knows what one means, and a log that calls it exhaustion is
563
+ // how a healthy credential gets retired by the next person to read
564
+ // it. See `RATE_LIMIT_OK`.
565
+ if (event.info.status === RATE_LIMIT_OK)
566
+ console.info("[claude-code] subscription bucket", at);
567
+ else
568
+ console.warn("[claude-code] unrecognised subscription status", at);
569
+ }
570
+ rateLimit = event.info;
571
+ }
572
+ }
573
+ // Numbered from the running total, so a note has the same key whether it is
574
+ // filed from here or returned on the outcome — and the same key again when
575
+ // a resumed drain replays this part of the stream, which is what makes the
576
+ // duplicate harmless. See `toProgress`.
577
+ for (const note of toProgress(parsed.events, emitted)) {
578
+ progress.push(note);
579
+ emitted++;
580
+ const sink = options.onProgress;
581
+ if (sink)
582
+ sunk = sunk
583
+ .then(() => sink(note))
584
+ .catch((err) => {
585
+ rejected = true;
586
+ console.warn("[claude-code] a note did not reach the parent", {
587
+ execId: cursor.execId,
588
+ key: note.key,
589
+ err: String(err)
590
+ });
591
+ });
236
592
  }
237
593
  if (parsed.skipped > 0) {
238
594
  // Not fatal, and deliberately not silent: a systematic schema change
@@ -251,29 +607,92 @@ export async function drainRun(handle, cursor, options) {
251
607
  });
252
608
  }
253
609
  };
254
- const finish = (code) => {
255
- const progress = toProgress(events, cursor.emitted);
256
- const next = {
257
- execId: cursor.execId,
258
- seq,
259
- carry: buffer,
260
- emitted: cursor.emitted + progress.length,
261
- ...(result ? { result } : {}),
262
- ...(stderr ? { stderr } : {})
263
- };
610
+ /**
611
+ * Where the drain has got to, as something safe to persist.
612
+ *
613
+ * One object, so `seq`, `carry` and `emitted` are written together — a cursor
614
+ * that advanced its position without its note count would renumber every key
615
+ * after it, and a replay would then post the whole tail again under keys the
616
+ * transcript has never seen.
617
+ */
618
+ const checkpoint = () => ({
619
+ execId: cursor.execId,
620
+ seq,
621
+ carry: buffer,
622
+ emitted,
623
+ ...(thinkingFrom === undefined ? {} : { thinkingFrom }),
624
+ ...(sessionId ? { sessionId } : {}),
625
+ ...(result ? { result } : {}),
626
+ ...(stderr ? { stderr } : {})
627
+ });
628
+ /**
629
+ * Offer the caller a cursor to store, at most every
630
+ * {@link CHECKPOINT_MIN_MS} — and once more the moment the session id appears.
631
+ *
632
+ * Fire-and-forget onto the same chain the notes ride, so a storage write
633
+ * cannot stall the read loop and cannot land before the notes it claims are
634
+ * already posted.
635
+ *
636
+ * **The id does not wait for the interval.** It is the handle to everything
637
+ * that follows — the one thing that lets a later run continue this
638
+ * conversation — and a session killed inside the first interval would
639
+ * otherwise leave none. One extra write per session buys that.
640
+ */
641
+ const offerCheckpoint = () => {
642
+ const sink = options.onCheckpoint;
643
+ // **Refused without `onProgress`, not merely discouraged.** A checkpoint is
644
+ // only safe because the notes behind it have been filed; offered to a
645
+ // caller that files nothing, it advances a cursor past notes that were never
646
+ // delivered, and a drain dying after it loses them permanently. The
647
+ // documented unsafe combination is one nothing can reach.
648
+ if (!sink || !options.onProgress || rejected)
649
+ return;
650
+ if (newHandle)
651
+ newHandle = false;
652
+ else if (now() - checkpointedAt < CHECKPOINT_MIN_MS)
653
+ return;
654
+ checkpointedAt = now();
655
+ const at = checkpoint();
656
+ // Checked again on the chain: a note queued ahead of this one may yet be
657
+ // rejected, and this cursor names it.
658
+ sunk = sunk
659
+ .then(() => {
660
+ if (rejected)
661
+ return;
662
+ safe = at;
663
+ return sink(at);
664
+ })
665
+ .catch(() => { });
666
+ };
667
+ const finish = async (code) => {
668
+ // Everything the sink was given has been delivered before the outcome
669
+ // naming it is returned, so a caller cannot store a cursor that claims
670
+ // notes nobody filed. After a rejected note it is the last cursor behind
671
+ // it: the caller stores it, and a drain resumed from there files the note
672
+ // again, while the notes after it replay under keys the transcript dedupes.
673
+ await sunk;
674
+ const next = rejected ? safe : checkpoint();
264
675
  return code === undefined
265
- ? { done: false, cursor: next, progress }
676
+ ? {
677
+ done: false,
678
+ cursor: next,
679
+ progress,
680
+ ...(rateLimit ? { rateLimit } : {})
681
+ }
266
682
  : {
267
683
  done: true,
268
684
  cursor: next,
269
685
  progress,
686
+ ...(rateLimit ? { rateLimit } : {}),
270
687
  exitCode: code,
271
688
  ...(result ? { result } : {}),
272
689
  // Only when there is no result. A session that reported for itself has
273
690
  // said everything worth saying, and its stderr is the CLI's own
274
691
  // diagnostic chatter — surfacing that beside a perfectly good report
275
692
  // would bury the report.
276
- ...(!result && stderr ? { stderr } : {})
693
+ ...(!result && (SIGNALS[code] || stderr)
694
+ ? { stderr: explainExit(code, stderr) }
695
+ : {})
277
696
  };
278
697
  };
279
698
  try {
@@ -290,34 +709,50 @@ export async function drainRun(handle, cursor, options) {
290
709
  * simply not there.
291
710
  *
292
711
  * Reading on is also how the sync gets *awaited*: the wrapper resolves its
293
- * pull before it closes the stream, so observing `done` means the sync has
294
- * already finished.
712
+ * pull before it closes the stream, so observing `done` means the pull was
713
+ * attempted. Attempted, not guaranteed — a pull that fails leaves the
714
+ * stream closing normally and reports itself on the result the host does
715
+ * not see here, and nothing retries it on its own. The host drives any
716
+ * outstanding pull; see this plugin's README.
717
+ *
718
+ * It is also unbounded, deliberately: the pull carries whatever the
719
+ * session wrote, an install's dependency tree included. Cutting it short
720
+ * would trade a late report for edits that never land, and that trade is
721
+ * not close: a drain cut with the pull still in flight is resumed, and
722
+ * the sync resumes from the blocks it has already committed. A pull that
723
+ * *fails* is the case above, and nothing retries that.
295
724
  */
296
725
  if (exitCode !== undefined) {
297
726
  const next = await reader.read();
298
727
  if (next.done)
299
- return finish(exitCode);
728
+ return await finish(exitCode);
300
729
  consume(next.value);
301
730
  continue;
302
731
  }
303
- const remaining = deadline - now();
304
- if (remaining <= 0)
305
- return await yieldWindow();
732
+ if (options.signal?.aborted)
733
+ return await stop();
734
+ const stopping = stopped(options.signal);
306
735
  const next = await Promise.race([
307
736
  reader.read(),
308
- sleep(remaining).then(() => WINDOW_EXPIRED)
309
- ]);
310
- if (next === WINDOW_EXPIRED)
311
- return await yieldWindow();
737
+ stopping.promise
738
+ ]).finally(stopping.clear);
739
+ if (next === STOPPED)
740
+ return await stop();
312
741
  if (next.done) {
313
742
  // The stream ended without an `exit` event — the container went away
314
743
  // under the run. Report it as a failure rather than as still-running,
315
- // or the caller waits out its whole chunk budget on a dead process.
316
- return finish(-1);
744
+ // or the caller would wait on a dead process.
745
+ stderr = containerLost(stderr);
746
+ return await finish(-1);
317
747
  }
318
748
  consume(next.value);
319
749
  }
320
750
  }
751
+ catch (err) {
752
+ // See `sunk`. It never rejects: each link catches its own.
753
+ await sunk;
754
+ throw err;
755
+ }
321
756
  finally {
322
757
  reader.releaseLock();
323
758
  }
@@ -326,6 +761,9 @@ export async function drainRun(handle, cursor, options) {
326
761
  if (event.name === "stdout" && event.value !== undefined) {
327
762
  buffer += event.value;
328
763
  absorb();
764
+ // After absorbing, never before: the position offered has to be one whose
765
+ // notes are already on the sink's queue.
766
+ offerCheckpoint();
329
767
  }
330
768
  // stderr is Claude Code's own diagnostics, not the protocol stream. Kept out
331
769
  // of the parser so a warning line cannot be mistaken for an event — but
@@ -338,25 +776,93 @@ export async function drainRun(handle, cursor, options) {
338
776
  exitCode = event.code ?? -1;
339
777
  }
340
778
  /**
341
- * End the window with the session still running.
779
+ * End the drain with the session still running.
342
780
  *
343
781
  * **Cancels rather than merely releasing the lock.** Releasing leaves the
344
- * attachment and its pending read alive on the far side, and a chunked run
345
- * would strand one per window. `cancel` is the wrapper's own designed exit —
346
- * it settles the pending read, resolves the sync outcome as `pending`, and
347
- * lets the next chunk's `getExec` open a fresh attachment that will run the
348
- * real sync when the session finally ends.
782
+ * attachment and its pending read alive on the far side, and the next drain
783
+ * would find it still subscribed. `cancel` is the wrapper's own designed exit
784
+ * — it settles the pending read, resolves the sync outcome as `pending`, and
785
+ * lets the next `getExec` open a fresh attachment that will run the real sync
786
+ * when the session finally ends.
787
+ *
788
+ * **Cancels before settling the sinks, not after**, so the attachment is not
789
+ * held open across notes still being filed. Safe in this order because
790
+ * `finish` reads only what `consume` already put in local state, and takes
791
+ * nothing off the stream.
349
792
  */
350
- async function yieldWindow() {
793
+ async function stop() {
351
794
  absorb();
352
- const outcome = finish();
353
- await reader.cancel("chunk window expired").catch(() => { });
354
- return outcome;
795
+ await reader.cancel("drain stopped").catch(() => { });
796
+ return await finish();
355
797
  }
356
798
  }
799
+ /**
800
+ * The signal behind each exit code `computerd` maps one to — the ones a process
801
+ * is stopped from outside with.
802
+ */
803
+ const SIGNALS = { 143: "SIGTERM", 137: "SIGKILL" };
804
+ /** A resultless session's `stderr`, explained when a signal ended it. */
805
+ function explainExit(code, stderr) {
806
+ const signal = SIGNALS[code];
807
+ if (!signal)
808
+ return stderr;
809
+ return endedFromOutside(`the process was stopped by ${signal} before it reported a result — its ` +
810
+ "container stopping, its time limit or a cancel does this.", stderr);
811
+ }
812
+ /**
813
+ * What a session reports on `stderr` when its container went away under it:
814
+ * the stream ended with no `exit` event, or a re-attach found the exec gone.
815
+ */
816
+ export function containerLost(printed = "") {
817
+ return endedFromOutside("the container holding this session was stopped or replaced before the " +
818
+ "session finished — a deploy or a container rollout does this — so the " +
819
+ "process was ended from outside and reported no result.", printed);
820
+ }
821
+ /**
822
+ * The report for a process that did not end on its own: why, what that leaves,
823
+ * then what it had printed.
824
+ *
825
+ * **The cause comes first.** What the process printed is not why it ended, and
826
+ * without this it is all a report shows — so whatever came last, a startup
827
+ * warning included, reads as the cause.
828
+ *
829
+ * **What it must not say is that starting over is safe.** A session is an
830
+ * agent: by the time it was stopped, it may have written files that reached the
831
+ * workspace, committed, pushed, or called something outside altogether. None of
832
+ * that is visible from here — only that the process is gone — so the honest
833
+ * report is what is known plus where to look, and the decision belongs to
834
+ * whoever can read the durable state.
835
+ */
836
+ function endedFromOutside(cause, printed) {
837
+ const said = printed.trim();
838
+ return (`${cause} What it had already written to the workspace is still there, ` +
839
+ "and anything it did outside the workspace — a commit, a push, a request — " +
840
+ "has already happened. Check the workspace and the branch before starting " +
841
+ "this work again, since a rerun repeats from the beginning." +
842
+ (said
843
+ ? `\n\nWhat it had printed by then, which is not why it ended:\n${said}`
844
+ : ""));
845
+ }
357
846
  function sleep(ms) {
358
847
  return new Promise((resolve) => setTimeout(resolve, ms));
359
848
  }
849
+ /**
850
+ * The caller's signal, as a race a read can win.
851
+ *
852
+ * Cleared by the caller whichever side wins: one listener is added per read,
853
+ * and a session is many reads.
854
+ */
855
+ function stopped(signal) {
856
+ let onAbort = () => { };
857
+ const promise = new Promise((resolve) => {
858
+ onAbort = () => resolve(STOPPED);
859
+ signal?.addEventListener("abort", onAbort, { once: true });
860
+ });
861
+ return {
862
+ promise,
863
+ clear: () => signal?.removeEventListener("abort", onAbort)
864
+ };
865
+ }
360
866
  /** How much stderr is worth carrying, in characters. */
361
867
  const STDERR_MAX = 2_000;
362
868
  /**
@@ -372,9 +878,9 @@ const STDERR_MAX = 2_000;
372
878
  * gives up prints one line and exits, making that line the last thing there is.
373
879
  * Keeping both ends costs a few hundred characters and removes the guess.
374
880
  *
375
- * Not a re-implementation of `/computer`'s `truncateOutput`: reaching across the
376
- * subpath boundary for it would merge two realms `verify:exports` keeps apart,
377
- * which is the same reason `/repo` carries its own.
881
+ * Not `truncateOutput`, and the difference is the marker: this runs on every
882
+ * append, so the cut has to stay a single character rather than a sentence that
883
+ * would be re-cut and nested on the next one.
378
884
  */
379
885
  function boundStderr(text) {
380
886
  if (text.length <= STDERR_MAX)