@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.
- package/LICENSE +661 -21
- package/README.md +16 -8
- package/dist/child-host/child-host.d.ts +31 -1
- package/dist/child-host/child-host.d.ts.map +1 -1
- package/dist/child-host/child-host.js +52 -17
- package/dist/child-host/child-host.js.map +1 -1
- package/dist/child-host/command.d.ts +12 -1
- package/dist/child-host/command.d.ts.map +1 -1
- package/dist/child-host/command.js +18 -1
- package/dist/child-host/command.js.map +1 -1
- package/dist/openai-prices.d.ts +8 -0
- package/dist/openai-prices.d.ts.map +1 -0
- package/dist/openai-prices.js +28 -0
- package/dist/openai-prices.js.map +1 -0
- package/dist/pi/claude/claude.d.ts +10 -0
- package/dist/pi/claude/claude.d.ts.map +1 -0
- package/dist/pi/{claude.js → claude/claude.js} +244 -252
- package/dist/pi/claude/claude.js.map +1 -0
- package/dist/pi/claude/codec.d.ts +67 -0
- package/dist/pi/claude/codec.d.ts.map +1 -0
- package/dist/pi/claude/codec.js +140 -0
- package/dist/pi/claude/codec.js.map +1 -0
- package/dist/pi/{claude.d.ts → claude/sdk.d.ts} +9 -7
- package/dist/pi/claude/sdk.d.ts.map +1 -0
- package/dist/pi/claude/sdk.js +63 -0
- package/dist/pi/claude/sdk.js.map +1 -0
- package/dist/pi/codex/account.d.ts +8 -2
- package/dist/pi/codex/account.d.ts.map +1 -1
- package/dist/pi/codex/account.js +18 -1
- package/dist/pi/codex/account.js.map +1 -1
- package/dist/pi/codex/app-server.d.ts +1 -1
- package/dist/pi/codex/app-server.d.ts.map +1 -1
- package/dist/pi/codex/app-server.js +1 -1
- package/dist/pi/codex/app-server.js.map +1 -1
- package/dist/pi/codex/codec.d.ts +1 -97
- package/dist/pi/codex/codec.d.ts.map +1 -1
- package/dist/pi/codex/codec.js +2 -91
- package/dist/pi/codex/codec.js.map +1 -1
- package/dist/pi/codex/codex.d.ts.map +1 -1
- package/dist/pi/codex/codex.js +17 -7
- package/dist/pi/codex/codex.js.map +1 -1
- package/dist/pi/conversation.d.ts +4 -0
- package/dist/pi/conversation.d.ts.map +1 -1
- package/dist/pi/conversation.js +4 -0
- package/dist/pi/conversation.js.map +1 -1
- package/dist/pi/pi.d.ts.map +1 -1
- package/dist/pi/pi.js +5 -0
- package/dist/pi/pi.js.map +1 -1
- package/dist/pi/sdk.d.ts +8 -1
- package/dist/pi/sdk.d.ts.map +1 -1
- package/dist/pi/sdk.js +21 -13
- package/dist/pi/sdk.js.map +1 -1
- package/dist/pi/usage.d.ts +13 -13
- package/dist/pi/usage.d.ts.map +1 -1
- package/dist/pi/usage.js +40 -18
- package/dist/pi/usage.js.map +1 -1
- package/dist/preflight/command.d.ts +4 -5
- package/dist/preflight/command.d.ts.map +1 -1
- package/dist/preflight/command.js +11 -22
- package/dist/preflight/command.js.map +1 -1
- package/dist/preflight/preflight.d.ts +11 -4
- package/dist/preflight/preflight.d.ts.map +1 -1
- package/dist/preflight/preflight.js +24 -10
- package/dist/preflight/preflight.js.map +1 -1
- package/dist/protocol/codex.d.ts +122 -4
- package/dist/protocol/codex.d.ts.map +1 -1
- package/dist/protocol/codex.js +108 -0
- package/dist/protocol/codex.js.map +1 -1
- package/dist/protocol/count.d.ts +4 -0
- package/dist/protocol/count.d.ts.map +1 -0
- package/dist/protocol/count.js +4 -0
- package/dist/protocol/count.js.map +1 -0
- package/dist/runtime.d.ts +18 -8
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +12 -6
- package/dist/runtime.js.map +1 -1
- package/package.json +23 -19
- package/dist/pi/claude.d.ts.map +0 -1
- package/dist/pi/claude.js.map +0 -1
|
@@ -1,73 +1,18 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
*
|
|
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
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
134
|
+
* block other Claude launches and preflights on that login.
|
|
284
135
|
*/
|
|
285
136
|
const holdLogin = Effect.fn("ClaudeChildBackend.holdLogin")(function* (request) {
|
|
286
|
-
|
|
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
|
|
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
|
|
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
|
|
318
|
-
* the first messages decide the login state.
|
|
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
|
|
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
|
-
|
|
216
|
+
for (const message of messages)
|
|
217
|
+
input.offer(userMessage(message));
|
|
348
218
|
const read = [];
|
|
349
|
-
const login =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
418
|
-
...optionsFor(request,
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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
|
|
544
|
-
if (Option.isSome(
|
|
545
|
-
yield* channel.emit(new SessionTokensUpdated({ session: request.session, ...
|
|
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
|
-
})
|
|
588
|
-
|
|
589
|
-
|
|
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),
|