@dynamicagents/plugins 0.12.0 → 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 (130) hide show
  1. package/README.md +58 -77
  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 +28 -63
  7. package/dist/claude-code/config.js +0 -11
  8. package/dist/claude-code/egress.d.ts +8 -1
  9. package/dist/claude-code/egress.js +42 -4
  10. package/dist/claude-code/events.d.ts +106 -9
  11. package/dist/claude-code/events.js +201 -56
  12. package/dist/claude-code/github.d.ts +39 -0
  13. package/dist/claude-code/github.js +82 -0
  14. package/dist/claude-code/index.d.ts +16 -90
  15. package/dist/claude-code/index.js +14 -276
  16. package/dist/claude-code/model.d.ts +152 -0
  17. package/dist/claude-code/model.js +342 -0
  18. package/dist/claude-code/run.d.ts +209 -112
  19. package/dist/claude-code/run.js +339 -138
  20. package/dist/claude-code/session.d.ts +131 -0
  21. package/dist/claude-code/session.js +285 -0
  22. package/dist/claude-code/spec.d.ts +46 -0
  23. package/dist/claude-code/spec.js +83 -0
  24. package/dist/claude-code/verified.d.ts +15 -0
  25. package/dist/claude-code/verified.js +15 -0
  26. package/dist/computer/context.d.ts +1 -5
  27. package/dist/computer/context.js +7 -10
  28. package/dist/computer/file-lock.js +5 -4
  29. package/dist/computer/gate.d.ts +4 -4
  30. package/dist/computer/gate.js +5 -5
  31. package/dist/computer/index.d.ts +40 -258
  32. package/dist/computer/index.js +71 -324
  33. package/dist/computer/paths.d.ts +36 -16
  34. package/dist/computer/paths.js +42 -29
  35. package/dist/computer/proxy.d.ts +45 -0
  36. package/dist/computer/proxy.js +234 -0
  37. package/dist/computer/render.d.ts +1 -40
  38. package/dist/computer/render.js +2 -68
  39. package/dist/computer/tools-exec.js +12 -13
  40. package/dist/computer/tools-file.d.ts +9 -1
  41. package/dist/computer/tools-file.js +22 -82
  42. package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
  43. package/dist/computer/tools-grep.js +129 -0
  44. package/dist/repo/checkout.d.ts +4 -5
  45. package/dist/repo/checkout.js +31 -8
  46. package/dist/repo/context.d.ts +35 -9
  47. package/dist/repo/context.js +101 -49
  48. package/dist/repo/index.d.ts +73 -28
  49. package/dist/repo/index.js +112 -96
  50. package/dist/repo/tools-checks.d.ts +3 -0
  51. package/dist/repo/tools-checks.js +112 -0
  52. package/dist/repo/tools-clone.js +1 -1
  53. package/dist/repo/tools-pr.d.ts +14 -0
  54. package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
  55. package/dist/repo/tools-review.d.ts +9 -0
  56. package/dist/repo/tools-review.js +67 -47
  57. package/dist/repo/tools-worktree.js +144 -60
  58. package/dist/repo/tools-worktrees.d.ts +12 -0
  59. package/dist/repo/tools-worktrees.js +48 -0
  60. package/dist/repo/url.d.ts +2 -2
  61. package/dist/repo/url.js +7 -7
  62. package/dist/scratch/index.d.ts +4 -4
  63. package/dist/scratch/index.js +12 -11
  64. package/dist/{computer → workspace}/advisory.d.ts +4 -4
  65. package/dist/{computer → workspace}/advisory.js +2 -2
  66. package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
  67. package/dist/{computer/host → workspace}/ca-trust.js +12 -3
  68. package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
  69. package/dist/{computer/host → workspace}/container-fault.js +23 -8
  70. package/dist/workspace/exec.d.ts +94 -0
  71. package/dist/workspace/exec.js +153 -0
  72. package/dist/workspace/format.d.ts +36 -0
  73. package/dist/workspace/format.js +57 -0
  74. package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
  75. package/dist/{computer/host → workspace}/git-host.js +72 -2
  76. package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
  77. package/dist/{computer/host → workspace}/git-identity.js +1 -1
  78. package/dist/workspace/index.d.ts +43 -47
  79. package/dist/workspace/index.js +52 -112
  80. package/dist/{computer/host → workspace}/install-job.d.ts +4 -4
  81. package/dist/{computer/host → workspace}/install-job.js +276 -176
  82. package/dist/{computer → workspace}/install.d.ts +8 -8
  83. package/dist/{computer → workspace}/install.js +3 -3
  84. package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +105 -27
  85. package/dist/{computer/host/workspace.js → workspace/object.js} +217 -48
  86. package/dist/workspace/open.d.ts +95 -0
  87. package/dist/workspace/open.js +63 -0
  88. package/dist/workspace/read.d.ts +16 -0
  89. package/dist/workspace/read.js +22 -0
  90. package/dist/{computer → workspace}/shell.d.ts +5 -5
  91. package/dist/{computer → workspace}/shell.js +6 -6
  92. package/dist/{computer/host → workspace}/wake.d.ts +1 -1
  93. package/package.json +16 -36
  94. package/dist/arc-agi/analysis.d.ts +0 -322
  95. package/dist/arc-agi/analysis.js +0 -802
  96. package/dist/arc-agi/client.d.ts +0 -82
  97. package/dist/arc-agi/client.js +0 -113
  98. package/dist/arc-agi/game-tools.d.ts +0 -24
  99. package/dist/arc-agi/game-tools.js +0 -26
  100. package/dist/arc-agi/index.d.ts +0 -40
  101. package/dist/arc-agi/index.js +0 -175
  102. package/dist/arc-agi/main-agent.d.ts +0 -24
  103. package/dist/arc-agi/main-agent.js +0 -62
  104. package/dist/arc-agi/recipe.d.ts +0 -48
  105. package/dist/arc-agi/recipe.js +0 -97
  106. package/dist/arc-agi/scorecard.d.ts +0 -118
  107. package/dist/arc-agi/scorecard.js +0 -163
  108. package/dist/arc-agi/soul.d.ts +0 -29
  109. package/dist/arc-agi/soul.js +0 -79
  110. package/dist/arc-agi/store.d.ts +0 -153
  111. package/dist/arc-agi/store.js +0 -215
  112. package/dist/arc-agi/tools.d.ts +0 -36
  113. package/dist/arc-agi/tools.js +0 -574
  114. package/dist/arc-agi/types.d.ts +0 -211
  115. package/dist/arc-agi/types.js +0 -19
  116. package/dist/claude-code/recipe.d.ts +0 -53
  117. package/dist/claude-code/recipe.js +0 -100
  118. package/dist/computer/read.d.ts +0 -133
  119. package/dist/computer/read.js +0 -200
  120. package/dist/computer/tools-find.js +0 -216
  121. package/dist/recall/index.d.ts +0 -105
  122. package/dist/recall/index.js +0 -181
  123. package/dist/repo/tools-forge.d.ts +0 -4
  124. package/dist/triage/index.d.ts +0 -360
  125. package/dist/triage/index.js +0 -311
  126. /package/dist/{computer/host → workspace}/container-deps.d.ts +0 -0
  127. /package/dist/{computer/host → workspace}/container-deps.js +0 -0
  128. /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
  129. /package/dist/{computer/host → workspace}/sync.js +0 -0
  130. /package/dist/{computer/host → workspace}/wake.js +0 -0
