@dynamicagents/plugins 0.11.1 → 0.12.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 (94) hide show
  1. package/README.md +12 -13
  2. package/dist/arc-agi/analysis.d.ts +16 -30
  3. package/dist/arc-agi/analysis.js +20 -47
  4. package/dist/arc-agi/game-tools.d.ts +5 -6
  5. package/dist/arc-agi/index.d.ts +6 -8
  6. package/dist/arc-agi/index.js +3 -3
  7. package/dist/arc-agi/main-agent.d.ts +5 -8
  8. package/dist/arc-agi/main-agent.js +5 -8
  9. package/dist/arc-agi/recipe.d.ts +17 -20
  10. package/dist/arc-agi/recipe.js +17 -20
  11. package/dist/arc-agi/soul.d.ts +16 -27
  12. package/dist/arc-agi/soul.js +16 -27
  13. package/dist/arc-agi/store.d.ts +4 -5
  14. package/dist/arc-agi/store.js +4 -5
  15. package/dist/arc-agi/tools.d.ts +1 -1
  16. package/dist/arc-agi/tools.js +10 -12
  17. package/dist/arc-agi/types.d.ts +6 -7
  18. package/dist/claude-code/config.d.ts +88 -11
  19. package/dist/claude-code/config.js +11 -2
  20. package/dist/claude-code/credentials.d.ts +21 -9
  21. package/dist/claude-code/credentials.js +43 -9
  22. package/dist/claude-code/egress.d.ts +7 -13
  23. package/dist/claude-code/egress.js +9 -10
  24. package/dist/claude-code/events.d.ts +47 -0
  25. package/dist/claude-code/events.js +96 -10
  26. package/dist/claude-code/index.d.ts +31 -13
  27. package/dist/claude-code/index.js +62 -15
  28. package/dist/claude-code/run.d.ts +135 -41
  29. package/dist/claude-code/run.js +366 -61
  30. package/dist/computer/advisory.d.ts +31 -18
  31. package/dist/computer/advisory.js +30 -5
  32. package/dist/computer/context.d.ts +23 -0
  33. package/dist/computer/context.js +225 -0
  34. package/dist/computer/file-lock.d.ts +5 -0
  35. package/dist/computer/file-lock.js +39 -0
  36. package/dist/computer/gate.d.ts +8 -8
  37. package/dist/computer/gate.js +7 -7
  38. package/dist/computer/host/ca-trust.d.ts +88 -0
  39. package/dist/computer/host/ca-trust.js +303 -0
  40. package/dist/computer/host/container-deps.d.ts +54 -0
  41. package/dist/computer/host/container-deps.js +224 -0
  42. package/dist/computer/host/container-fault.d.ts +38 -0
  43. package/dist/computer/host/container-fault.js +58 -0
  44. package/dist/computer/host/git-host.d.ts +62 -0
  45. package/dist/computer/host/git-host.js +156 -0
  46. package/dist/computer/host/git-identity.d.ts +57 -0
  47. package/dist/computer/host/git-identity.js +147 -0
  48. package/dist/computer/host/install-job.d.ts +121 -0
  49. package/dist/computer/host/install-job.js +766 -0
  50. package/dist/computer/host/sync.d.ts +111 -0
  51. package/dist/computer/host/sync.js +124 -0
  52. package/dist/computer/host/wake.d.ts +19 -0
  53. package/dist/computer/host/wake.js +10 -0
  54. package/dist/computer/host/workspace.d.ts +424 -0
  55. package/dist/computer/host/workspace.js +1258 -0
  56. package/dist/computer/index.d.ts +70 -50
  57. package/dist/computer/index.js +79 -767
  58. package/dist/computer/install.d.ts +8 -12
  59. package/dist/computer/install.js +6 -9
  60. package/dist/computer/paths.d.ts +21 -16
  61. package/dist/computer/paths.js +40 -38
  62. package/dist/computer/read.d.ts +18 -22
  63. package/dist/computer/read.js +24 -27
  64. package/dist/computer/render.d.ts +28 -0
  65. package/dist/computer/render.js +34 -1
  66. package/dist/computer/shell.d.ts +45 -0
  67. package/dist/computer/shell.js +80 -0
  68. package/dist/computer/tools-exec.d.ts +3 -0
  69. package/dist/computer/tools-exec.js +269 -0
  70. package/dist/computer/tools-file.d.ts +4 -0
  71. package/dist/computer/tools-file.js +124 -0
  72. package/dist/computer/tools-find.d.ts +3 -0
  73. package/dist/computer/tools-find.js +216 -0
  74. package/dist/recall/index.d.ts +9 -3
  75. package/dist/recall/index.js +22 -12
  76. package/dist/repo/checkout.d.ts +34 -4
  77. package/dist/repo/checkout.js +36 -1
  78. package/dist/repo/context.d.ts +109 -0
  79. package/dist/repo/context.js +530 -0
  80. package/dist/repo/index.d.ts +6 -29
  81. package/dist/repo/index.js +17 -1027
  82. package/dist/repo/tools-clone.d.ts +4 -0
  83. package/dist/repo/tools-clone.js +192 -0
  84. package/dist/repo/tools-forge.d.ts +4 -0
  85. package/dist/repo/tools-forge.js +179 -0
  86. package/dist/repo/tools-review.d.ts +3 -0
  87. package/dist/repo/tools-review.js +373 -0
  88. package/dist/repo/tools-worktree.d.ts +4 -0
  89. package/dist/repo/tools-worktree.js +221 -0
  90. package/dist/triage/index.d.ts +6 -1
  91. package/dist/triage/index.js +32 -23
  92. package/dist/workspace/index.d.ts +3 -3
  93. package/dist/workspace/index.js +3 -3
  94. package/package.json +21 -15
