@usine/agents 0.4.0 → 0.5.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 (79) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +16 -8
  3. package/dist/child-host/child-host.d.ts +31 -1
  4. package/dist/child-host/child-host.d.ts.map +1 -1
  5. package/dist/child-host/child-host.js +52 -17
  6. package/dist/child-host/child-host.js.map +1 -1
  7. package/dist/child-host/command.d.ts +12 -1
  8. package/dist/child-host/command.d.ts.map +1 -1
  9. package/dist/child-host/command.js +18 -1
  10. package/dist/child-host/command.js.map +1 -1
  11. package/dist/openai-prices.d.ts +8 -0
  12. package/dist/openai-prices.d.ts.map +1 -0
  13. package/dist/openai-prices.js +28 -0
  14. package/dist/openai-prices.js.map +1 -0
  15. package/dist/pi/claude/claude.d.ts +10 -0
  16. package/dist/pi/claude/claude.d.ts.map +1 -0
  17. package/dist/pi/{claude.js → claude/claude.js} +244 -252
  18. package/dist/pi/claude/claude.js.map +1 -0
  19. package/dist/pi/claude/codec.d.ts +67 -0
  20. package/dist/pi/claude/codec.d.ts.map +1 -0
  21. package/dist/pi/claude/codec.js +140 -0
  22. package/dist/pi/claude/codec.js.map +1 -0
  23. package/dist/pi/{claude.d.ts → claude/sdk.d.ts} +9 -7
  24. package/dist/pi/claude/sdk.d.ts.map +1 -0
  25. package/dist/pi/claude/sdk.js +63 -0
  26. package/dist/pi/claude/sdk.js.map +1 -0
  27. package/dist/pi/codex/account.d.ts +8 -2
  28. package/dist/pi/codex/account.d.ts.map +1 -1
  29. package/dist/pi/codex/account.js +18 -1
  30. package/dist/pi/codex/account.js.map +1 -1
  31. package/dist/pi/codex/app-server.d.ts +1 -1
  32. package/dist/pi/codex/app-server.d.ts.map +1 -1
  33. package/dist/pi/codex/app-server.js +1 -1
  34. package/dist/pi/codex/app-server.js.map +1 -1
  35. package/dist/pi/codex/codec.d.ts +1 -97
  36. package/dist/pi/codex/codec.d.ts.map +1 -1
  37. package/dist/pi/codex/codec.js +2 -91
  38. package/dist/pi/codex/codec.js.map +1 -1
  39. package/dist/pi/codex/codex.d.ts.map +1 -1
  40. package/dist/pi/codex/codex.js +17 -7
  41. package/dist/pi/codex/codex.js.map +1 -1
  42. package/dist/pi/conversation.d.ts +4 -0
  43. package/dist/pi/conversation.d.ts.map +1 -1
  44. package/dist/pi/conversation.js +4 -0
  45. package/dist/pi/conversation.js.map +1 -1
  46. package/dist/pi/pi.d.ts.map +1 -1
  47. package/dist/pi/pi.js +5 -0
  48. package/dist/pi/pi.js.map +1 -1
  49. package/dist/pi/sdk.d.ts +8 -1
  50. package/dist/pi/sdk.d.ts.map +1 -1
  51. package/dist/pi/sdk.js +21 -13
  52. package/dist/pi/sdk.js.map +1 -1
  53. package/dist/pi/usage.d.ts +13 -13
  54. package/dist/pi/usage.d.ts.map +1 -1
  55. package/dist/pi/usage.js +40 -18
  56. package/dist/pi/usage.js.map +1 -1
  57. package/dist/preflight/command.d.ts +4 -5
  58. package/dist/preflight/command.d.ts.map +1 -1
  59. package/dist/preflight/command.js +11 -22
  60. package/dist/preflight/command.js.map +1 -1
  61. package/dist/preflight/preflight.d.ts +11 -4
  62. package/dist/preflight/preflight.d.ts.map +1 -1
  63. package/dist/preflight/preflight.js +24 -10
  64. package/dist/preflight/preflight.js.map +1 -1
  65. package/dist/protocol/codex.d.ts +122 -4
  66. package/dist/protocol/codex.d.ts.map +1 -1
  67. package/dist/protocol/codex.js +108 -0
  68. package/dist/protocol/codex.js.map +1 -1
  69. package/dist/protocol/count.d.ts +4 -0
  70. package/dist/protocol/count.d.ts.map +1 -0
  71. package/dist/protocol/count.js +4 -0
  72. package/dist/protocol/count.js.map +1 -0
  73. package/dist/runtime.d.ts +18 -8
  74. package/dist/runtime.d.ts.map +1 -1
  75. package/dist/runtime.js +12 -6
  76. package/dist/runtime.js.map +1 -1
  77. package/package.json +23 -19
  78. package/dist/pi/claude.d.ts.map +0 -1
  79. package/dist/pi/claude.js.map +0 -1
@@ -1,73 +1,18 @@
1
- import { query as openClaudeQuery, } from "@anthropic-ai/claude-agent-sdk";
1
+ import {} from "@anthropic-ai/claude-agent-sdk";
2
2
  import { spawn } from "node:child_process";
3
3
  import { ModelId } from "@usine/core/agent/brain";