@@ -1,4 +1,5 @@
1
1
  import { shellQuote } from "@cloudflare/computer";
2
+ import { GITHUB_ENV_KEYS, githubEnv } from "./github.js";
2
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. */
@@ -6,21 +7,27 @@ 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.
@@ -44,6 +51,41 @@ export const CREDENTIAL_PLACEHOLDER = "sk-ant-oat01-" + "0".repeat(24);
44
51
  * is refused rather than overridden.
45
52
  */
46
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";
47
89
  /**
48
90
  * Git's identity as an *environment*, not a `git config`.
49
91
  *
@@ -73,17 +115,19 @@ function gitIdentityEnv(author) {
73
115
  * Build the command and environment for one session.
74
116
  *
75
117
  * `--output-format stream-json` with `--verbose`, because the stream is the only
76
- * way to report progress before the run ends and a run legitimately lasts longer
77
- * 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
78
120
  * only the final result even in stream mode.
79
121
  *
80
- * Note what is **absent**. No `--bare`, no `--settings` override, no
81
- * `CLAUDE_CONFIG_DIR`: a cloned repository's `CLAUDE.md`, skills and hooks are
82
- * exactly the material that makes the agent good at that repository, and the
83
- * container is already an arbitrary-code-execution environment by design — the
84
- * install runs the repo's `postinstall`, the agent runs its test suite. Stripping
85
- * one door while the others stand open buys nothing and costs the agent its
86
- * context.
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}.
87
131
  *
88
132
  * No `ANTHROPIC_BASE_URL` either: `http-gateway` egress intercepts
89
133
  * transparently, so the client talks to the real hostname and the gateway sees
@@ -132,14 +176,59 @@ export function buildLaunch(options) {
132
176
  "hand it to every process in the container, including a cloned " +
133
177
  "repository's install scripts. Pass it as `credential` instead.");
134
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
+ }
135
217
  const permissionMode = options.permissionMode ?? DEFAULT_PERMISSION_MODE;
136
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");
137
223
  argv.push("--output-format", "stream-json", "--verbose");
138
224
  argv.push("--permission-mode", permissionMode);
139
225
  if (options.model)
140
226
  argv.push("--model", shellQuote(options.model));
141
227
  if (options.effort)
142
228
  argv.push("--effort", options.effort);
229
+ if (options.jsonSchema) {
230
+ argv.push("--json-schema", shellQuote(JSON.stringify(options.jsonSchema)));
231
+ }
143
232
  const env = {
144
233
  // Pinned image; an autoupdate would move the wire shape the gateway and the
145
234
  // parser are both written against, mid-run and without a deploy.
@@ -155,9 +244,10 @@ export function buildLaunch(options) {
155
244
  env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS = String(options.maxConcurrentSubagents);
156
245
  return {
157
246
  command: argv.join(" "),
158
- // The last three are applied **after** the host's environment rather than
159
- // before it. For the placeholder that makes the guard above a second line
160
- // 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
161
251
  // the variable its own permission mode depends on — see the note above.
162
252
  //
163
253
  // For the identity it is what keeps the four keys one answer. Spread before
@@ -172,6 +262,8 @@ export function buildLaunch(options) {
172
262
  ? { IS_SANDBOX: "1" }
173
263
  : undefined),
174
264
  ...gitIdentityEnv(options.author),
265
+ ...(options.github ? githubEnv() : undefined),
266
+ [CONFIG_DIR_ENV_KEY]: SESSION_CONFIG_DIR,
175
267
  [RESERVED_ENV_KEY]: CREDENTIAL_PLACEHOLDER
176
268
  }
177
269
  };
@@ -204,8 +296,8 @@ function changedReading(previous, next) {
204
296
  const after = { ...next };
205
297
  return [...new Set([...Object.keys(before), ...Object.keys(after)])].some((key) => before[key] !== after[key]);
206
298
  }
207
- /** Distinguishes "the window ran out" from a real stream event in the race below. */
208
- const WINDOW_EXPIRED = Symbol("window-expired");
299
+ /** Distinguishes "the caller stopped the drain" from a stream event in the race below. */
300
+ const STOPPED = Symbol("stopped");
209
301
  /**
210
302
  * How rarely a drain offers a cursor to persist.
211
303
  *
@@ -217,7 +309,7 @@ const WINDOW_EXPIRED = Symbol("window-expired");
217
309
  */