@@ -1,5 +1,5 @@
1
1
  import { shellQuote } from "@cloudflare/computer";
2
- import { parseStream, toProgress } from "./events.js";
2
+ import { parseStream, toProgress, RATE_LIMIT_OK } from "./events.js";
3
3
  import { DEFAULT_PERMISSION_MODE } from "./config.js";
4
4
  /** Namespace for every session exec id in a workspace. */
5
5
  export const CLAUDE_EXEC_PREFIX = "claude-code-run";
@@ -25,11 +25,10 @@ export function execIdFor(subtaskId) {
25
25
  /**
26
26
  * What the container is given instead of a credential.
27
27
  *
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.
28
+ * Claude Code does not validate it locally: a run with this exact value
29
+ * succeeds, and the container only ever sends the placeholder. The egress
30
+ * gateway swaps in the real credential on the way out, so a `postinstall` script
31
+ * that dumps the environment learns this and nothing else.
33
32
  *
34
33
  * Shaped like a real token deliberately: something that looks obviously fake
35
34
  * invites a future reader to "fix" it by putting the real one there.
@@ -45,6 +44,31 @@ export const CREDENTIAL_PLACEHOLDER = "sk-ant-oat01-" + "0".repeat(24);
45
44
  * is refused rather than overridden.
46
45
  */
47
46
  const RESERVED_ENV_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
47
+ /**
48
+ * Git's identity as an *environment*, not a `git config`.
49
+ *
50
+ * The session's cwd is one checkout, and what it commits in is not: a
51
+ * superproject's submodules, a scratch clone, anything it initialises are all
52
+ * separate repositories with separate configs, and `/repo` only ever configured
53
+ * the one it cloned. A config write would have to find each of them; the
54
+ * environment is inherited by every git the session starts, and it outranks all
55
+ * four config levels — so a checkout carrying a name from whenever it was
56
+ * created no longer decides who commits today.
57
+ *
58
+ * Both pairs, because they answer different questions: an amend or a rebase
59
+ * keeps the original author and stamps a fresh committer, and with only
60
+ * `GIT_AUTHOR_*` set that commit ends up half attributed.
61
+ */
62
+ function gitIdentityEnv(author) {
63
+ if (!author)
64
+ return {};
65
+ return {
66
+ GIT_AUTHOR_NAME: author.name,
67
+ GIT_AUTHOR_EMAIL: author.email,
68
+ GIT_COMMITTER_NAME: author.name,
69
+ GIT_COMMITTER_EMAIL: author.email
70
+ };
71
+ }
48
72
  /**
49
73
  * Build the command and environment for one session.
50
74
  *
@@ -59,27 +83,20 @@ const RESERVED_ENV_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
59
83
  * container is already an arbitrary-code-execution environment by design — the
60
84
  * install runs the repo's `postinstall`, the agent runs its test suite. Stripping
61
85
  * 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.)
86
+ * context.
63
87
  *
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.
88
+ * No `ANTHROPIC_BASE_URL` either: `http-gateway` egress intercepts
89
+ * transparently, so the client talks to the real hostname and the gateway sees
90
+ * it. Nothing has to be told to use a proxy, which means nothing in the
91
+ * container can be told *not* to.
68
92
  *
69
93
  * ## `--permission-mode`, and the root guard behind it
70
94
  *
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.
95
+ * The mode is always passed explicitly, never left to the CLI's `default` — see
96
+ * {@link file://./config.ts ClaudeCodeConfig.permissionMode} for what an
97
+ * unset mode does to a headless session, and why
98
+ * {@link DEFAULT_PERMISSION_MODE} is the permissive value rather than a
99
+ * concession.
83
100
  *
84
101
  * **`IS_SANDBOX=1` ships in the same branch as the flag, and separating the two
85
102
  * breaks a session harder than passing no flag at all.** The container runs as
@@ -121,8 +138,8 @@ export function buildLaunch(options) {
121
138
  argv.push("--permission-mode", permissionMode);
122
139
  if (options.model)
123
140
  argv.push("--model", shellQuote(options.model));
124
- if (options.maxTurns !== undefined)
125
- argv.push("--max-turns", String(options.maxTurns));
141
+ if (options.effort)
142
+ argv.push("--effort", options.effort);
126
143
  const env = {
127
144
  // Pinned image; an autoupdate would move the wire shape the gateway and the
128
145
  // parser are both written against, mid-run and without a deploy.
@@ -138,16 +155,23 @@ export function buildLaunch(options) {
138
155
  env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS = String(options.maxConcurrentSubagents);
139
156
  return {
140
157
  command: argv.join(" "),
141
- // The last two are applied **after** the host's environment rather than
158
+ // The last three are applied **after** the host's environment rather than
142
159
  // before it. For the placeholder that makes the guard above a second line
143
160
  // rather than the only one; for `IS_SANDBOX` it means a host cannot unset
144
161
  // the variable its own permission mode depends on — see the note above.
162
+ //
163
+ // For the identity it is what keeps the four keys one answer. Spread before
164
+ // `env`, a host that set `GIT_AUTHOR_NAME` there and `author` here would
165
+ // get the author from one and the committer from the other — a commit
166
+ // attributed to two people, which is the exact failure `author` exists to
167
+ // end. Last, `author` is simply the answer whenever a host gives one.
145
168
  env: {
146
169
  ...env,
147
170
  ...options.env,
148
171
  ...(permissionMode === "bypassPermissions"
149
172
  ? { IS_SANDBOX: "1" }
150
173
  : undefined),
174
+ ...gitIdentityEnv(options.author),
151
175
  [RESERVED_ENV_KEY]: CREDENTIAL_PLACEHOLDER
152
176
  }
153
177
  };
@@ -156,12 +180,109 @@ export function buildLaunch(options) {
156
180
  export function freshCursor(execId) {
157
181
  return { execId, seq: 0, carry: "", emitted: 0 };
158
182
  }
183
+ /**
184
+ * Whether a bucket reading says anything the last one did not.
185
+ *
186
+ * Every field, because each is separately actionable: the status decides whether
187
+ * a credential is worth using, `resetsAt` is when it recovers, the type says
188
+ * which bucket is talking, and the overage pair says whether spending past it is
189
+ * allowed and why not. Comparing only the status would hold a rollover — a new
190
+ * `resetsAt` under an unchanged `allowed` — out of the logs entirely.
191
+ *
192
+ * **Over the keys rather than a written-out list**, which is the difference
193
+ * between one bug and a class of them: a field added to
194
+ * {@link RateLimitInfo} and forgotten here does not make a smaller log, it makes
195
+ * a change to the reading that never reaches a log at all — the one failure this
196
+ * function exists to prevent, and a silent one. Shallow is exact because the
197
+ * reading is flat and every value on it is a primitive; a nested field would
198
+ * need this revisited, and the parser is where that would be decided.
199
+ */
200
+ function changedReading(previous, next) {
201
+ if (previous === undefined)
202
+ return true;
203
+ const before = { ...previous };
204
+ const after = { ...next };
205
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].some((key) => before[key] !== after[key]);
206
+ }
159
207
  /** Distinguishes "the window ran out" from a real stream event in the race below. */