4
- import { AgentSessionEvent, ChildSession, HarnessSession, HarnessSessionId, ProviderRecovered, ProviderRetries, ProviderRetrying, SessionStarted, SessionTokensUpdated, TextOutput, ToolFinished, ToolStarted, } from "@usine/core/agent/session";
5
- import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer, Option, Queue, Ref, Result, Schema, Scope, Stream, } from "effect";
6
- import { AgentResultError, ClaudeChildBackend, ClaudeLoginLock, ChildBackend, SessionOperationError, UnknownModelError, } from "../runtime.js";
7
- import { childPidFile, sshArguments } from "../child-host/child-host.js";
8
- import { sessionChannel } from "./session.js";
9
- import { sessionConversation } from "./conversation.js";
10
- import { claudeTokenMeter } from "./usage.js";
11
- /** The service that opens Claude Code sessions. */
12
- export class ClaudeSdk extends Context.Service()("@usine/agents/pi/ClaudeSdk") {
13
- }
14
- const decodeVendorMessage = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json));
15
- const vendorMessageJson = (message) => decodeVendorMessage(JSON.stringify(message));
16
- export const claudeSdkLive = Layer.succeed(ClaudeSdk)({
17
- open: (input, options) => {
18
- // SAFETY: ClaudeUserMessage is the streaming-input subset of SDKUserMessage.
19
- const query = openClaudeQuery({ prompt: input, options });
20
- return {
21
- close: () => query.close(),
22
- interrupt: async () => {
23
- await query.interrupt();
24
- },
25
- supportedModels: () => query.supportedModels(),
26
- async *[Symbol.asyncIterator]() {
27
- for await (const message of query)
28
- yield vendorMessageJson(message);
29
- },
30
- };
31
- },
32
- });
33
- class AsyncInput {
34
- #buffer = [];
35
- #waiters = [];
36
- #ended = false;
37
- offer(message) {
38
- if (this.#ended)
39
- throw new Error("Claude input is closed");
40
- const waiter = this.#waiters.shift();
41
- if (waiter === undefined)
42
- this.#buffer.push(message);
43
- else
44
- waiter({ done: false, value: message });
45
- }
46
- end() {
47
- if (this.#ended)
48
- return;
49
- this.#ended = true;
50
- for (const waiter of this.#waiters.splice(0))
51
- waiter({ done: true, value: undefined });
52
- }
53
- [Symbol.asyncIterator]() {
54
- return {
55
- next: () => {
56
- const value = this.#buffer.shift();
57
- if (value !== undefined)
58
- return Promise.resolve({ done: false, value });
59
- if (this.#ended)
60
- return Promise.resolve({ done: true, value: undefined });
61
- return new Promise((resolve) => this.#waiters.push(resolve));
62
- },
63
- };
64
- }
65
- }
66
- const userMessage = (content) => ({
67
- type: "user",
68
- message: { role: "user", content },
69
- parent_tool_use_id: null,
70
- });
4
+ import { HarnessSession, HarnessSessionId, ProviderRecovered, ProviderRetries, ProviderRetrying, SessionStarted, SessionTokensUpdated, ToolFinished, } from "@usine/core/agent/session";
5
+ import { Deferred, Duration, Effect, Exit, Fiber, Layer, Option, Queue, Ref, Result, Schema, Scope, Stream, } from "effect";
6
+ import { ChildProcessSpawner } from "effect/process";
7
+ import { AgentResultError, ClaudeChildBackend, ClaudeLoginLock, ChildBackend, SessionOperationError, UnknownModelError, } from "../../runtime.js";
8
+ import { CHILD_STOP_SCRIPT, childPidFile, SSH_FAILURE, sshArguments, } from "../../child-host/child-host.js";
9
+ import { hostOutput } from "../../child-host/command.js";
10
+ import { sessionChannel } from "../session.js";
11
+ import { sessionConversation } from "../conversation.js";
12
+ import { claudeSessionMeter, claudeTokenMeter } from "../usage.js";
13
+ import { apiRetryMessage, ClaudeApiRetry, ClaudeCompletionHeader, ClaudeResultMessage, decodeApiRetry, decodeClaudeInit, decodeCompletionUpdate, isAssistantMessage, loginCheck, sessionEventsFrom, } from "./codec.js";
14
+ import { AsyncInput, ClaudeSdk, userMessage, } from "./sdk.js";
15
+ export { ClaudeSdk, claudeSdkLive, } from "./sdk.js";
71
16
  const operationError = (operation, request, message, cause) => new SessionOperationError({
72
17
  operation,
73
18
  sessionId: Option.some(request.session.id),
@@ -77,9 +22,9 @@ const operationError = (operation, request, message, cause) => new SessionOperat
77
22
  /**
78
23
  * Returns a spawner that runs Claude Code on the remote host as one SSH command, with the SDK's
79
24
  * command, arguments, working directory, and environment in the remote command line. The abort
80
- * signal stops the SSH client.
25
+ * signal stops the SSH client; `onExit` gets its exit code, or `null` if a signal stopped it.
81
26
  */
82
- const sshSpawner = (host, pidFile, onStderr) => (options) => {
27
+ const sshSpawner = (host, pidFile, onStderr, onExit) => (options) => {
83
28
  const child = spawn(host.ssh.command, sshArguments(host, {
84
29
  cwd: options.cwd,
85
30
  environment: options.env,
@@ -93,9 +38,11 @@ const sshSpawner = (host, pidFile, onStderr) => (options) => {
93
38
  windowsHide: true,
94
39
  });
95
40
  child.stderr.setEncoding("utf8").on("data", onStderr);
41
+ child.once("exit", (code) => onExit(code));
96
42
  return child;
97
43
  };
98
- const optionsFor = (request, onStderr) => {
44
+ /** Returns the options of every launch for `request`; each launch adds its remote spawner. */
45
+ const optionsFor = (request) => {
99
46
  const effort = request.session.brain.effort;
100
47
  const shared = {
101
48
  allowDangerouslySkipPermissions: true,
@@ -110,10 +57,6 @@ const optionsFor = (request, onStderr) => {
110
57
  // The worktree is untrusted, so its project and local settings (hooks, env) are not loaded into
111
58
  // a child that has the App credential; only the Worker user's settings apply.
112
59
  settingSources: ["user"],
113
- // On a local host, the SDK spawns the process itself.
114
- ...(request.host._tag === "RemoteShell" && {
115
- spawnClaudeCodeProcess: sshSpawner(request.host, childPidFile(request.cwd, request.session.id), onStderr),
116
- }),
117
60
  };
118
61
  return effort === "off"
119
62
  ? { ...shared, thinking: { type: "disabled" } }
@@ -124,81 +67,6 @@ const optionsFor = (request, onStderr) => {
124
67
  };
125
68
  };
126
69
  const modelExists = (models, model) => models.some((candidate) => candidate.value === model || candidate.resolvedModel === model);
127
- const decodeClaudeInit = Schema.decodeUnknownOption(Schema.Struct({
128
- type: Schema.Literal("system"),
129
- subtype: Schema.Literal("init"),
130
- session_id: HarnessSessionId,
131
- }));
132
- const ClaudeTextBlock = Schema.Struct({
133
- type: Schema.Literal("text"),
134
- text: Schema.NonEmptyString,
135
- });
136
- const ClaudeContentMessage = Schema.Struct({
137
- type: Schema.Literals(["assistant", "user"]),
138
- message: Schema.Struct({ content: Schema.Array(Schema.Json) }),
139
- });
140
- const ClaudeToolUseBlock = Schema.Struct({
141
- type: Schema.Literal("tool_use"),
142
- id: Schema.NonEmptyString,
143
- name: Schema.NonEmptyString,
144
- input: Schema.Json,
145
- });
146
- const ClaudeToolResultBlock = Schema.Struct({
147
- type: Schema.Literal("tool_result"),
148
- tool_use_id: Schema.NonEmptyString,
149
- content: Schema.Json,
150
- is_error: Schema.optionalKey(Schema.Boolean),
151
- });
152
- const ClaudeResultMessage = Schema.Struct({
153
- type: Schema.Literal("result"),
154
- subtype: Schema.String,
155
- result: Schema.optionalKey(Schema.String),
156
- errors: Schema.optionalKey(Schema.Array(Schema.String)),
157
- queued_turn_count: Schema.optionalKey(Schema.Number),
158
- });
159
- const ClaudeBackgroundTasks = Schema.Struct({
160
- type: Schema.Literal("system"),
161
- subtype: Schema.Literal("background_tasks_changed"),
162
- tasks: Schema.Array(Schema.Struct({
163
- task_id: Schema.NonEmptyString,
164
- ambient: Schema.optionalKey(Schema.Boolean),
165
- })),
166
- });
167
- const ClaudeSessionState = Schema.Struct({
168
- type: Schema.Literal("system"),
169
- subtype: Schema.Literal("session_state_changed"),
170
- state: Schema.Literals(["idle", "running", "requires_action"]),
171
- });
172
- const ClaudeCompletionHeader = Schema.Struct({
173
- type: Schema.Literal("system"),
174
- subtype: Schema.Literals(["background_tasks_changed", "session_state_changed"]),
175
- });
176
- const decodeCompletionUpdate = Schema.decodeUnknownEffect(Schema.Union([ClaudeBackgroundTasks, ClaudeSessionState]));
177
- /** Claude Code's report of a failed API request that it retries; a `null` status means no response was received. */
178
- const ClaudeApiRetry = Schema.Struct({
179
- type: Schema.Literal("system"),
180
- subtype: Schema.Literal("api_retry"),
181
- attempt: Schema.Number,
182
- max_retries: Schema.Number,
183
- retry_delay_ms: Schema.Number,
184
- error_status: Schema.NullOr(Schema.Number),
185
- error: Schema.String,
186
- });
187
- const decodeApiRetry = Schema.decodeUnknownOption(ClaudeApiRetry);
188
- const isAssistantMessage = Schema.is(Schema.Struct({ type: Schema.Literal("assistant") }));
189
- /** Returns an API retry's fields as one sentence, because Claude Code includes no message. */
190
- const apiRetryMessage = (retry) => {
191
- const failure = retry.error_status === null ? "without a response" : `with HTTP ${retry.error_status}`;
192
- const delay = retry.retry_delay_ms < 1_000
193
- ? `${retry.retry_delay_ms}ms`
194
- : `${Math.round(retry.retry_delay_ms / 1_000)}s`;
195
- return `API request failed ${failure} (${retry.error}); retry ${retry.attempt} of ${retry.max_retries} in ${delay}`;
196
- };
197
- /**
198
- * The text Claude Code ends a turn with if another Claude process has the login refresh lock, or
199
- * exited while it had the lock.
200
- */
201
- const LOGIN_CONTENDED = "Failed to refresh OAuth token: another Claude Code process";
202
70
  /**
203
71
  * The delays before each relaunch after a Claude process loses the login refresh lock; Claude's
204
72
  * error says to retry in a minute. If the last launch fails the same way, the parent gets the error
@@ -210,91 +78,76 @@ const LOGIN_RETRY_DELAYS = [
210
78
  Duration.seconds(60),
211
79
  ];
212
80
  /**
213
- * The maximum time a launch keeps the Worker's Claude login lock while waiting for a message that
214
- * decides the login state. If no such message arrives, the launch continues as a started session.
81
+ * The maximum time a launch keeps its Claude login lock while waiting for a message that decides
82
+ * the login state. If no such message arrives, the launch continues as a started session.
215
83
  */
216
84
  const LOGIN_WINDOW = Duration.seconds(60);
217
- /** Messages that arrive only after an API request was sent with a working login. */
218
- const ClaudeLoginEvidence = Schema.Union([
219
- Schema.Struct({ type: Schema.Literals(["rate_limit_event", "stream_event"]) }),
220
- Schema.Struct({
221
- type: Schema.Literal("system"),
222
- subtype: Schema.Literals(["api_retry", "thinking_tokens"]),
223
- }),
224
- ]);
225
- const mentionsLoginContention = (text) => text.includes(LOGIN_CONTENDED);
226
85
  /**
227
- * Returns the login state shown by one of a launch's first messages. `init` and other session
228
- * notices can arrive before the first API request, where Claude refreshes an expired login, so they
229
- * return `waiting`. A model answer, rate-limit or thinking update, API retry, or result returns
230
- * `loggedIn`, except a result with the refresh lock error and no tokens used, which returns
231
- * `contended`. The synthetic answer with the same error returns `waiting` until that result.
86
+ * The delays before each try to resume a remote child whose SSH connection broke, such as when the
87
+ * orchestrator's machine slept. A message from Claude resets the count. When the delays run out,
88
+ * the child fails with the broken connection's error (#603).
232
89
  */
233
- const loginCheck = (message, spentTokens) => {
234
- if (Schema.is(ClaudeResultMessage)(message)) {
235
- const reported = [
236
- ...(message.result === undefined ? [] : [message.result]),
237
- ...(message.errors ?? []),
238
- ];
239
- return !spentTokens && reported.some(mentionsLoginContention) ? "contended" : "loggedIn";
240
- }
241
- if (Schema.is(ClaudeContentMessage)(message) && message.type === "assistant") {
242
- const contended = message.message.content.some((block) => Schema.is(ClaudeTextBlock)(block) && mentionsLoginContention(block.text));
243
- return contended && !spentTokens ? "waiting" : "loggedIn";
244
- }
245
- return Schema.is(ClaudeLoginEvidence)(message) ? "loggedIn" : "waiting";
246
- };
247
- const sessionEventsFrom = (message, session, tools) => {
248
- if (!Schema.is(ClaudeContentMessage)(message))
249
- return [];
250
- const events = [];
251
- for (const block of message.message.content) {
252
- // Emits only text blocks of completed assistant messages; streaming deltas are not recorded.
253
- if (message.type === "assistant" && Schema.is(ClaudeTextBlock)(block)) {
254
- events.push(new TextOutput({ session, text: block.text }));
255
- }
256
- else if (Schema.is(ClaudeToolUseBlock)(block)) {
257
- tools.set(block.id, block.name);
258
- events.push(new ToolStarted({
259
- session,
260
- callId: block.id,
261
- tool: block.name,
262
- input: block.input,
263
- }));
264
- }
265
- else if (Schema.is(ClaudeToolResultBlock)(block)) {
266
- events.push(new ToolFinished({
267
- session,
268
- callId: block.tool_use_id,
269
- tool: tools.get(block.tool_use_id) ?? "unknown",
270
- output: block.content,
271
- isError: block.is_error === true,
272
- }));
273
- }
274
- }
275
- return events;
276
- };
90
+ const RESUME_DELAYS = [
91
+ Duration.seconds(5),
92
+ Duration.seconds(15),
93
+ Duration.seconds(30),
94
+ Duration.minutes(1),
95
+ Duration.minutes(2),
96
+ Duration.minutes(5),
97
+ ];
98
+ /** The time a broken launch waits for its SSH client's exit code before it gives up resuming. */
99
+ const SSH_EXIT_WAIT = Duration.seconds(5);
100
+ /** The time limit for stopping a broken launch's Claude process on the Worker. */
101
+ const STOP_TIMEOUT = Duration.seconds(30);
102
+ /** The message that starts the next turn of a resumed child that was working. */
103
+ const RESUME_PROMPT = "usine lost its connection to this session and resumed it. Your last step may not have finished: check the worktree and any command you started before you go on. Then continue the task and end with your final answer. If the task is done, give your final answer again.";
277
104
  const makeClaudeChild = Effect.gen(function* () {
278
105
  const sdk = yield* ClaudeSdk;
279
106
  const logins = yield* ClaudeLoginLock;
107
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
108
+ /**
109
+ * Stops the Claude process of `request` on the remote `host` through its PID file, so that a
110
+ * resumed launch is the only process in the session. Fails with `SessionOperationError` if the
111
+ * Worker can't be reached or the stop fails.
112
+ */
113
+ const stopRemote = Effect.fn("ClaudeChildBackend.stopRemote")(function* (request, host) {
114
+ yield* Effect.annotateCurrentSpan({ sessionId: request.session.id, worker: request.worker });
115
+ const failed = (message, cause) => operationError("launch", request, message, cause);
116
+ const { stdout, exitCode } = yield* hostOutput(host, {
117
+ environment: {},
118
+ command: "sh",
119
+ args: ["-c", CHILD_STOP_SCRIPT, "sh", childPidFile(request.cwd, request.session.id)],
120
+ }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.timeout(STOP_TIMEOUT), Effect.mapError((cause) => failed(`Can't stop the Claude child on ${host.destination}`, cause)));
121
+ if (exitCode !== 0)
122
+ return yield* failed(exitCode === SSH_FAILURE
123
+ ? `Can't reach ${host.destination} to stop the Claude child`
124
+ : `Stopping the Claude child on ${host.destination} exited with code ${exitCode}`, new Error(`exit code ${exitCode}`));
125
+ yield* Effect.logInfo("Stopped what was left of the Claude child on its Worker", {
126
+ sessionId: request.session.id,
127
+ worker: request.worker,
128
+ outcome: stdout,
129
+ });
130
+ });
280
131
  /**
281
- * Acquires the request Worker's login lock in a fiber of the current scope and returns an effect
132
+ * Acquires the request's Claude login lock in a fiber of the current scope and returns an effect
282
133
  * that releases it. The lock is also released after `LOGIN_WINDOW`, so a hung launch does not
283
- * block other Claude launches and preflights on the Worker.
134
+ * block other Claude launches and preflights on that login.
284
135
  */
285
136
  const holdLogin = Effect.fn("ClaudeChildBackend.holdLogin")(function* (request) {
286
- yield* Effect.annotateCurrentSpan({ sessionId: request.session.id, worker: request.worker });
137
+ const fields = {
138
+ sessionId: request.session.id,
139
+ worker: request.worker,
140
+ ...Option.match(request.machine, { onNone: () => ({}), onSome: (machine) => ({ machine }) }),
141
+ };
142
+ yield* Effect.annotateCurrentSpan(fields);
287
143
  const held = yield* Deferred.make();
288
144
  const released = yield* Deferred.make();
289
- yield* Effect.forkScoped(logins.withLock(request.worker)(Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(released)), Effect.timeoutOption(LOGIN_WINDOW), Effect.flatMap(Option.match({
145
+ yield* Effect.forkScoped(logins.withLock(request.worker, request.machine)(Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(released)), Effect.timeoutOption(LOGIN_WINDOW), Effect.flatMap(Option.match({
290
146
  onSome: () => Effect.void,
291
- onNone: () => Effect.logWarning("Claude child showed no sign of its login in time; releasing the Worker's Claude login lock", { sessionId: request.session.id, worker: request.worker }),
147
+ onNone: () => Effect.logWarning("Claude child showed no sign of its login in time; releasing its Claude login lock", fields),
292
148
  })))));
293
149
  yield* Deferred.await(held);
294
- yield* Effect.logDebug("Holding the Worker's Claude login lock to launch the child", {
295
- sessionId: request.session.id,
296
- worker: request.worker,
297
- });
150
+ yield* Effect.logDebug("Holding the Claude login lock to launch the child", fields);
298
151
  return Deferred.succeed(released, undefined).pipe(Effect.asVoid);
299
152
  });
300
153
  /** Reads messages from `output` into `read` until one decides the login state. */
@@ -314,15 +167,31 @@ const makeClaudeChild = Effect.gen(function* () {
314
167
  }
315
168
  });
316
169
  /**
317
- * Launches Claude Code once for `request` and sends the task, keeping the Worker's login lock until
318
- * the first messages decide the login state. Resources are added to the current scope.
170
+ * Launches Claude Code once for `request` and sends `messages`, keeping its Claude login lock
171
+ * until the first messages decide the login state. With no messages, the launch waits for input
172
+ * and skips the login check. Resources are added to the current scope.
319
173
  */
320
- const launchOnce = Effect.fn("ClaudeChildBackend.launchOnce")(function* (request, options) {
174
+ const launchOnce = Effect.fn("ClaudeChildBackend.launchOnce")(function* (request, options, messages) {
321
175
  yield* Effect.annotateCurrentSpan({ sessionId: request.session.id, worker: request.worker });
176
+ const context = yield* Effect.context();
322
177
  const release = yield* holdLogin(request);
323
178
  return yield* Effect.gen(function* () {
324
179
  const input = new AsyncInput();
325
- const query = sdk.open(input, options);
180
+ const sshExit = yield* Deferred.make();
181
+ const query = sdk.open(input, request.host._tag === "RemoteShell"
182
+ ? {
183
+ ...options,
184
+ spawnClaudeCodeProcess: sshSpawner(request.host, childPidFile(request.cwd, request.session.id), (text) => {
185
+ Effect.runForkWith(context)(Effect.logDebug("Claude child stderr", {
186
+ sessionId: request.session.id,
187
+ text: text.trimEnd(),
188
+ }));
189
+ }, (code) => {
190
+ Deferred.doneUnsafe(sshExit, Exit.succeed(code));
191
+ }),
192
+ }
193
+ : // On a local host, the SDK spawns the process itself.
194
+ options);
326
195
  const output = yield* Queue.make();
327
196
  yield* Effect.forkScoped(Stream.fromAsyncIterable(query, (cause) => operationError("stream", request, "Claude output stream failed", cause)).pipe(Stream.runIntoQueue(output)));
328
197
  // Finalizers run newest first, so this closes the query before the scope stops the output
@@ -344,39 +213,50 @@ const makeClaudeChild = Effect.gen(function* () {
344
213
  message: `Claude has no model "${request.session.brain.model}"`,
345
214
  });
346
215
  }
347
- input.offer(userMessage(request.prompt));
216
+ for (const message of messages)
217
+ input.offer(userMessage(message));
348
218
  const read = [];
349
- const login = Option.getOrElse(yield* checkLogin(output, read).pipe(Effect.timeoutOption(LOGIN_WINDOW)), () => "undecided");
219
+ const login = messages.length === 0
220
+ ? "undecided"
221
+ : Option.getOrElse(yield* checkLogin(output, read).pipe(Effect.timeoutOption(LOGIN_WINDOW)), () => "undecided");
350
222
  yield* Effect.logDebug("Claude child's first messages settled its login check", {
351
223
  sessionId: request.session.id,
352
224
  worker: request.worker,
353
225
  login,
354
226
  messages: read.length,
355
227
  });
356
- return { input, query, read, output, login };
228
+ const lostConnection = request.host._tag === "RemoteShell"
229
+ ? Deferred.await(sshExit).pipe(Effect.timeoutOption(SSH_EXIT_WAIT), Effect.map((code) => Option.contains(code, SSH_FAILURE)))
230
+ : Effect.succeed(false);
231
+ return { input, query, read, output, login, lostConnection };
357
232
  }).pipe(Effect.ensuring(release));
358
233
  });
359
234
  /**
360
235
  * Launches Claude Code for `request`, relaunching after each delay in `LOGIN_RETRY_DELAYS` while it
361
236
  * fails on the login refresh lock. Each launch has its own scope inside `scope`, closed before the
362
- * next, so a failed launch's process and messages do not reach the session.
237
+ * next or when the launch fails, so a failed launch's process and messages do not reach the
238
+ * session. Returns the launch with its scope.
363
239
  */
364
- const launchWithRetries = Effect.fn("ClaudeChildBackend.launchWithRetries")(function* (request, options, scope) {
240
+ const launchWithRetries = Effect.fn("ClaudeChildBackend.launchWithRetries")(function* (request, options, messages, scope) {
365
241
  yield* Effect.annotateCurrentSpan({ sessionId: request.session.id, worker: request.worker });
366
242
  for (let retry = 0;; retry += 1) {
367
243
  const launchScope = yield* Scope.fork(scope);
368
- const opened = yield* launchOnce(request, options).pipe(Scope.provide(launchScope));
244
+ const opened = {
245
+ ...(yield* launchOnce(request, options, messages).pipe(Scope.provide(launchScope), Effect.onError((cause) => Scope.close(launchScope, Exit.failCause(cause))))),
246
+ scope: launchScope,
247
+ };
369
248
  if (opened.login !== "contended") {
370
249
  if (opened.login === "undecided")
371
250
  yield* Effect.logInfo("Claude child started without showing whether its login worked", {
372
251
  sessionId: request.session.id,
373
252
  worker: request.worker,
253
+ messages: messages.length,
374
254
  });
375
255
  return opened;
376
256
  }
377
257
  const delay = LOGIN_RETRY_DELAYS[retry];
378
258
  if (delay === undefined) {
379
- yield* Effect.logWarning("Claude child still could not refresh the Worker's Claude login; returning the error to the parent", {
259
+ yield* Effect.logWarning("Claude child still could not refresh its Claude login; returning the error to the parent", {
380
260
  sessionId: request.session.id,
381
261
  parentSessionId: request.session.parentId,
382
262
  worker: request.worker,
@@ -385,7 +265,7 @@ const makeClaudeChild = Effect.gen(function* () {
385
265
  return opened;
386
266
  }
387
267
  yield* Scope.close(launchScope, Exit.void);
388
- yield* Effect.logInfo("Claude child could not refresh the Worker's Claude login; launching it again", {
268
+ yield* Effect.logInfo("Claude child could not refresh its Claude login; launching it again", {
389
269
  sessionId: request.session.id,
390
270
  parentSessionId: request.session.parentId,
391
271
  worker: request.worker,
@@ -414,31 +294,28 @@ const makeClaudeChild = Effect.gen(function* () {
414
294
  await Effect.runPromiseWith(context)(Effect.acquireUseRelease(Effect.forkIn(Effect.suspend(() => boundary), scope), Fiber.join, Fiber.interrupt), { signal: options.signal });
415
295
  return {};
416
296
  };
417
- const { input, query, read, output } = yield* launchWithRetries(request, {
418
- ...optionsFor(request, (text) => {
419
- Effect.runForkWith(context)(Effect.logDebug("Claude child stderr", {
420
- sessionId: request.session.id,
421
- text: text.trimEnd(),
422
- }));
423
- }),
297
+ const options = {
298
+ ...optionsFor(request),
424
299
  hooks: {
425
300
  PreToolUse: [{ hooks: [pauseAtBoundary], timeout: 86_400 }],
426
301
  PostToolUse: [{ hooks: [pauseAtBoundary], timeout: 86_400 }],
427
302
  },
428
- }, scope);
303
+ };
304
+ // The launch that runs the session; a resume replaces it.
305
+ let current = yield* launchWithRetries(request, options, [request.prompt], scope);
429
306
  const channel = yield* sessionChannel({
430
307
  label: "Claude child session",
431
308
  sessionId: request.session.id,
432
309
  });
433
310
  channel.onTerminal(() => {
434
- input.end();
311
+ current.input.end();
435
312
  });
436
313
  const conversation = yield* sessionConversation({
437
314
  session: request.session,
438
315
  channel,
439
316
  // A message sent during a turn is queued until a tool call returns or the turn ends.
440
317
  send: (message, activity) => Effect.try({
441
- try: () => input.offer(userMessage(message)),
318
+ try: () => current.input.offer(userMessage(message)),
442
319
  catch: (cause) => operationError("steer", request, "Can't send a message to Claude", cause),
443
320
  }).pipe(Effect.as(activity === "idle" ? "read" : "queued")),
444
321
  pause: Effect.void,
@@ -446,7 +323,11 @@ const makeClaudeChild = Effect.gen(function* () {
446
323
  });
447
324
  boundary = conversation.awaitUnpaused;
448
325
  const tools = new Map();
449
- const tokens = claudeTokenMeter();
326
+ const usage = claudeSessionMeter();
327
+ // The latest session ID from Claude, which a resume loads.
328
+ let harnessSessionId = Option.none();
329
+ // Resumes since Claude last sent a message.
330
+ let resumes = 0;
450
331
  let backgroundTasks = [];
451
332
  let reportsSessionState = false;
452
333
  let completedTurn = Option.none();
@@ -485,7 +366,8 @@ const makeClaudeChild = Effect.gen(function* () {
485
366
  retries: ProviderRetries.make(retries),
486
367
  }));
487
368
  });
488
- const consume = Stream.concat(Stream.fromIterable(read), Stream.fromQueue(output)).pipe(Stream.runForEach((message) => Effect.gen(function* () {
369
+ /** Applies one message from Claude to the session. */
370
+ const handle = (message) => Effect.gen(function* () {
489
371
  const retry = decodeApiRetry(message);
490
372
  if (Option.isSome(retry))
491
373
  return yield* reportRetry(retry.value);
@@ -524,6 +406,8 @@ const makeClaudeChild = Effect.gen(function* () {
524
406
  return;
525
407
  }
526
408
  const init = decodeClaudeInit(message);
409
+ if (Option.isSome(init))
410
+ harnessSessionId = Option.some(init.value.session_id);
527
411
  if (Option.isSome(init) && !(yield* Ref.getAndSet(launched, true))) {
528
412
  const harnessSession = new HarnessSession({
529
413
  id: init.value.session_id,
@@ -540,9 +424,9 @@ const makeClaudeChild = Effect.gen(function* () {
540
424
  cwd: harnessSession.cwd,
541
425
  });
542
426
  }
543
- const usage = tokens(message);
544
- if (Option.isSome(usage))
545
- yield* channel.emit(new SessionTokensUpdated({ session: request.session, ...usage.value }));
427
+ const used = usage.read(message);
428
+ if (Option.isSome(used))
429
+ yield* channel.emit(new SessionTokensUpdated({ session: request.session, ...used.value }));
546
430
  for (const event of sessionEventsFrom(message, request.session, tools)) {
547
431
  yield* channel.emit(event);
548
432
  }
@@ -584,22 +468,130 @@ const makeClaudeChild = Effect.gen(function* () {
584
468
  // Older Claude Code versions on Workers do not send session-state messages.
585
469
  yield* conversation.finish(answer);
586
470
  }
587
- })), Effect.flatMap(() => channel.isTerminal.pipe(Effect.flatMap((done) => done
588
- ? Effect.void
589
- : channel.fail(operationError("stream", request, "Claude output closed without a result", new Error("unexpected end of output")))))), Effect.catch((error) => channel.fail(error)));
471
+ });
472
+ /**
473
+ * Applies `launch`'s messages until its output ends. Returns the error that broke the output,
474
+ * or `None` if the output closed.
475
+ */
476
+ const drain = (launch) => Effect.gen(function* () {
477
+ let broken = Option.none();
478
+ yield* Stream.concat(Stream.fromIterable(launch.read), Stream.fromQueue(launch.output)).pipe(Stream.map((message) => Result.succeed(message)), Stream.catch((error) => Stream.make(Result.fail(error))), Stream.runForEach(Result.match({
479
+ onSuccess: (message) => Effect.suspend(() => {
480
+ resumes = 0;
481
+ return handle(message);
482
+ }),
483
+ onFailure: (error) => Effect.sync(() => {
484
+ broken = Option.some(error);
485
+ }),
486
+ })));
487
+ return broken;
488
+ });
489
+ /**
490
+ * Resumes the session in a new launch after `broken` lost its SSH connection to the Worker:
491
+ * stops what is left of its Claude process there, then launches Claude with the saved session,
492
+ * retrying after each delay in `RESUME_DELAYS`. Returns `None` if the session can't be resumed.
493
+ */
494
+ const resume = Effect.fn("ClaudeChildBackend.resume")(function* (broken, error) {
495
+ yield* Effect.annotateCurrentSpan({
496
+ sessionId: request.session.id,
497
+ worker: request.worker,
498
+ });
499
+ const fields = {
500
+ sessionId: request.session.id,
501
+ parentSessionId: request.session.parentId,
502
+ worker: request.worker,
503
+ };
504
+ const notResumed = (reason) => Effect.logInfo("Not resuming the Claude child after its output ended", {
505
+ ...fields,
506
+ reason,
507
+ message: error.message,
508
+ }).pipe(Effect.as(Option.none()));
509
+ const host = request.host;
510
+ const harnessSession = harnessSessionId;
511
+ if (host._tag !== "RemoteShell")
512
+ return yield* notResumed("it runs on this machine");
513
+ if (Option.isNone(harnessSession))
514
+ return yield* notResumed("Claude saved no session");
515
+ if (!(yield* broken.lostConnection))
516
+ return yield* notResumed("its SSH connection didn't break");
517
+ yield* Effect.logWarning("Claude child lost its SSH connection to the Worker; resuming its session", { ...fields, harnessSessionId: harnessSession.value, message: error.message });
518
+ yield* Scope.close(broken.scope, Exit.void);
519
+ // The broken launch's process took its background work and open tool calls with it.
520
+ backgroundTasks = [];
521
+ completedTurn = Option.none();
522
+ for (const [callId, tool] of tools)
523
+ yield* channel.emit(new ToolFinished({
524
+ session: request.session,
525
+ callId,
526
+ tool,
527
+ output: "usine lost its connection to the Worker before this call returned",
528
+ isError: true,
529
+ }));
530
+ tools.clear();
531
+ usage.restart();
532
+ for (;;) {
533
+ const delay = RESUME_DELAYS[resumes];
534
+ if (delay === undefined) {
535
+ yield* Effect.logWarning("Could not resume the Claude child; it fails", {
536
+ ...fields,
537
+ tries: resumes,
538
+ });
539
+ return Option.none();
540
+ }
541
+ resumes += 1;
542
+ yield* Effect.logInfo("Resuming the Claude child after a pause", {
543
+ ...fields,
544
+ try: resumes,
545
+ maxTries: RESUME_DELAYS.length,
546
+ delay: Duration.format(delay),
547
+ });
548
+ yield* Effect.sleep(delay);
549
+ if (yield* channel.isTerminal)
550
+ return Option.none();
551
+ // A paused or idle child waits for the operator, so only a working child gets a turn.
552
+ const working = !((yield* conversation.isPaused) || (yield* conversation.isIdle));
553
+ const messages = [...(working ? [RESUME_PROMPT] : []), ...(yield* conversation.unread)];
554
+ const resumed = yield* stopRemote(request, host).pipe(Effect.andThen(launchWithRetries(request, { ...options, resume: harnessSession.value }, messages, scope)), Effect.map(Option.some), Effect.catchTag("SessionOperationError", (failure) => Effect.logWarning("Could not resume the Claude child yet", {
555
+ ...fields,
556
+ try: resumes,
557
+ message: failure.message,
558
+ }).pipe(Effect.as(Option.none()))));
559
+ if (Option.isSome(resumed)) {
560
+ yield* Effect.logInfo("Resumed the Claude child's session", {
561
+ ...fields,
562
+ harnessSessionId: harnessSession.value,
563
+ working,
564
+ messages: messages.length,
565
+ });
566
+ return resumed;
567
+ }
568
+ }
569
+ });
570
+ const consume = Effect.gen(function* () {
571
+ for (;;) {
572
+ const broken = yield* drain(current);
573
+ if (yield* channel.isTerminal)
574
+ return;
575
+ const error = Option.getOrElse(broken, () => operationError("stream", request, "Claude output closed without a result", new Error("unexpected end of output")));
576
+ const resumed = yield* resume(current, error);
577
+ if (Option.isNone(resumed))
578
+ return yield* channel.fail(error);
579
+ current = resumed.value;
580
+ }
581
+ }).pipe(Effect.catch((error) => channel.fail(error)));
590
582
  yield* Effect.forkScoped(consume);
591
583
  const steer = Effect.fn("ClaudeChildBackend.steer")((message) => Effect.logDebug("Steering Claude child session", {
592
584
  sessionId: request.session.id,
593
585
  }).pipe(Effect.andThen(Effect.try({
594
- try: () => input.offer(userMessage(message)),
586
+ try: () => current.input.offer(userMessage(message)),
595
587
  catch: (cause) => operationError("steer", request, "Can't steer Claude", cause),
596
588
  }))));
597
589
  const interrupt = Effect.logInfo("Interrupting Claude child session", {
598
590
  sessionId: request.session.id,
599
591
  }).pipe(Effect.andThen(Effect.tryPromise({
600
- try: () => query.interrupt(),
592
+ try: () => current.query.interrupt(),
601
593
  catch: (cause) => operationError("interrupt", request, "Can't interrupt Claude", cause),
602
- })), Effect.tap(() => Effect.sync(() => input.end())), Effect.asVoid);
594
+ })), Effect.tap(() => Effect.sync(() => current.input.end())), Effect.asVoid);
603
595
  return {
604
596
  session: request.session,
605
597
  events: Stream.fromQueue(channel.events),