@dynamicagents/plugins 0.14.0 → 0.15.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 (141) hide show
  1. package/README.md +14 -14
  2. package/dist/claude-code/cards.d.ts +2 -0
  3. package/dist/claude-code/cards.js +11 -0
  4. package/dist/claude-code/config.d.ts +10 -83
  5. package/dist/claude-code/config.js +0 -2
  6. package/dist/claude-code/credentials.d.ts +32 -50
  7. package/dist/claude-code/credentials.js +115 -74
  8. package/dist/claude-code/daemon/index.d.ts +24 -0
  9. package/dist/claude-code/daemon/index.js +203 -0
  10. package/dist/claude-code/egress.d.ts +35 -117
  11. package/dist/claude-code/egress.js +80 -325
  12. package/dist/claude-code/events.d.ts +14 -17
  13. package/dist/claude-code/events.js +19 -15
  14. package/dist/claude-code/harness-wire.d.ts +45 -0
  15. package/dist/claude-code/harness-wire.js +47 -0
  16. package/dist/claude-code/index.d.ts +19 -29
  17. package/dist/claude-code/index.js +17 -25
  18. package/dist/claude-code/language-model.d.ts +19 -0
  19. package/dist/claude-code/language-model.js +71 -0
  20. package/dist/claude-code/model.d.ts +77 -70
  21. package/dist/claude-code/model.js +327 -327
  22. package/dist/claude-code/placeholder.d.ts +12 -0
  23. package/dist/claude-code/placeholder.js +12 -0
  24. package/dist/claude-code/spec.d.ts +4 -4
  25. package/dist/claude-code/spec.js +4 -4
  26. package/dist/computer/client.d.ts +88 -0
  27. package/dist/computer/client.js +6 -0
  28. package/dist/computer/context.d.ts +7 -7
  29. package/dist/computer/context.js +14 -18
  30. package/dist/computer/gate.d.ts +4 -23
  31. package/dist/computer/gate.js +1 -37
  32. package/dist/computer/index.d.ts +44 -43
  33. package/dist/computer/index.js +29 -50
  34. package/dist/computer/paths.d.ts +5 -27
  35. package/dist/computer/paths.js +7 -30
  36. package/dist/computer/proxy.d.ts +17 -31
  37. package/dist/computer/proxy.js +9 -44
  38. package/dist/computer/render.d.ts +3 -40
  39. package/dist/computer/render.js +3 -36
  40. package/dist/computer/sandbox.d.ts +55 -0
  41. package/dist/computer/sandbox.js +236 -0
  42. package/dist/computer/shell.d.ts +36 -0
  43. package/dist/computer/shell.js +41 -0
  44. package/dist/computer/tools-exec.js +3 -9
  45. package/dist/computer/tools-file.d.ts +1 -1
  46. package/dist/computer/tools-file.js +1 -1
  47. package/dist/{repo → repo-github}/checkout.d.ts +3 -4
  48. package/dist/{repo → repo-github}/checkout.js +3 -4
  49. package/dist/{repo → repo-github}/context.d.ts +14 -32
  50. package/dist/{repo → repo-github}/context.js +46 -81
  51. package/dist/{repo → repo-github}/index.d.ts +30 -13
  52. package/dist/{repo → repo-github}/index.js +25 -6
  53. package/dist/repo-github/mcp.d.ts +95 -0
  54. package/dist/repo-github/mcp.js +107 -0
  55. package/dist/repo-github/sandbox-egress.d.ts +68 -0
  56. package/dist/repo-github/sandbox-egress.js +272 -0
  57. package/dist/repo-github/sandbox-git.d.ts +30 -0
  58. package/dist/repo-github/sandbox-git.js +113 -0
  59. package/dist/{repo → repo-github}/tools-checks.js +43 -4
  60. package/dist/{repo → repo-github}/tools-clone.js +2 -21
  61. package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
  62. package/dist/{repo → repo-github}/tools-pr.js +85 -20
  63. package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
  64. package/dist/{repo → repo-github}/tools-review.js +62 -176
  65. package/dist/{repo → repo-github}/url.d.ts +11 -0
  66. package/dist/{repo → repo-github}/url.js +12 -1
  67. package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
  68. package/dist/{workspace → sandbox}/advisory.js +2 -29
  69. package/dist/sandbox/agent-exec.d.ts +31 -0
  70. package/dist/sandbox/agent-exec.js +62 -0
  71. package/dist/sandbox/ca-trust.d.ts +9 -0
  72. package/dist/sandbox/ca-trust.js +21 -0
  73. package/dist/sandbox/constants.d.ts +22 -0
  74. package/dist/sandbox/constants.js +22 -0
  75. package/dist/sandbox/daemon/composite.d.ts +9 -0
  76. package/dist/sandbox/daemon/composite.js +35 -0
  77. package/dist/sandbox/daemon/index.d.ts +47 -0
  78. package/dist/sandbox/daemon/index.js +116 -0
  79. package/dist/sandbox/egress.d.ts +81 -0
  80. package/dist/sandbox/egress.js +109 -0
  81. package/dist/sandbox/exec.d.ts +57 -0
  82. package/dist/sandbox/exec.js +52 -0
  83. package/dist/sandbox/format.d.ts +2 -0
  84. package/dist/sandbox/format.js +7 -0
  85. package/dist/sandbox/header.d.ts +50 -0
  86. package/dist/sandbox/header.js +94 -0
  87. package/dist/sandbox/index.d.ts +17 -0
  88. package/dist/sandbox/index.js +17 -0
  89. package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +2 -2
  90. package/dist/{workspace/install.js → sandbox/install-plan.js} +2 -2
  91. package/dist/sandbox/name.d.ts +27 -0
  92. package/dist/sandbox/name.js +32 -0
  93. package/dist/sandbox/object.d.ts +244 -0
  94. package/dist/sandbox/object.js +1139 -0
  95. package/dist/scratch/index.d.ts +1 -9
  96. package/dist/scratch/index.js +2 -18
  97. package/package.json +22 -18
  98. package/dist/claude-code/github.d.ts +0 -39
  99. package/dist/claude-code/github.js +0 -82
  100. package/dist/claude-code/run.d.ts +0 -491
  101. package/dist/claude-code/run.js +0 -913
  102. package/dist/claude-code/session.d.ts +0 -131
  103. package/dist/claude-code/session.js +0 -288
  104. package/dist/workspace/ca-trust.d.ts +0 -95
  105. package/dist/workspace/ca-trust.js +0 -312
  106. package/dist/workspace/container-deps.d.ts +0 -58
  107. package/dist/workspace/container-deps.js +0 -249
  108. package/dist/workspace/container-fault.d.ts +0 -38
  109. package/dist/workspace/container-fault.js +0 -73
  110. package/dist/workspace/deps-snapshot.d.ts +0 -110
  111. package/dist/workspace/deps-snapshot.js +0 -424
  112. package/dist/workspace/exec.d.ts +0 -94
  113. package/dist/workspace/exec.js +0 -153
  114. package/dist/workspace/format.d.ts +0 -36
  115. package/dist/workspace/format.js +0 -57
  116. package/dist/workspace/git-host.d.ts +0 -79
  117. package/dist/workspace/git-host.js +0 -226
  118. package/dist/workspace/git-identity.d.ts +0 -57
  119. package/dist/workspace/git-identity.js +0 -147
  120. package/dist/workspace/index.d.ts +0 -50
  121. package/dist/workspace/index.js +0 -58
  122. package/dist/workspace/install-job.d.ts +0 -170
  123. package/dist/workspace/install-job.js +0 -934
  124. package/dist/workspace/object.d.ts +0 -522
  125. package/dist/workspace/object.js +0 -1541
  126. package/dist/workspace/open.d.ts +0 -95
  127. package/dist/workspace/open.js +0 -63
  128. package/dist/workspace/read.d.ts +0 -16
  129. package/dist/workspace/read.js +0 -22
  130. package/dist/workspace/shell.d.ts +0 -45
  131. package/dist/workspace/shell.js +0 -80
  132. package/dist/workspace/sync.d.ts +0 -111
  133. package/dist/workspace/sync.js +0 -124
  134. package/dist/workspace/wake.d.ts +0 -21
  135. package/dist/workspace/wake.js +0 -10
  136. /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
  137. /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
  138. /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
  139. /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
  140. /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
  141. /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