160
208
  const WINDOW_EXPIRED = Symbol("window-expired");
209
+ /**
210
+ * How rarely a drain offers a cursor to persist.
211
+ *
212
+ * A checkpoint is a Durable Object storage write, and the stream can carry many
213
+ * events a second while a session reads files — so this is a floor on the writes,
214
+ * not a schedule for them. Sized against what it is protecting: the loss it
215
+ * bounds is re-drained in seconds, so buying a smaller bound with a write per
216
+ * event would cost more than the bound is worth.
217
+ */
218
+ const CHECKPOINT_MIN_MS = 30_000;
161
219
  /** Whether a thrown value is the runtime refusing to reuse a live exec id. */
162
220
  function isExecBusy(err) {
163
221
  return err?.code === "EEXEC_BUSY";
164
222
  }
223
+ /**
224
+ * Whether a thrown value means the session this cursor names is gone for good.
225
+ *
226
+ * `EEXEC_LOST` is the runtime saying the container that held the execution was
227
+ * replaced. The session died with it, and no attachment will ever find it — so
228
+ * 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.
230
+ */
231
+ export function isExecLost(err) {
232
+ return err?.code === "EEXEC_LOST";
233
+ }
234
+ /**
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
243
+ * waiting rather than by failing.
244
+ *
245
+ * **Matched on the message, not on a code, and that is not laziness.** This one
246
+ * 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`
249
+ * above, which the client throws and codes. The code is checked first anyway, so
250
+ * the day the container starts sending one this keeps working and the string
251
+ * test becomes the fallback it should have been.
252
+ */
253
+ function isExecSubscribed(err) {
254
+ const e = err;
255
+ if (e?.code === "EEXEC_SUBSCRIBED")
256
+ return true;
257
+ return (typeof e?.message === "string" &&
258
+ e.message.includes("already has a live subscriber"));
259
+ }
260
+ /**
261
+ * How long {@link attachRun} waits out a subscriber that has not been released.
262
+ *
263
+ * Bounded, because what it is waiting on may not be a teardown at all: an
264
+ * isolate that is genuinely wedged holding the stream releases it when it dies,
265
+ * 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.
269
+ *
270
+ * Sized well above what the race costs in practice. The boundary normally clears
271
+ * on the first or second look, and a deployment where it took fifteen seconds is
272
+ * what this is sized to outlast.
273
+ */
274
+ const ATTACH_MAX_MS = 30_000;
275
+ /** The first gap between attach attempts, doubled up to {@link ATTACH_CAP_MS}. */
276
+ const ATTACH_BACKOFF_MS = 250;
277
+ /**
278
+ * The ceiling on that doubling.
279
+ *
280
+ * Without it the last gap before {@link ATTACH_MAX_MS} would be most of the
281
+ * budget, so a subscriber released early in it would still be waited out to the
282
+ * end. What this buys is that the wait ends soon after the release, rather than
283
+ * at the next power of two.
284
+ */
285
+ const ATTACH_CAP_MS = 2_000;
165
286
  /**
166
287
  * Start a session, detached.
167
288
  *
@@ -172,6 +293,12 @@ function isExecBusy(err) {
172
293
  * still running (`EEXEC_BUSY`). Without this the retry throws, every later retry
173
294
  * throws the same way, and a perfectly healthy session in the container becomes
174
295
  * unreachable.
296
+ *
297
+ * **The sync stays on the default, and `defer` is not an option here.** A
298
+ * deferred exec skips the post-command pull entirely and settles the outcome
299
+ * 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.
175
302
  */