218
310
  const CHECKPOINT_MIN_MS = 30_000;
219
311
  /** Whether a thrown value is the runtime refusing to reuse a live exec id. */
220
- function isExecBusy(err) {
312
+ export function isExecBusy(err) {
221
313
  return err?.code === "EEXEC_BUSY";
222
314
  }
223
315
  /**
@@ -226,26 +318,26 @@ function isExecBusy(err) {
226
318
  * `EEXEC_LOST` is the runtime saying the container that held the execution was
227
319
  * replaced. The session died with it, and no attachment will ever find it — so
228
320
  * unlike `EEXEC_BUSY`, which is a live session reached the wrong way, there is
229
- * nothing to recover and retrying only spends chunks discovering that again.
321
+ * nothing to recover and retrying only discovers that again.
230
322
  */
231
323
  export function isExecLost(err) {
232
324
  return err?.code === "EEXEC_LOST";
233
325
  }
234
326
  /**
235
- * Whether a thrown value is the *previous* window's attachment, not yet released.
236
- *
237
- * An exec's event stream admits one subscriber at a time. A chunk boundary asks
238
- * for the next one within milliseconds of the last one returning, and the
239
- * release on the far side is asynchronous and unobservable from here — the
240
- * workspace client carries `Symbol.dispose`, not `Symbol.asyncDispose`, so
241
- * there is nothing a caller can await to know the attachment is gone. So the
242
- * boundary races the teardown every time, and {@link attachRun} answers by
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
243
335
  * waiting rather than by failing.
244
336
  *
245
337
  * **Matched on the message, not on a code, and that is not laziness.** This one
246
338
  * is raised inside the container image rather than by the runtime client: the
247
- * string appears nowhere in the Worker bundle, and it arrives at a Workflow step
248
- * as a plain `Error` with no `code` — unlike `EEXEC_BUSY` and `EEXEC_LOST`
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`
249
341
  * above, which the client throws and codes. The code is checked first anyway, so
250
342
  * the day the container starts sending one this keeps working and the string
251
343
  * test becomes the fallback it should have been.
@@ -263,11 +355,11 @@ function isExecSubscribed(err) {
263
355
  * Bounded, because what it is waiting on may not be a teardown at all: an
264
356
  * isolate that is genuinely wedged holding the stream releases it when it dies,
265
357
  * and nothing here can tell that apart from one that is a moment from
266
- * finishing. Exceeding this rethrows, so the Workflow step retries with a fresh
267
- * isolate — which is the right escalation, and the one this whole mechanism
268
- * exists to stop being the *first* resort.
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.
269
361
  *
270
- * Sized well above what the race costs in practice. The boundary normally clears
362
+ * Sized well above what the race costs in practice. The race normally clears
271
363
  * on the first or second look, and a deployment where it took fifteen seconds is
272
364
  * what this is sized to outlast.
273
365
  */
@@ -287,18 +379,18 @@ const ATTACH_CAP_MS = 2_000;
287
379
  * Start a session, detached.
288
380
  *
289
381
  * **Falls back to attaching when the id is already live**, which is not a
290
- * nicety. `start` spawns and then drains for minutes, so a chunk that fails
291
- * anywhere after the spawn is retried by the Workflow with no cursor to resume
292
- * from — and `@cloudflare/computer` refuses to reuse an id whose execution is
293
- * 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
294
386
  * throws the same way, and a perfectly healthy session in the container becomes
295
387
  * unreachable.
296
388
  *
297
389
  * **The sync stays on the default, and `defer` is not an option here.** A
298
390
  * deferred exec skips the post-command pull entirely and settles the outcome
299
391
  * from the cursor instead — and its `cancel` reads the stream to the end first,
300
- * so ending a window would block until the session exits rather than yielding
301
- * the chunk. Both halves are exactly what this drain is built not to do.
392
+ * so stopping a drain would block until the session exits. Both halves are
393
+ * exactly what this drain is built not to do.
302
394
  */
303
395
  export async function startRun(runtime, options) {
304
396
  const { command, env } = buildLaunch(options);
@@ -315,21 +407,22 @@ export async function startRun(runtime, options) {
315
407
  if (!isExecBusy(err))
316
408
  throw err;
317
409
  console.info("[claude-code] a session is already running under this id — attaching", { execId: options.execId });
318
- return await attachRun(runtime, freshCursor(options.execId));
410
+ return await attachRun(runtime, freshCursor(options.execId), {
411
+ signal: options.signal
412
+ });
319
413
  }
320
414
  }
321
415
  /**
322
416
  * Re-attach to a session this isolate did not start.
323
417
  *
324
- * **Waits out a subscriber the previous window has not released yet**, and that
418
+ * **Waits out a subscriber the previous drain has not released yet**, and that
325
419
  * wait is the point of this function rather than a refinement of it. The
326
- * alternative is not "fail fast" — it is spending a Workflow retry to discover a
327
- * condition that clears on its own in a second, when a chunk step's retries are
328
- * what a deploy, a severed stub and a network drop have to be covered out of.
329
- * Core sizes that budget and says why; what matters here is that this must not
330
- * be charged to it. A deployment lost a twenty-one-minute session exactly that
331
- * way — consecutive attach races used the retries up, and the redeploy that
332
- * followed had no attempt left to be retried on. See {@link isExecSubscribed}.
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}.
333
426
  *
334
427
  * Everything else is rethrown on the first look, `EEXEC_LOST` above all: it is
335
428
  * the runtime saying the container was replaced, `resume` turns it into a report
@@ -346,14 +439,16 @@ export async function attachRun(runtime, cursor, options = {}) {
346
439
  return await runtime.getExec(cursor.execId, {
347
440
  encoding: "utf8",
348
441
  // `0` is a legal seq and also the beginning, so a fresh cursor resumes
349
- // from the start either way. Later chunks name their own place.
442
+ // from the start either way. A stored cursor names its own place.
350
443
  resume: cursor.seq
351
444
  });
352
445
  }
353
446
  catch (err) {
354
447
  // Rethrown rather than retried once the budget is gone, so the step still
355
448
  // fails on a subscriber that is never coming back — just not first.
356
- if (!isExecSubscribed(err) || now() >= deadline)
449
+ if (!isExecSubscribed(err) ||
450
+ now() >= deadline ||
451
+ options.signal?.aborted)
357
452
  throw err;
358
453
  // Clamped to what is left, so the last gap lands *on* the deadline rather
359
454
  // than past it. Uncapped, a schedule whose final doubling straddles the
@@ -362,66 +457,90 @@ export async function attachRun(runtime, cursor, options = {}) {
362
457
  const waitMs = Math.min(backoff, deadline - now());
363
458
  // One line per wait, not per attempt: this is the condition whose
364
459
  // frequency is worth watching, and it went unnamed in the logs for as
365
- // long as the Workflow was absorbing it a retry at a time.
366
- console.info("[claude-code] the previous window is still attached — waiting", { execId: cursor.execId, waitMs });
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 });
367
462
  await wait(waitMs);
368
463
  backoff = Math.min(backoff * 2, ATTACH_CAP_MS);
369
464
  }
370
465
  }
371
466
  }
372
467
  /**
373
- * Drain a session for one bounded window.
468
+ * Drain a session to its end.
374
469
  *
375
- * Returns `done: false` when the window expired with the process still running —
376
- * the caller checkpoints the cursor and comes back — or `done: true` once the
377
- * 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.
378
472
  */
379
- export async function drainRun(handle, cursor, options) {
473
+ export async function drainRun(handle, cursor, options = {}) {
380
474
  const now = options.now ?? Date.now;
381
- const deadline = now() + options.windowMs;
382
475
  const reader = handle.getReader();
383
476
  /**
384
- * The window's notes, in order — built as the stream is parsed rather than
477
+ * The drain's notes, in order — built as the stream is parsed rather than
385
478
  * from a list of events at the end, so there is one place a note is numbered
386
479
  * and the sink below cannot disagree with what is returned.
387
480
  */
388
481
  const progress = [];
389
482
  let emitted = cursor.emitted;
390
483
  let buffer = cursor.carry;
484
+ let thinkingFrom = cursor.thinkingFrom;
391
485
  let seq = cursor.seq;
392
486
  let result = cursor.result;
393
- // This window's reading only — see `DrainOutcome`. Starting undefined also
394
- // means the first reading of each window reaches the log, which is what keeps
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
395
489
  // a resumed session's bucket visible without carrying a stale one forward.
396
490
  let rateLimit;
397
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;
398
498
  let exitCode;
399
499
  let checkpointedAt = now();
400
500
  /**
401
501
  * The sink's calls, chained rather than awaited.
402
502
  *
403
- * Ordering matters — these are sentences in a conversation — and a post is a
404
- * signed round trip, so awaiting one inside the read loop would stall the
405
- * drain for as long as the session keeps talking. Failures are swallowed here
406
- * because the sink's own contract is best-effort; settled before the drain
407
- * returns, so nothing is cut short when the RPC unwinds.
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.
408
510
  */
409
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;
410
519
  /**
411
520
  * Parse whatever complete lines the buffer now holds.
412
521
  *
413
522
  * Called after **every** stdout event rather than once at the end. Claude
414
- * Code's stream carries whole tool results, so a chunk that only parsed on the
415
- * way out would hold a whole window's transcript of a noisy build in a Durable
416
- * Object's memory; parsing eagerly keeps only `carry`, which is at most one
417
- * 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.
418
527
  */
419
528
  const absorb = () => {
420
- const parsed = parseStream(buffer);
529
+ const parsed = parseStream(buffer, {
530
+ now: now(),
531
+ ...(thinkingFrom === undefined ? {} : { thinkingFrom })
532
+ });
421
533
  buffer = parsed.carry;
534
+ thinkingFrom = parsed.thinkingFrom;
422
535
  for (const event of parsed.events) {
423
536
  if (event.kind === "result")
424
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
+ }
425
544
  if (event.kind === "rateLimit") {
426
545
  /**
427
546
  * Logged when the reading **changes**, not per line — and a change is
@@ -452,15 +571,24 @@ export async function drainRun(handle, cursor, options) {
452
571
  }
453
572
  }
454
573
  // Numbered from the running total, so a note has the same key whether it is
455
- // posted from here or returned on the outcome — and the same key again when
456
- // a retry replays this part of the stream, which is what makes the duplicate
457
- // harmless. See `toProgress`.
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`.
458
577
  for (const note of toProgress(parsed.events, emitted)) {
459
578
  progress.push(note);
460
579
  emitted++;
461
580
  const sink = options.onProgress;
462
581
  if (sink)
463
- sunk = sunk.then(() => sink(note)).catch(() => { });
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
+ });
464
592
  }
465
593
  if (parsed.skipped > 0) {
466
594
  // Not fatal, and deliberately not silent: a systematic schema change
@@ -485,45 +613,65 @@ export async function drainRun(handle, cursor, options) {
485
613
  * One object, so `seq`, `carry` and `emitted` are written together — a cursor
486
614
  * that advanced its position without its note count would renumber every key
487
615
  * after it, and a replay would then post the whole tail again under keys the
488
- * gatekeeper has never seen.
616
+ * transcript has never seen.
489
617
  */
490
618
  const checkpoint = () => ({
491
619
  execId: cursor.execId,
492
620
  seq,
493
621
  carry: buffer,
494
622
  emitted,
623
+ ...(thinkingFrom === undefined ? {} : { thinkingFrom }),
624
+ ...(sessionId ? { sessionId } : {}),
495
625
  ...(result ? { result } : {}),
496
626
  ...(stderr ? { stderr } : {})
497
627
  });
498
628
  /**
499
- * Offer the caller a cursor to persist, at most every
500
- * {@link CHECKPOINT_MIN_MS}.
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.
501
631
  *
502
632
  * Fire-and-forget onto the same chain the notes ride, so a storage write
503
633
  * cannot stall the read loop and cannot land before the notes it claims are
504
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.
505
640
  */
506
641
  const offerCheckpoint = () => {
507
642
  const sink = options.onCheckpoint;
508
643
  // **Refused without `onProgress`, not merely discouraged.** A checkpoint is
509
- // only safe because the notes behind it have been posted; offered to a
510
- // caller that posts nothing, it advances a cursor past notes that were never
511
- // delivered, and a chunk dying after it loses them permanently. The
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
512
647
  // documented unsafe combination is one nothing can reach.
513
- if (!sink || !options.onProgress)
648
+ if (!sink || !options.onProgress || rejected)
514
649
  return;
515
- if (now() - checkpointedAt < CHECKPOINT_MIN_MS)
650
+ if (newHandle)
651
+ newHandle = false;
652
+ else if (now() - checkpointedAt < CHECKPOINT_MIN_MS)
516
653
  return;
517
654
  checkpointedAt = now();
518
655
  const at = checkpoint();
519
- sunk = sunk.then(() => sink(at)).catch(() => { });
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(() => { });
520
666
  };
521
667
  const finish = async (code) => {
522
- const next = checkpoint();
523
668
  // Everything the sink was given has been delivered before the outcome
524
- // naming it is returned, so a caller cannot commit a cursor that claims
525
- // notes nobody posted.
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.
526
673
  await sunk;
674
+ const next = rejected ? safe : checkpoint();
527
675
  return code === undefined
528
676
  ? {
529
677
  done: false,
@@ -542,7 +690,9 @@ export async function drainRun(handle, cursor, options) {
542
690
  // said everything worth saying, and its stderr is the CLI's own
543
691
  // diagnostic chatter — surfacing that beside a perfectly good report
544
692
  // would bury the report.
545
- ...(!result && stderr ? { stderr } : {})
693
+ ...(!result && (SIGNALS[code] || stderr)
694
+ ? { stderr: explainExit(code, stderr) }
695
+ : {})
546
696
  };
547
697
  };
548
698
  try {
@@ -566,14 +716,11 @@ export async function drainRun(handle, cursor, options) {
566
716
  * outstanding pull; see this plugin's README.
567
717
  *
568
718
  * It is also unbounded, deliberately: the pull carries whatever the
569
- * session wrote, an install's dependency tree included, so this last read
570
- * can outlast the window that was left — and the step with it. Cutting it
571
- * short to keep the window would trade a late chunk for edits that never
572
- * land, and that trade is not close: a step killed with the pull still in
573
- * flight is retried, and the sync resumes from the blocks it has already
574
- * committed. A pull that *fails* is the case above, and nothing retries
575
- * that. See {@link file://./config.ts ClaudeCodeConfig.windowMs} for how
576
- * the window is sized around an unbounded tail.
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.
577
724
  */
578
725
  if (exitCode !== undefined) {
579
726
  const next = await reader.read();
@@ -582,22 +729,30 @@ export async function drainRun(handle, cursor, options) {
582
729
  consume(next.value);
583
730
  continue;
584
731
  }
585
- const remaining = deadline - now();
586
- if (remaining <= 0)
587
- return await yieldWindow();
588
- const timer = windowTimer(remaining);
589
- const next = await Promise.race([reader.read(), timer.expired]).finally(timer.clear);
590
- if (next === WINDOW_EXPIRED)
591
- return await yieldWindow();
732
+ if (options.signal?.aborted)
733
+ return await stop();
734
+ const stopping = stopped(options.signal);
735
+ const next = await Promise.race([
736
+ reader.read(),
737
+ stopping.promise
738
+ ]).finally(stopping.clear);
739
+ if (next === STOPPED)
740
+ return await stop();
592
741
  if (next.done) {
593
742
  // The stream ended without an `exit` event — the container went away
594
743
  // under the run. Report it as a failure rather than as still-running,
595
- // or the caller waits out its whole chunk budget on a dead process.
744
+ // or the caller would wait on a dead process.
745
+ stderr = containerLost(stderr);
596
746
  return await finish(-1);
597
747
  }
598
748
  consume(next.value);
599
749
  }
600
750
  }
751
+ catch (err) {
752
+ // See `sunk`. It never rejects: each link catches its own.
753
+ await sunk;
754
+ throw err;
755
+ }
601
756
  finally {
602
757
  reader.releaseLock();
603
758
  }
@@ -621,46 +776,92 @@ export async function drainRun(handle, cursor, options) {
621
776
  exitCode = event.code ?? -1;
622
777
  }
623
778
  /**
624
- * End the window with the session still running.
779
+ * End the drain with the session still running.
625
780
  *
626
781
  * **Cancels rather than merely releasing the lock.** Releasing leaves the
627
- * attachment and its pending read alive on the far side, and a chunked run
628
- * would strand one per window. `cancel` is the wrapper's own designed exit —
629
- * it settles the pending read, resolves the sync outcome as `pending`, and
630
- * lets the next chunk's `getExec` open a fresh attachment that will run the
631
- * 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.
632
787
  *
633
- * **Cancels before settling the sinks, not after.** `finish` awaits every
634
- * progress post this window queued — signed round trips to the gatekeeper, one
635
- * per note — and holding the attachment open across them handed the next chunk
636
- * a subscriber that was still live for no reason but ordering. Safe in this
637
- * order because `finish` reads only what `consume` already put in local state,
638
- * and takes nothing off the stream. It does not close the race that
639
- * {@link attachRun} waits out — the release is still asynchronous on the far
640
- * side — it just stops this end adding to it.
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.
641
792
  */
642
- async function yieldWindow() {
793
+ async function stop() {
643
794
  absorb();
644
- await reader.cancel("chunk window expired").catch(() => { });
795
+ await reader.cancel("drain stopped").catch(() => { });
645
796
  return await finish();
646
797
  }
647
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
+ }
648
846
  function sleep(ms) {
649
847
  return new Promise((resolve) => setTimeout(resolve, ms));
650
848
  }
651
849
  /**
652
- * What's left of the window, as a race a read can win.
850
+ * The caller's signal, as a race a read can win.
653
851
  *
654
- * Cleared by the caller whichever side wins: one is armed per read, and a timer
655
- * left pending holds the facet's `executeChunk` open until the window's end, so
656
- * a session that finishes in seconds would still cost the whole window.
852
+ * Cleared by the caller whichever side wins: one listener is added per read,
853
+ * and a session is many reads.
657
854
  */
658
- function windowTimer(ms) {
659
- let id;
660
- const expired = new Promise((resolve) => {
661
- id = setTimeout(() => resolve(WINDOW_EXPIRED), ms);
855
+ function stopped(signal) {
856
+ let onAbort = () => { };
857
+ const promise = new Promise((resolve) => {
858
+ onAbort = () => resolve(STOPPED);
859
+ signal?.addEventListener("abort", onAbort, { once: true });
662
860
  });
663
- return { expired, clear: () => clearTimeout(id) };
861
+ return {
862
+ promise,
863
+ clear: () => signal?.removeEventListener("abort", onAbort)
864
+ };
664
865
  }
665
866
  /** How much stderr is worth carrying, in characters. */
666
867
  const STDERR_MAX = 2_000;