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