176
303
  export async function startRun(runtime, options) {
177
304
  const { command, env } = buildLaunch(options);
@@ -191,14 +318,56 @@ export async function startRun(runtime, options) {
191
318
  return await attachRun(runtime, freshCursor(options.execId));
192
319
  }
193
320
  }
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
- });
321
+ /**
322
+ * Re-attach to a session this isolate did not start.
323
+ *
324
+ * **Waits out a subscriber the previous window has not released yet**, and that
325
+ * 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}.
333
+ *
334
+ * Everything else is rethrown on the first look, `EEXEC_LOST` above all: it is
335
+ * the runtime saying the container was replaced, `resume` turns it into a report
336
+ * for the model, and a retry loop would sit on it for {@link ATTACH_MAX_MS}
337
+ * learning nothing.
338
+ */
339
+ export async function attachRun(runtime, cursor, options = {}) {
340
+ const now = options.now ?? Date.now;
341
+ const wait = options.wait ?? sleep;
342
+ const deadline = now() + ATTACH_MAX_MS;
343
+ let backoff = ATTACH_BACKOFF_MS;
344
+ for (;;) {
345
+ try {
346
+ return await runtime.getExec(cursor.execId, {
347
+ encoding: "utf8",
348
+ // `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.
350
+ resume: cursor.seq
351
+ });
352
+ }
353
+ catch (err) {
354
+ // Rethrown rather than retried once the budget is gone, so the step still
355
+ // fails on a subscriber that is never coming back — just not first.
356
+ if (!isExecSubscribed(err) || now() >= deadline)
357
+ throw err;
358
+ // Clamped to what is left, so the last gap lands *on* the deadline rather
359
+ // than past it. Uncapped, a schedule whose final doubling straddles the
360
+ // bound would take one more look on the far side of it — and the bound
361
+ // would be a number in a comment rather than one the code keeps.
362
+ const waitMs = Math.min(backoff, deadline - now());
363
+ // One line per wait, not per attempt: this is the condition whose
364
+ // 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 });
367
+ await wait(waitMs);
368
+ backoff = Math.min(backoff * 2, ATTACH_CAP_MS);
369
+ }
370
+ }
202
371
  }