@@ -1,366 +1,366 @@
1
- var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
- if (value !== null && value !== void 0) {
3
- if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
- var dispose, inner;
5
- if (async) {
6
- if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
- dispose = value[Symbol.asyncDispose];
8
- }
9
- if (dispose === void 0) {
10
- if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
- dispose = value[Symbol.dispose];
12
- if (async) inner = dispose;
13
- }
14
- if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
- if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
- env.stack.push({ value: value, dispose: dispose, async: async });
17
- }
18
- else if (async) {
19
- env.stack.push({ async: true });
20
- }
21
- return value;
22
- };
23
- var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
- return function (env) {
25
- function fail(e) {
26
- env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
- env.hasError = true;
28
- }
29
- var r, s = 0;
30
- function next() {
31
- while (r = env.stack.pop()) {
32
- try {
33
- if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
- if (r.dispose) {
35
- var result = r.dispose.call(r.value);
36
- if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
- }
38
- else s |= 1;
39
- }
40
- catch (e) {
41
- fail(e);
42
- }
43
- }
44
- if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
- if (env.hasError) throw env.error;
46
- }
47
- return next();
48
- };
49
- })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
- var e = new Error(message);
51
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
- });
53
1
  import { isPlatformTransientError } from "agents";
54
- import { lostCard } from "./cards.js";
55
- import { claudeCodeSession, requireCredentials } from "./session.js";
56
- import { AttachGivenUpError, execIdFor, followUpExecIdFor } from "./run.js";
57
- /** Zeroed: the session's tokens are the CLI's context, not this conversation's. */
58
- const USAGE = {
59
- inputTokens: {
60
- total: 0,
61
- noCache: 0,
62
- cacheRead: 0,
63
- cacheWrite: 0
64
- },
65
- outputTokens: { total: 0, text: 0, reasoning: 0 }
66
- };
2
+ import { SANDBOX_CONTRACT } from "../sandbox/constants.js";
3
+ import { withRunHeader } from "../sandbox/header.js";
4
+ import { containerLostCard, lostCard } from "./cards.js";
5
+ import { decodeNote, decodeRunEnd, decodeSessionId } from "./harness-wire.js";
6
+ import { lastUserText, reportingModel } from "./language-model.js";
7
+ /**
8
+ * Three hours: a ceiling on a session that has stopped making progress, not a
9
+ * budget for one that is. No timer in the sandbox races it, since the
10
+ * container's idle stop never fires while an operation is open.
11
+ */
12
+ export const DEFAULT_TIMEOUT_MS = 3 * 60 * 60_000;
13
+ /** What a session is told when it resumes in a new container. */
14
+ export const CONTAINER_LOST_PROMPT = "The container this session was running in was lost, and the session has " +
15
+ "resumed in a new one. Its disk is as of the sandbox's last snapshot, so " +
16
+ "files written since may be gone, and nothing you started is still running. " +
17
+ "Check the checkout's state before carrying on with the task.";
18
+ /**
19
+ * Attempts after the first. A turn that kills its container every time — an
20
+ * install that runs it out of memory — would otherwise be resubmitted forever.
21
+ */
22
+ const LOST_RESUBMITS = 3;
23
+ /** Why an operation settled that a new attempt may get past. */
24
+ const LOST_REASONS = new Set(["container_lost", "container_unavailable"]);
25
+ /** Polls in a row a note may fail to file before it is given up on. */
26
+ const NOTE_TRIES = 3;
27
+ /** The sandbox's own ceiling on one poll's wait. */
28
+ const POLL_WAIT_MS = 20_000;
29
+ /**
30
+ * Failed calls in a row before one is thrown. A sandbox reset costs one call;
31
+ * an isolate this run's object has been replaced on fails every call, and is
32
+ * left to the restart's recovery of the turn.
33
+ */
34
+ const TRANSIENT_TRIES = 8;
35
+ const TRANSIENT_BACKOFF_MS = 1_000;
67
36
  export function claudeCodeModel(options) {
68
- requireCredentials(options.config);
69
37
  const { storage, runId } = options;
70
- const session = claudeCodeSession(options.config);
38
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
71
39
  const key = (name) => `claude-code:${runId}:${name}`;
72
40
  const KEYS = {
73
41
  brief: key("brief"),
74
- cursor: key("cursor"),
75
42
  session: key("session"),
76
43
  followUp: key("follow-up"),
77
44
  followUpEnd: key("follow-up-end"),
78
- report: key("report")
45
+ report: key("report"),
46
+ turn: (step) => key(`turn:${step}`),
47
+ /**
48
+ * Where a run that started in a workspace container kept its place. Such a
49
+ * run can still be resumed by a task waiting on input, and the guard below
50
+ * is what stops it being briefed again.
51
+ */
52
+ cursor: key("cursor")
79
53
  };
80
54
  const run = async (call) => {
81
- const env_1 = { stack: [], error: void 0, hasError: false };
82
- try {
83
- // The once-guarantee: a run that already reported says so again.
84
- const reported = await storage.get(KEYS.report);
85
- if (reported !== undefined)
86
- return reported;
87
- const signal = call.abortSignal;
88
- // Opened again after a cut stream — see `drain`. Whichever is current is
89
- // released when the run ends.
90
- let workspace = await options.workspace();
91
- const _release = __addDisposableResource(env_1, { [Symbol.dispose]: () => workspace[Symbol.dispose]() }, false);
92
- /** The exec being drained, which leads each note's key. */
93
- let current = "";
94
- /**
95
- * The handle already recorded, so one id is not announced twice over — and
96
- * set only once the host has taken it, so a record that failed is tried
97
- * again at the next checkpoint rather than given up on.
98
- */
99
- let announced = "";
100
- /**
101
- * Tell the host the session's handle, and never fail the run over it.
102
- *
103
- * Not on the note chain: a note is one line of a transcript, and this is the
104
- * only way back to the conversation. It is awaited where it is called so a
105
- * handle is recorded before whatever comes next can lose it.
106
- */
107
- const announce = async (record) => {
108
- const sink = options.onSession;
109
- if (!sink)
110
- return;
55
+ const reported = await storage.get(KEYS.report);
56
+ if (reported !== undefined)
57
+ return reported;
58
+ if ((await storage.get(KEYS.cursor)) !== undefined &&
59
+ (await storage.get(KEYS.turn("session"))) === undefined) {
60
+ throw new Error("claude-code: this run's session started in a workspace container, " +
61
+ "which a sandbox cannot attach to, so it ended when the deployment moved to sandboxes");
62
+ }
63
+ const signal = call.abortSignal;
64
+ /** Set only once the host has taken the handle, so a failed record is tried again. */
65
+ let announced = "";
66
+ const announce = async (record) => {
67
+ const sink = options.onSession;
68
+ if (!sink || (record.end === undefined && announced === record.sessionId))
69
+ return;
70
+ try {
71
+ await sink(record);
72
+ announced = record.sessionId;
73
+ }
74
+ catch (err) {
75
+ console.warn("[claude-code] the session's handle was not recorded", {
76
+ runId,
77
+ sessionId: record.sessionId,
78
+ err: String(err)
79
+ });
80
+ }
81
+ };
82
+ const file = async (noteKey, text, detail) => {
83
+ await options.note(noteKey, text, detail).catch((err) => console.warn("[claude-code] a note was not filed", {
84
+ runId,
85
+ key: noteKey,
86
+ err: String(err)
87
+ }));
88
+ };
89
+ /**
90
+ * One sandbox call, through a new stub after each transient failure. The
91
+ * abort ends the wait, not the call: an RPC cannot be taken back.
92
+ */
93
+ const rpc = async (fn) => {
94
+ for (let tries = 1;; tries++) {
111
95
  try {
112
- await sink(record);
113
- announced = record.sessionId;
96
+ return await untilAborted(fn(options.sandbox()), signal);
114
97
  }
115
98
  catch (err) {
116
- console.warn("[claude-code] the session's handle was not recorded", {
99
+ if (signal?.aborted ||
100
+ !isPlatformTransientError(err) ||
101
+ tries >= TRANSIENT_TRIES)
102
+ throw err;
103
+ console.warn("[claude-code] a sandbox call failed — retrying", {
117
104
  runId,
118
- sessionId: record.sessionId,
105
+ tries,
119
106
  err: String(err)
120
107
  });
108
+ await untilAborted(sleep(TRANSIENT_BACKOFF_MS * tries), signal);
121
109
  }
110
+ }
111
+ };
112
+ /** Stop the session, file its end, and end the run with `reason`. */
113
+ const stop = async (operationId, reason) => {
114
+ await options
115
+ .sandbox()
116
+ .stopRun(runId)
117
+ .catch((err) => console.warn("[claude-code] could not stop the session", {
118
+ runId,
119
+ err: String(err)
120
+ }));
121
+ const card = lostCard(reason instanceof Error ? reason.message : String(reason));
122
+ await file(`${operationId}:lost`, card.text, card.detail);
123
+ throw reason;
124
+ };
125
+ /**
126
+ * One step of the run, to its end: the brief's session, or the follow-up.
127
+ * Both count against one limit, from the session's first submission.
128
+ */
129
+ const turn = async (step, prompt, header) => {
130
+ const origin = step === "session"
131
+ ? undefined
132
+ : (await storage.get(KEYS.turn("session")))?.startedAt;
133
+ const stored = await storage.get(KEYS.turn(step));
134
+ let state = stored ?? {
135
+ attempt: 0,
136
+ startedAt: origin ?? Date.now(),
137
+ from: 0
122
138
  };
123
- /**
124
- * Each note, filed as it is parsed, then the cursor behind it. Keyed on the
125
- * exec as well as the position: the parent's transcript is one per task and
126
- * dedupes on the key, so a run's notes must not collide with another run's
127
- * — or with its own follow-up's, which counts from zero again.
128
- */
129
- const sinks = {
130
- onProgress: (note) => options.note(`${current}:${note.key}`, note.text, note.detail),
131
- // The drain offers a cursor the moment the session id appears, which is
132
- // what makes this the earliest the handle can be recorded — seconds in,
133
- // rather than at the end a killed session never reaches.
134
- onCheckpoint: async (cursor) => {
135
- await storage.put(KEYS.cursor, cursor);
136
- if (cursor.sessionId && cursor.sessionId !== announced) {
137
- await announce({ sessionId: cursor.sessionId });
138
- }
139
- },
140
- ...(signal ? { signal } : {})
139
+ const save = async (next) => {
140
+ state = next;
141
+ await storage.put(KEYS.turn(step), state);
141
142
  };
142
- /**
143
- * The session's end card, filed where the run gives up on a session it will
144
- * never read to its end: a re-attach given up on, or a cancel that stops it.
145
- * Without it the transcript shows a session that never ended, and a retry's
146
- * start reads as more of it.
147
- *
148
- * Not on any other throw: a transient one is retried by re-entering this
149
- * turn, which reads on from the stored cursor. Keyed on the exec, so a card
150
- * filed twice is filed once.
151
- */
152
- const fileLost = async (execId, reason) => {
153
- const card = lostCard(reason instanceof Error ? reason.message : String(reason));
154
- await options
155
- .note(`${execId}:lost`, card.text, card.detail)
156
- .catch((err) => console.warn("[claude-code] a lost session's end was not filed", {
157
- execId,
158
- err: String(err)
143
+ await save(state);
144
+ const resume = options.resume;
145
+ try {
146
+ await rpc((sandbox) => sandbox.createRun(runId, {
147
+ cwd: options.dir,
148
+ ...(resume
149
+ ? resume.fork
150
+ ? { forkOf: resume.run }
151
+ : { continues: resume.run }
152
+ : {})
159
153
  }));
160
- };
161
- /**
162
- * Drain one exec to its end, starting it or re-attaching from the cursor.
163
- *
164
- * **A cut stream is re-attached here, not failed.** The session is still
165
- * running, and a failed run is stopped — the one outcome that loses it. The
166
- * retry opens the workspace afresh, since the stub the stream came over may
167
- * not survive the cut. Two attempts in a row that end in a cut with no
168
- * checkpoint stored fail the run as before: a stream that breaks every time
169
- * it opens is not a blip.
170
- */
171
- const drain = async (execId, begin) => {
172
- current = execId;
173
- let outcome;
174
- let stalled = false;
175
- while (!outcome) {
176
- const cursor = await storage.get(KEYS.cursor);
154
+ }
155
+ catch (err) {
156
+ // Recovering, the last invocation's turn may still be running: an
157
+ // abort stops it. A step that never began has nothing to stop.
158
+ if (signal?.aborted && stored)
159
+ await stop(`${runId}:${step}:a${state.attempt}`, signal.reason ?? err);
160
+ throw err;
161
+ }
162
+ for (;;) {
163
+ const operationId = `${runId}:${step}:a${state.attempt}`;
164
+ // A resubmission that reached the CLI is told what happened, in the
165
+ // conversation it already had; one that never did is the step again.
166
+ const { fork: _fork, ...resumed } = header;
167
+ const text = state.attempt > 0 && state.sessionId
168
+ ? withRunHeader(resumed, CONTAINER_LOST_PROMPT)
169
+ : withRunHeader(header, prompt);
170
+ try {
171
+ await rpc((sandbox) => sandbox.submit(runId, text, operationId));
172
+ }
173
+ catch (err) {
174
+ // The submission may have landed: an abort stops what it started.
175
+ if (signal?.aborted)
176
+ await stop(operationId, signal.reason ?? err);
177
+ throw err;
178
+ }
179
+ let result;
180
+ /** Polls in a row that stopped at one unfiled note. */
181
+ let stuck = 0;
182
+ while (!result) {
183
+ if (signal?.aborted)
184
+ await stop(operationId, signal.reason ?? new Error("the session was stopped"));
185
+ if (Date.now() - state.startedAt > timeoutMs) {
186
+ await stop(operationId, new Error(`the session ran past its limit of ${Math.round(timeoutMs / 60_000)} minutes`));
187
+ }
188
+ let polled;
177
189
  try {
178
- outcome =
179
- cursor?.execId === execId
180
- ? await session.resume(workspace.runtime, cursor, sinks)
181
- : await begin(workspace.runtime);
190
+ polled = await rpc((sandbox) => sandbox.poll(runId, operationId, POLL_WAIT_MS, state.from));
182
191
  }
183
192
  catch (err) {
184
- const after = await storage.get(KEYS.cursor);
185
- const moved = after?.execId === execId &&
186
- (cursor?.execId !== execId || after.seq > cursor.seq);
187
- if (err instanceof AttachGivenUpError)
188
- await fileLost(execId, err);
189
- if (!isStreamCut(err) || signal?.aborted || (stalled && !moved))
190
- throw err;
191
- stalled = !moved;
192
- console.warn("[claude-code] the session's stream was cut — re-attaching", {
193
- execId,
194
- err: String(err)
195
- });
196
- const fresh = await options.workspace();
197
- workspace[Symbol.dispose]();
198
- workspace = fresh;
193
+ if (signal?.aborted)
194
+ continue;
195
+ throw err;
196
+ }
197
+ if (polled.contract !== SANDBOX_CONTRACT) {
198
+ throw new Error(`claude-code: the sandbox answered with contract ${polled.contract} and this run expects ${SANDBOX_CONTRACT}: ` +
199
+ "a sandbox kept busy on older code through a deploy answered it");
199
200
  }
201
+ // In order, and the position never past a note that was not filed:
202
+ // the next poll sends it again. One that never files is given up on
203
+ // after a few polls, so the session's end still arrives.
204
+ let filedTo = polled.next;
205
+ for (const [i, message] of polled.messages.entries()) {
206
+ const taken = await take(message);
207
+ if (taken.failed && stuck < NOTE_TRIES) {
208
+ stuck += 1;
209
+ filedTo = state.from + i;
210
+ break;
211
+ }
212
+ if (taken.failed) {
213
+ console.warn("[claude-code] a note was given up on", {
214
+ runId,
215
+ key: message.id
216
+ });
217
+ }
218
+ stuck = 0;
219
+ if (taken.sessionId !== undefined) {
220
+ if (taken.sessionId !== state.sessionId)
221
+ await save({ ...state, sessionId: taken.sessionId });
222
+ await announce({ sessionId: taken.sessionId });
223
+ }
224
+ }
225
+ if (filedTo !== state.from)
226
+ await save({ ...state, from: filedTo });
227
+ if (filedTo === polled.next)
228
+ result = polled.result;
200
229
  }
201
- await storage.put(KEYS.cursor, outcome.cursor);
202
- if (!outcome.done) {
203
- // Only the signal stops a drain short of the end: the turn was
204
- // cancelled, so the session goes with it.
205
- await session.stop(workspace.runtime, runId).catch((err) => console.warn("[claude-code] could not stop a cancelled session", {
206
- runId,
207
- err: String(err)
208
- }));
209
- const reason = signal?.reason ?? new Error("the session was stopped");
210
- await fileLost(execId, reason);
211
- throw reason;
230
+ if (result.status === "done") {
231
+ const end = decodeRunEnd(result.text);
232
+ if (!end?.result) {
233
+ throw new Error(`claude-code: the sandbox settled the session with no end it could read: ${String(result.text).slice(0, 200)}`);
234
+ }
235
+ // No code is a process a signal ended.
236
+ return sessionEnd(end, end.exitCode ?? -1, state.sessionId);
212
237
  }
213
- return {
214
- exitCode: outcome.exitCode,
215
- ...(outcome.result ? { result: outcome.result } : {}),
216
- ...(outcome.stderr ? { stderr: outcome.stderr } : {}),
217
- ...(outcome.rateLimit ? { rateLimit: outcome.rateLimit } : {}),
218
- ...(outcome.cursor.sessionId
219
- ? { sessionId: outcome.cursor.sessionId }
220
- : {})
221
- };
222
- };
223
- let ended = await storage.get(KEYS.session);
224
- if (!ended) {
225
- let prompt = await storage.get(KEYS.brief);
226
- if (prompt === undefined) {
227
- const task = lastUserText(call.prompt);
228
- prompt = options.brief ? await options.brief(task) : task;
229
- await storage.put(KEYS.brief, prompt);
238
+ const reason = result.reason ?? "";
239
+ const end = decodeRunEnd(reason);
240
+ if (end)
241
+ return sessionEnd(end, end.exitCode ?? 1, state.sessionId);
242
+ if (signal?.aborted)
243
+ await stop(operationId, signal.reason ?? new Error("the session was stopped"));
244
+ if (!LOST_REASONS.has(reason) || state.attempt >= LOST_RESUBMITS) {
245
+ const card = lostCard(reason || "the sandbox gave no reason");
246
+ await file(`${operationId}:lost`, card.text, card.detail);
247
+ throw new Error(`claude-code: the sandbox ended the session: ${reason || "no reason given"}`);
230
248
  }
231
- const brief = prompt;
232
- ended = await drain(execIdFor(runId), (runtime) => session.start(runtime, runId, {
233
- prompt: brief,
234
- dir: options.dir,
235
- ...(options.jsonSchema ? { jsonSchema: options.jsonSchema } : {}),
236
- ...(options.permissionMode
237
- ? { permissionMode: options.permissionMode }
238
- : {}),
239
- ...(options.resume ? { resume: options.resume } : {})
240
- }, sinks));
241
- await storage.put(KEYS.session, ended);
249
+ console.warn("[claude-code] the session's container was lost — resuming", {
250
+ runId,
251
+ operationId,
252
+ reason
253
+ });
254
+ const card = containerLostCard(reason);
255
+ await file(`${operationId}:container-lost`, card.text, card.detail);
256
+ await save({ ...state, attempt: state.attempt + 1, from: 0 });
242
257
  }
243
- // After the end is stored, so a turn that dies here does not report an end
244
- // the run has not kept — and outside the guard above, so a turn cut between
245
- // storing the end and telling the host tells it on the next attempt. Telling
246
- // it twice is harmless: its record is keyed on the id, see `SessionRecord`.
247
- if (ended.sessionId)
248
- await announce({ sessionId: ended.sessionId, end: ended });
249
- let followUpEnd = await storage.get(KEYS.followUpEnd);
250
- if (!followUpEnd && options.followUp) {
251
- let asked = await storage.get(KEYS.followUp);
252
- if (!asked) {
253
- const prompt = await options.followUp(ended);
254
- asked = prompt ? { prompt } : {};
255
- await storage.put(KEYS.followUp, asked);
256
- }
257
- // The cursor's id as well as the result's: a session that was stopped
258
- // before it reported still has a conversation worth one more turn.
259
- const sessionId = ended.result?.sessionId ?? ended.sessionId;
260
- if (asked.prompt && sessionId) {
261
- const prompt = asked.prompt;
262
- followUpEnd = await drain(followUpExecIdFor(runId), (runtime) => session.followUp(runtime, runId, {
263
- sessionId,
264
- prompt,
265
- dir: options.dir,
266
- ...(options.permissionMode
267
- ? { permissionMode: options.permissionMode }
268
- : {})
269
- }, sinks));
270
- await storage.put(KEYS.followUpEnd, followUpEnd);
271
- }
258
+ };
259
+ /** File one polled message's note, or read the session id it carries. */
260
+ const take = async (message) => {
261
+ const part = message.message.parts[0];
262
+ if (part?.type !== "text")
263
+ return {};
264
+ const sessionId = decodeSessionId(part.text);
265
+ if (sessionId !== undefined)
266
+ return { sessionId };
267
+ const note = decodeNote(part.text);
268
+ if (!note)
269
+ return {};
270
+ // Keyed on the message, which is the operation and the note's position:
271
+ // the parent's transcript dedupes on the key, so a refiled poll is free.
272
+ try {
273
+ const at = message.message.createdAt;
274
+ await options.note(message.id, note.text, note.detail, at > 0 ? at : undefined);
275
+ return {};
276
+ }
277
+ catch (err) {
278
+ console.warn("[claude-code] a note was not filed — polling it again", {
279
+ runId,
280
+ key: message.id,
281
+ err: String(err)
282
+ });
283
+ return { failed: true };
272
284
  }
273
- const text = await options.report({
274
- session: ended,
275
- ...(followUpEnd ? { followUp: followUpEnd } : {})
285
+ };
286
+ const header = {
287
+ cwd: options.dir,
288
+ ...(options.permissionMode === "plan"
289
+ ? { mode: "plan" }
290
+ : { mode: "build" }),
291
+ ...(options.schema !== undefined ? { schema: options.schema } : {}),
292
+ ...(options.effort !== undefined ? { effort: options.effort } : {}),
293
+ ...(options.model !== undefined ? { model: options.model } : {})
294
+ };
295
+ let ended = await storage.get(KEYS.session);
296
+ if (!ended) {
297
+ let prompt = await storage.get(KEYS.brief);
298
+ if (prompt === undefined) {
299
+ const task = lastUserText(call.prompt);
300
+ prompt = options.brief ? await options.brief(task) : task;
301
+ await storage.put(KEYS.brief, prompt);
302
+ }
303
+ ended = await turn("session", prompt, {
304
+ ...header,
305
+ ...(options.resume?.fork ? { fork: true } : {})
276
306
  });
277
- await storage.put(KEYS.report, text);
278
- return text;
279
- }
280
- catch (e_1) {
281
- env_1.error = e_1;
282
- env_1.hasError = true;
307
+ await storage.put(KEYS.session, ended);
283
308
  }
284
- finally {
285
- __disposeResources(env_1);
309
+ if (ended.sessionId)
310
+ await announce({ sessionId: ended.sessionId, end: ended });
311
+ let followUpEnd = await storage.get(KEYS.followUpEnd);
312
+ if (!followUpEnd && options.followUp) {
313
+ let asked = await storage.get(KEYS.followUp);
314
+ if (!asked) {
315
+ const prompt = await options.followUp(ended);
316
+ asked = prompt ? { prompt } : {};
317
+ await storage.put(KEYS.followUp, asked);
318
+ }
319
+ // A session with no conversation has nothing to follow up. Its schema
320
+ // was the session's answer, not the follow-up's — except a plan's, which
321
+ // answers to one every turn.
322
+ if (asked.prompt && (ended.result?.sessionId ?? ended.sessionId)) {
323
+ const { schema: _schema, ...unbound } = header;
324
+ const followUpHeader = header.mode === "plan" ? header : unbound;
325
+ followUpEnd = await turn("follow-up", asked.prompt, followUpHeader);
326
+ await storage.put(KEYS.followUpEnd, followUpEnd);
327
+ }
286
328
  }
329
+ const text = await options.report({
330
+ session: ended,
331
+ ...(followUpEnd ? { followUp: followUpEnd } : {})
332
+ });
333
+ await storage.put(KEYS.report, text);
334
+ return text;
287
335
  };
336
+ return reportingModel(options.model ?? "claude-code", run);
337
+ }
338
+ function sessionEnd(end, exitCode, seen) {
339
+ const sessionId = end.sessionId ?? seen;
288
340
  return {
289
- specificationVersion: "v3",
290
- provider: "claude-code",
291
- modelId: options.config.model ?? "claude-code",
292
- supportedUrls: {},
293
- async doStream(call) {
294
- // The stream is returned at once and the session runs inside it, so
295
- // nothing waits on a promise for the length of a session.
296
- const stream = new ReadableStream({
297
- async start(controller) {
298
- controller.enqueue({ type: "stream-start", warnings: [] });
299
- try {
300
- const text = await run(call);
301
- controller.enqueue({ type: "text-start", id: "report" });
302
- controller.enqueue({
303
- type: "text-delta",
304
- id: "report",
305
- delta: text
306
- });
307
- controller.enqueue({ type: "text-end", id: "report" });
308
- controller.enqueue({
309
- type: "finish",
310
- usage: USAGE,
311
- finishReason: { unified: "stop", raw: undefined }
312
- });
313
- controller.close();
314
- }
315
- catch (err) {
316
- controller.error(err);
317
- }
318
- }
319
- });
320
- return { stream };
321
- },
322
- async doGenerate(call) {
323
- const text = await run(call);
324
- return {
325
- content: [{ type: "text", text }],
326
- finishReason: { unified: "stop", raw: undefined },
327
- usage: USAGE,
328
- warnings: []
329
- };
330
- }
341
+ exitCode,
342
+ ...(end.result ? { result: end.result } : {}),
343
+ ...(end.stderr ? { stderr: end.stderr } : {}),
344
+ ...(end.rateLimit ? { rateLimit: end.rateLimit } : {}),
345
+ ...(sessionId ? { sessionId } : {})
331
346
  };
332
347
  }
333
- /**
334
- * Whether a drain failed because the transport under a live session gave way.
335
- *
336
- * An exec's events reach the sub-agent as a stream over RPC, relayed by the
337
- * workspace object from the container's own RPC session — so a drop anywhere on
338
- * that path cuts the stream while the session runs on. Measured: a container's
339
- * session dropped after 21 minutes and was attached again within a second, the
340
- * process untouched. The message is workerd's for a stream whose far side went
341
- * away, and it carries no code.
342
- */
343
- function isStreamCut(err) {
344
- if (isPlatformTransientError(err))
345
- return true;
346
- for (let e = err; e instanceof Error; e = e.cause) {
347
- if (e.message.includes("ReadableStream received over RPC disconnected"))
348
- return true;
349
- }
350
- return false;
348
+ function sleep(ms) {
349
+ return new Promise((resolve) => setTimeout(resolve, ms));
351
350
  }
352
- /**
353
- * The task: the last user message's text. On a continued turn the prompt ends
354
- * with the interrupted assistant message, so the last message is not it.
355
- */
356
- function lastUserText(prompt) {
357
- for (let i = prompt.length - 1; i >= 0; i--) {
358
- const message = prompt[i];
359
- if (message.role !== "user")
360
- continue;
361
- return message.content
362
- .map((part) => (part.type === "text" ? part.text : ""))
363
- .join("");
364
- }
365
- throw new Error("claude-code: the run has no task — no user message");
351
+ /** `promise`, or the abort's reason as soon as `signal` fires. */
352
+ function untilAborted(promise, signal) {
353
+ if (!signal)
354
+ return promise;
355
+ return new Promise((resolve, reject) => {
356
+ const onAbort = () => reject(signal.reason);
357
+ if (signal.aborted) {
358
+ onAbort();
359
+ return;
360
+ }
361
+ signal.addEventListener("abort", onAbort, { once: true });
362
+ promise
363
+ .then(resolve, reject)
364
+ .finally(() => signal.removeEventListener("abort", onAbort));
365
+ });
366
366
  }