@dynamicagents/plugins 0.11.0 → 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.
- package/README.md +12 -13
- package/dist/arc-agi/analysis.d.ts +16 -30
- package/dist/arc-agi/analysis.js +20 -47
- package/dist/arc-agi/client.d.ts +3 -2
- package/dist/arc-agi/game-tools.d.ts +5 -6
- package/dist/arc-agi/index.d.ts +6 -8
- package/dist/arc-agi/index.js +3 -3
- package/dist/arc-agi/main-agent.d.ts +5 -8
- package/dist/arc-agi/main-agent.js +5 -8
- package/dist/arc-agi/recipe.d.ts +17 -20
- package/dist/arc-agi/recipe.js +17 -20
- package/dist/arc-agi/soul.d.ts +16 -27
- package/dist/arc-agi/soul.js +16 -27
- package/dist/arc-agi/store.d.ts +4 -5
- package/dist/arc-agi/store.js +4 -5
- package/dist/arc-agi/tools.d.ts +1 -1
- package/dist/arc-agi/tools.js +26 -19
- package/dist/arc-agi/types.d.ts +6 -7
- package/dist/claude-code/config.d.ts +88 -11
- package/dist/claude-code/config.js +11 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +7 -13
- package/dist/claude-code/egress.js +9 -10
- package/dist/claude-code/events.d.ts +47 -0
- package/dist/claude-code/events.js +96 -10
- package/dist/claude-code/index.d.ts +31 -13
- package/dist/claude-code/index.js +62 -15
- package/dist/claude-code/run.d.ts +135 -41
- package/dist/claude-code/run.js +366 -61
- package/dist/computer/advisory.d.ts +31 -18
- package/dist/computer/advisory.js +30 -5
- package/dist/computer/context.d.ts +23 -0
- package/dist/computer/context.js +225 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +39 -0
- package/dist/computer/gate.d.ts +8 -8
- package/dist/computer/gate.js +7 -7
- package/dist/computer/host/ca-trust.d.ts +88 -0
- package/dist/computer/host/ca-trust.js +303 -0
- package/dist/computer/host/container-deps.d.ts +54 -0
- package/dist/computer/host/container-deps.js +224 -0
- package/dist/computer/host/container-fault.d.ts +38 -0
- package/dist/computer/host/container-fault.js +58 -0
- package/dist/computer/host/git-host.d.ts +62 -0
- package/dist/computer/host/git-host.js +156 -0
- package/dist/computer/host/git-identity.d.ts +57 -0
- package/dist/computer/host/git-identity.js +147 -0
- package/dist/computer/host/install-job.d.ts +121 -0
- package/dist/computer/host/install-job.js +766 -0
- package/dist/computer/host/sync.d.ts +111 -0
- package/dist/computer/host/sync.js +124 -0
- package/dist/computer/host/wake.d.ts +19 -0
- package/dist/computer/host/wake.js +10 -0
- package/dist/computer/host/workspace.d.ts +424 -0
- package/dist/computer/host/workspace.js +1258 -0
- package/dist/computer/index.d.ts +70 -50
- package/dist/computer/index.js +79 -767
- package/dist/computer/install.d.ts +8 -12
- package/dist/computer/install.js +6 -9
- package/dist/computer/paths.d.ts +21 -16
- package/dist/computer/paths.js +40 -38
- package/dist/computer/read.d.ts +18 -22
- package/dist/computer/read.js +24 -27
- package/dist/computer/render.d.ts +28 -0
- package/dist/computer/render.js +34 -1
- package/dist/computer/shell.d.ts +45 -0
- package/dist/computer/shell.js +80 -0
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +269 -0
- package/dist/computer/tools-file.d.ts +4 -0
- package/dist/computer/tools-file.js +124 -0
- package/dist/computer/tools-find.d.ts +3 -0
- package/dist/computer/tools-find.js +216 -0
- package/dist/recall/index.d.ts +9 -3
- package/dist/recall/index.js +22 -12
- package/dist/repo/checkout.d.ts +34 -4
- package/dist/repo/checkout.js +36 -1
- package/dist/repo/context.d.ts +109 -0
- package/dist/repo/context.js +530 -0
- package/dist/repo/index.d.ts +6 -29
- package/dist/repo/index.js +17 -1027
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-forge.d.ts +4 -0
- package/dist/repo/tools-forge.js +179 -0
- package/dist/repo/tools-review.d.ts +3 -0
- package/dist/repo/tools-review.js +373 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +221 -0
- package/dist/triage/index.d.ts +6 -1
- package/dist/triage/index.js +32 -23
- package/dist/workspace/index.d.ts +3 -3
- package/dist/workspace/index.js +3 -3
- package/package.json +21 -15
package/dist/claude-code/run.js
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
86
|
+
* context.
|
|
63
87
|
*
|
|
64
|
-
* No `ANTHROPIC_BASE_URL` either
|
|
65
|
-
* the
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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.
|
|
125
|
-
argv.push("--
|
|
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
|
|
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
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
? {
|
|
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
|
|
294
|
-
*
|
|
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
|
|
307
|
-
|
|
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
|
|
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
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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)
|