203
372
  /**
204
373
  * Drain a session for one bounded window.
@@ -211,18 +380,39 @@ export async function drainRun(handle, cursor, options) {
211
380
  const now = options.now ?? Date.now;
212
381
  const deadline = now() + options.windowMs;
213
382
  const reader = handle.getReader();
214
- const events = [];
383
+ /**
384
+ * The window's notes, in order — built as the stream is parsed rather than
385
+ * from a list of events at the end, so there is one place a note is numbered
386
+ * and the sink below cannot disagree with what is returned.
387
+ */
388
+ const progress = [];
389
+ let emitted = cursor.emitted;
215
390
  let buffer = cursor.carry;
216
391
  let seq = cursor.seq;
217
392
  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
395
+ // a resumed session's bucket visible without carrying a stale one forward.
396
+ let rateLimit;
218
397
  let stderr = cursor.stderr ?? "";
219
398
  let exitCode;
399
+ let checkpointedAt = now();
400
+ /**
401
+ * The sink's calls, chained rather than awaited.
402
+ *
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.
408
+ */
409
+ let sunk = Promise.resolve();
220
410
  /**
221
411
  * Parse whatever complete lines the buffer now holds.
222
412
  *
223
413
  * Called after **every** stdout event rather than once at the end. Claude
224
414
  * 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
415
+ * way out would hold a whole window's transcript of a noisy build in a Durable
226
416
  * Object's memory; parsing eagerly keeps only `carry`, which is at most one
227
417
  * incomplete line.
228
418
  */
@@ -230,9 +420,47 @@ export async function drainRun(handle, cursor, options) {
230
420
  const parsed = parseStream(buffer);
231
421
  buffer = parsed.carry;
232
422
  for (const event of parsed.events) {
233
- events.push(event);
234
423
  if (event.kind === "result")
235
424
  result = event.result;
425
+ if (event.kind === "rateLimit") {
426
+ /**
427
+ * Logged when the reading **changes**, not per line — and a change is
428
+ * any field of it, not just the status: a bucket rolling over moves
429
+ * `resetsAt` while saying `allowed` throughout, and the new reset is
430
+ * the useful half of that line.
431
+ *
432
+ * The client repeats itself for as long as a session runs, so a log per
433
+ * event would be the noisiest thing this package writes and every copy
434
+ * after the first would say the same thing. A change is the whole signal:
435
+ * the first reading names the bucket and when it refills, and the only
436
+ * other one that can happen is the bucket saying no — which is the line
437
+ * nobody has captured yet, and the one the pool needs before it can be
438
+ * trusted to act on a status rather than on a refused request.
439
+ */
440
+ if (changedReading(rateLimit, event.info)) {
441
+ const at = { execId: cursor.execId, ...event.info };
442
+ // An unrecognised status is **not** reported as a refusal. Nothing
443
+ // here knows what one means, and a log that calls it exhaustion is
444
+ // how a healthy credential gets retired by the next person to read
445
+ // it. See `RATE_LIMIT_OK`.
446
+ if (event.info.status === RATE_LIMIT_OK)
447
+ console.info("[claude-code] subscription bucket", at);
448
+ else
449
+ console.warn("[claude-code] unrecognised subscription status", at);
450
+ }
451
+ rateLimit = event.info;
452
+ }
453
+ }
454
+ // 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`.
458
+ for (const note of toProgress(parsed.events, emitted)) {
459
+ progress.push(note);
460
+ emitted++;
461
+ const sink = options.onProgress;
462
+ if (sink)
463
+ sunk = sunk.then(() => sink(note)).catch(() => { });
236
464
  }
237
465
  if (parsed.skipped > 0) {
238
466
  // Not fatal, and deliberately not silent: a systematic schema change
@@ -251,22 +479,63 @@ export async function drainRun(handle, cursor, options) {
251
479
  });
252
480
  }
253
481
  };
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
- };
482
+ /**
483
+ * Where the drain has got to, as something safe to persist.
484
+ *
485
+ * One object, so `seq`, `carry` and `emitted` are written together — a cursor
486
+ * that advanced its position without its note count would renumber every key
487
+ * after it, and a replay would then post the whole tail again under keys the
488
+ * gatekeeper has never seen.
489
+ */
490
+ const checkpoint = () => ({
491
+ execId: cursor.execId,
492
+ seq,
493
+ carry: buffer,
494
+ emitted,
495
+ ...(result ? { result } : {}),
496
+ ...(stderr ? { stderr } : {})
497
+ });
498
+ /**
499
+ * Offer the caller a cursor to persist, at most every
500
+ * {@link CHECKPOINT_MIN_MS}.
501
+ *
502
+ * Fire-and-forget onto the same chain the notes ride, so a storage write
503
+ * cannot stall the read loop and cannot land before the notes it claims are
504
+ * already posted.
505
+ */
506
+ const offerCheckpoint = () => {
507
+ const sink = options.onCheckpoint;
508
+ // **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
512
+ // documented unsafe combination is one nothing can reach.
513
+ if (!sink || !options.onProgress)
514
+ return;
515
+ if (now() - checkpointedAt < CHECKPOINT_MIN_MS)
516
+ return;
517
+ checkpointedAt = now();
518
+ const at = checkpoint();
519
+ sunk = sunk.then(() => sink(at)).catch(() => { });
520
+ };
521
+ const finish = async (code) => {
522
+ const next = checkpoint();
523
+ // 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.
526
+ await sunk;
264
527
  return code === undefined
265
- ? { done: false, cursor: next, progress }
528
+ ? {
529
+ done: false,
530
+ cursor: next,
531
+ progress,
532
+ ...(rateLimit ? { rateLimit } : {})
533
+ }
266
534
  : {
267
535
  done: true,
268
536
  cursor: next,
269
537
  progress,
538
+ ...(rateLimit ? { rateLimit } : {}),
270
539
  exitCode: code,
271
540
  ...(result ? { result } : {}),
272
541
  // Only when there is no result. A session that reported for itself has
@@ -290,30 +559,41 @@ export async function drainRun(handle, cursor, options) {
290
559
  * simply not there.
291
560
  *
292
561
  * 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.
562
+ * pull before it closes the stream, so observing `done` means the pull was
563
+ * attempted. Attempted, not guaranteed — a pull that fails leaves the
564
+ * stream closing normally and reports itself on the result the host does
565
+ * not see here, and nothing retries it on its own. The host drives any
566
+ * outstanding pull; see this plugin's README.
567
+ *
568
+ * 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.
295
577
  */
296
578
  if (exitCode !== undefined) {
297
579
  const next = await reader.read();
298
580
  if (next.done)
299
- return finish(exitCode);
581
+ return await finish(exitCode);
300
582
  consume(next.value);
301
583
  continue;
302
584
  }
303
585
  const remaining = deadline - now();
304
586
  if (remaining <= 0)
305
587
  return await yieldWindow();
306
- const next = await Promise.race([
307
- reader.read(),
308
- sleep(remaining).then(() => WINDOW_EXPIRED)
309
- ]);
588
+ const timer = windowTimer(remaining);
589
+ const next = await Promise.race([reader.read(), timer.expired]).finally(timer.clear);
310
590
  if (next === WINDOW_EXPIRED)
311
591
  return await yieldWindow();
312
592
  if (next.done) {
313
593
  // The stream ended without an `exit` event — the container went away
314
594
  // under the run. Report it as a failure rather than as still-running,
315
595
  // or the caller waits out its whole chunk budget on a dead process.
316
- return finish(-1);
596
+ return await finish(-1);
317
597
  }
318
598
  consume(next.value);
319
599
  }
@@ -326,6 +606,9 @@ export async function drainRun(handle, cursor, options) {
326
606
  if (event.name === "stdout" && event.value !== undefined) {
327
607
  buffer += event.value;
328
608
  absorb();
609
+ // After absorbing, never before: the position offered has to be one whose
610
+ // notes are already on the sink's queue.
611
+ offerCheckpoint();
329
612
  }
330
613
  // stderr is Claude Code's own diagnostics, not the protocol stream. Kept out
331
614
  // of the parser so a warning line cannot be mistaken for an event — but
@@ -346,17 +629,39 @@ export async function drainRun(handle, cursor, options) {
346
629
  * it settles the pending read, resolves the sync outcome as `pending`, and
347
630
  * lets the next chunk's `getExec` open a fresh attachment that will run the
348
631
  * real sync when the session finally ends.
632
+ *
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.
349
641
  */
350
642
  async function yieldWindow() {
351
643
  absorb();
352
- const outcome = finish();
353
644
  await reader.cancel("chunk window expired").catch(() => { });
354
- return outcome;
645
+ return await finish();
355
646
  }
356
647
  }
357
648
  function sleep(ms) {
358
649
  return new Promise((resolve) => setTimeout(resolve, ms));
359
650
  }
651
+ /**
652
+ * What's left of the window, as a race a read can win.
653
+ *
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.
657
+ */
658
+ function windowTimer(ms) {
659
+ let id;
660
+ const expired = new Promise((resolve) => {
661
+ id = setTimeout(() => resolve(WINDOW_EXPIRED), ms);
662
+ });
663
+ return { expired, clear: () => clearTimeout(id) };
664
+ }
360
665
  /** How much stderr is worth carrying, in characters. */
361
666
  const STDERR_MAX = 2_000;
362
667
  /**
@@ -372,9 +677,9 @@ const STDERR_MAX = 2_000;
372
677
  * gives up prints one line and exits, making that line the last thing there is.
373
678
  * Keeping both ends costs a few hundred characters and removes the guess.
374
679
  *
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.
680
+ * Not `truncateOutput`, and the difference is the marker: this runs on every
681
+ * append, so the cut has to stay a single character rather than a sentence that
682
+ * would be re-cut and nested on the next one.
378
683
  */
379
684
  function boundStderr(text) {
380
685
  if (text.length <= STDERR_MAX)