@khorsheed/dsh-local-agent-claude-code 0.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +120 -0
- package/README.i18n.yaml +6 -0
- package/README.md +120 -0
- package/cordis.patch.yml +42 -0
- package/lib/client.js +1065 -0
- package/lib/index.js +3771 -0
- package/lib/invariant.js +31 -0
- package/lib/types/claude-cli-provider.d.ts +389 -0
- package/lib/types/claude-cli-provider.js +1509 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +198 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/index.js +87 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/control-requests.d.ts +15 -0
- package/lib/types/control-requests.js +62 -0
- package/lib/types/index.d.ts +81 -0
- package/lib/types/index.js +300 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/live-driver.d.ts +172 -0
- package/lib/types/live-driver.js +1057 -0
- package/lib/types/live-switch.d.ts +67 -0
- package/lib/types/live-switch.js +85 -0
- package/lib/types/model-broker.d.ts +131 -0
- package/lib/types/model-broker.js +248 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +118 -0
- package/lib/types/provision.d.ts +63 -0
- package/lib/types/provision.js +132 -0
- package/lib/types/records.d.ts +120 -0
- package/lib/types/records.js +687 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +106 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3771 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, modelDirectoryContextKey, persistChildSession, probeCliVersion, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol } from "@khorsheed/dsh-local-agent";
|
|
3
|
+
import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV, endpointHost } from "@khorsheed/dsh-local-agent/types";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
6
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
7
|
+
import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { StringDecoder } from "node:string_decoder";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
//#region lib/types/control-requests.js
|
|
14
|
+
/** Bounded, correlated Claude stream-json controls, shared by discovery and live sessions. */
|
|
15
|
+
var ClaudeControlRequests = class {
|
|
16
|
+
send;
|
|
17
|
+
timeoutMs;
|
|
18
|
+
sequence = 0;
|
|
19
|
+
closed = false;
|
|
20
|
+
pending = /* @__PURE__ */ new Map();
|
|
21
|
+
constructor(send, timeoutMs = 5e3) {
|
|
22
|
+
this.send = send;
|
|
23
|
+
this.timeoutMs = timeoutMs;
|
|
24
|
+
}
|
|
25
|
+
accept(event) {
|
|
26
|
+
if (event["type"] !== "control_response") return false;
|
|
27
|
+
const response = event["response"];
|
|
28
|
+
const id = response?.["request_id"];
|
|
29
|
+
if (typeof id !== "string") return true;
|
|
30
|
+
const pending = this.pending.get(id);
|
|
31
|
+
if (pending === void 0) return true;
|
|
32
|
+
if (response?.["subtype"] !== "success") pending.reject(new Error(typeof response?.["error"] === "string" ? response["error"] : "Claude rejected the control request"));
|
|
33
|
+
else {
|
|
34
|
+
const result = response["response"];
|
|
35
|
+
pending.resolve(typeof result === "object" && result !== null ? result : {});
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
request(request, signal) {
|
|
40
|
+
if (this.closed || signal?.aborted) return Promise.reject(/* @__PURE__ */ new Error("Claude control channel is closed"));
|
|
41
|
+
const id = `dsh-control-${++this.sequence}`;
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const finish = () => {
|
|
44
|
+
this.pending.delete(id);
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
signal?.removeEventListener("abort", abort);
|
|
47
|
+
};
|
|
48
|
+
const fail = (error) => {
|
|
49
|
+
finish();
|
|
50
|
+
reject(error);
|
|
51
|
+
};
|
|
52
|
+
const abort = () => {
|
|
53
|
+
fail(/* @__PURE__ */ new Error("Claude control request cancelled"));
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(() => fail(/* @__PURE__ */ new Error(`Claude ${String(request["subtype"])} control timed out`)), this.timeoutMs);
|
|
56
|
+
timer.unref();
|
|
57
|
+
this.pending.set(id, {
|
|
58
|
+
resolve: (value) => {
|
|
59
|
+
finish();
|
|
60
|
+
resolve(value);
|
|
61
|
+
},
|
|
62
|
+
reject: fail
|
|
63
|
+
});
|
|
64
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
65
|
+
try {
|
|
66
|
+
this.send({
|
|
67
|
+
type: "control_request",
|
|
68
|
+
request_id: id,
|
|
69
|
+
request
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
fail(error instanceof Error ? error : /* @__PURE__ */ new Error("Claude control write failed"));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
close() {
|
|
77
|
+
if (this.closed) return;
|
|
78
|
+
this.closed = true;
|
|
79
|
+
for (const request of [...this.pending.values()]) request.reject(/* @__PURE__ */ new Error("Claude control channel closed before acknowledgement"));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region lib/types/model-catalog.js
|
|
84
|
+
/** Initialize's native picker vocabulary, not an account entitlement claim. */
|
|
85
|
+
function claudeDirectory(result) {
|
|
86
|
+
const models = result["models"];
|
|
87
|
+
if (models === void 0) return {
|
|
88
|
+
entries: [],
|
|
89
|
+
complete: false,
|
|
90
|
+
customInput: true,
|
|
91
|
+
unsupported: true,
|
|
92
|
+
reason: "This Claude CLI does not expose a native model directory"
|
|
93
|
+
};
|
|
94
|
+
if (!Array.isArray(models)) throw new Error("Claude initialize returned an invalid model directory");
|
|
95
|
+
const entries = /* @__PURE__ */ new Map();
|
|
96
|
+
for (const candidate of models) {
|
|
97
|
+
if (typeof candidate !== "object" || candidate === null) continue;
|
|
98
|
+
const model = candidate;
|
|
99
|
+
const value = typeof model["value"] === "string" ? model["value"].trim() : "";
|
|
100
|
+
if (value === "" || entries.has(value)) continue;
|
|
101
|
+
const levels = model["supportedEffortLevels"];
|
|
102
|
+
const options = Array.isArray(levels) ? levels.flatMap((level) => typeof level === "string" && level !== "" ? [{
|
|
103
|
+
value: level,
|
|
104
|
+
label: level
|
|
105
|
+
}] : []) : void 0;
|
|
106
|
+
entries.set(value, {
|
|
107
|
+
value,
|
|
108
|
+
label: typeof model["displayName"] === "string" ? model["displayName"] : value,
|
|
109
|
+
source: "native",
|
|
110
|
+
...typeof model["resolvedModel"] === "string" ? { resolvedModel: model["resolvedModel"] } : {},
|
|
111
|
+
...typeof model["description"] === "string" ? { description: model["description"] } : {},
|
|
112
|
+
...options === void 0 ? {} : { reasoning: { options } }
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
entries: [...entries.values()],
|
|
117
|
+
complete: true,
|
|
118
|
+
customInput: true,
|
|
119
|
+
...entries.has("default") ? { defaultModel: "default" } : {}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** Scoped native control probe. Never syncs credentials, writes settings or submits a user turn. */
|
|
123
|
+
var ClaudeModelCatalog = class {
|
|
124
|
+
deps;
|
|
125
|
+
cache;
|
|
126
|
+
constructor(deps) {
|
|
127
|
+
this.deps = deps;
|
|
128
|
+
this.cache = new ModelDirectoryCache({
|
|
129
|
+
load: (key, signal) => {
|
|
130
|
+
const context = JSON.parse(key);
|
|
131
|
+
return this.probe(context.homeDir, context.cwd, signal);
|
|
132
|
+
},
|
|
133
|
+
...deps.ttlMs === void 0 ? {} : { ttlMs: deps.ttlMs },
|
|
134
|
+
onError: (error) => deps.warn?.(`Claude model discovery failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
key(homeDir, cwd = homeDir) {
|
|
138
|
+
const files = [
|
|
139
|
+
join(homeDir, "settings.json"),
|
|
140
|
+
join(homeDir, ".credentials.json"),
|
|
141
|
+
join(homeDir, ".claude.json")
|
|
142
|
+
];
|
|
143
|
+
for (let directory = cwd;; directory = dirname(directory)) {
|
|
144
|
+
files.push(join(directory, ".claude", "settings.json"), join(directory, ".claude", "settings.local.json"));
|
|
145
|
+
if (dirname(directory) === directory) break;
|
|
146
|
+
}
|
|
147
|
+
return modelDirectoryContextKey({
|
|
148
|
+
provider: "claude-code",
|
|
149
|
+
homeDir,
|
|
150
|
+
cwd,
|
|
151
|
+
cli: [this.deps.cli ?? "claude"],
|
|
152
|
+
files,
|
|
153
|
+
...this.deps.baseUrl === void 0 ? {} : { endpoint: this.deps.baseUrl }
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
read(homeDir, cwd) {
|
|
157
|
+
return this.cache.read(this.key(homeDir, cwd));
|
|
158
|
+
}
|
|
159
|
+
refresh(homeDir, cwd) {
|
|
160
|
+
return this.cache.refresh(this.key(homeDir, cwd));
|
|
161
|
+
}
|
|
162
|
+
follow(homeDir, cwd, signal) {
|
|
163
|
+
return this.cache.follow(() => this.key(homeDir, cwd), signal);
|
|
164
|
+
}
|
|
165
|
+
invalidate(homeDir, cwd) {
|
|
166
|
+
this.cache.invalidate(this.key(homeDir, cwd));
|
|
167
|
+
}
|
|
168
|
+
dispose() {
|
|
169
|
+
this.cache.dispose();
|
|
170
|
+
}
|
|
171
|
+
async probe(homeDir, cwd, signal) {
|
|
172
|
+
const child = this.deps.spawn({
|
|
173
|
+
argv: [
|
|
174
|
+
this.deps.cli ?? "claude",
|
|
175
|
+
"-p",
|
|
176
|
+
"--verbose",
|
|
177
|
+
"--input-format",
|
|
178
|
+
"stream-json",
|
|
179
|
+
"--output-format",
|
|
180
|
+
"stream-json",
|
|
181
|
+
"--no-session-persistence",
|
|
182
|
+
"--settings",
|
|
183
|
+
"{\"disableAllHooks\":true}",
|
|
184
|
+
"--strict-mcp-config",
|
|
185
|
+
"--mcp-config",
|
|
186
|
+
"{\"mcpServers\":{}}",
|
|
187
|
+
"--"
|
|
188
|
+
],
|
|
189
|
+
cwd,
|
|
190
|
+
stdio: {
|
|
191
|
+
stdin: "pipe",
|
|
192
|
+
stdout: "pipe",
|
|
193
|
+
stderr: "pipe"
|
|
194
|
+
},
|
|
195
|
+
graceMs: 2e3,
|
|
196
|
+
env: delegationEnv({
|
|
197
|
+
CLAUDE_CONFIG_DIR: homeDir,
|
|
198
|
+
...this.deps.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.deps.baseUrl }
|
|
199
|
+
})
|
|
200
|
+
});
|
|
201
|
+
const controls = new ClaudeControlRequests((frame) => {
|
|
202
|
+
if (child.stdin === void 0) throw new Error("Claude discovery stdin is unavailable");
|
|
203
|
+
child.stdin.write(JSON.stringify(frame) + "\n");
|
|
204
|
+
}, this.deps.timeoutMs ?? 5e3);
|
|
205
|
+
const decoder = new StringDecoder("utf8");
|
|
206
|
+
let buffer = "";
|
|
207
|
+
child.stdout?.on("data", (chunk) => {
|
|
208
|
+
buffer += decoder.write(chunk);
|
|
209
|
+
if (buffer.length > 1048576) {
|
|
210
|
+
controls.close();
|
|
211
|
+
child.terminate();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
let index;
|
|
215
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
216
|
+
const line = buffer.slice(0, index);
|
|
217
|
+
buffer = buffer.slice(index + 1);
|
|
218
|
+
let event;
|
|
219
|
+
try {
|
|
220
|
+
event = JSON.parse(line);
|
|
221
|
+
} catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (controls.accept(event)) continue;
|
|
225
|
+
if (event["type"] === "control_request" && typeof event["request_id"] === "string") child.stdin?.write(JSON.stringify({
|
|
226
|
+
type: "control_response",
|
|
227
|
+
response: {
|
|
228
|
+
subtype: "error",
|
|
229
|
+
request_id: event["request_id"],
|
|
230
|
+
error: "Model discovery does not execute tools"
|
|
231
|
+
}
|
|
232
|
+
}) + "\n");
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
child.stderr?.on("data", () => {});
|
|
236
|
+
child.done.then(() => controls.close(), () => controls.close());
|
|
237
|
+
let cleanupTimer;
|
|
238
|
+
try {
|
|
239
|
+
return claudeDirectory(await controls.request({
|
|
240
|
+
subtype: "initialize",
|
|
241
|
+
hooks: {}
|
|
242
|
+
}, signal));
|
|
243
|
+
} finally {
|
|
244
|
+
controls.close();
|
|
245
|
+
child.stdin?.end();
|
|
246
|
+
await Promise.race([child.done.catch(() => {}), new Promise((resolve) => {
|
|
247
|
+
cleanupTimer = setTimeout(resolve, 1e3);
|
|
248
|
+
cleanupTimer.unref();
|
|
249
|
+
})]);
|
|
250
|
+
if (cleanupTimer !== void 0) clearTimeout(cleanupTimer);
|
|
251
|
+
child.terminate();
|
|
252
|
+
await child.waitForExit().catch(() => {});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region lib/types/records.js
|
|
258
|
+
/**
|
|
259
|
+
* Claude Code session records adapter: scans the scoped home's `projects/`
|
|
260
|
+
* session files (one JSONL per session under `projects/<cwd-slug>/`, the
|
|
261
|
+
* slug being the workspace path with separators replaced by dashes), plus
|
|
262
|
+
* the transcript model read-back ({@link readClaudeTranscriptModel}) the
|
|
263
|
+
* model broker's `lastObserved` backstop runs on. The
|
|
264
|
+
* slug is a LOSSY encoding of the cwd — different paths can collide on the
|
|
265
|
+
* same slug — so the listed cwd always comes from the file CONTENT (the
|
|
266
|
+
* first `user` event's `cwd` field), never from the directory name. Only the
|
|
267
|
+
* head of each file is read — the first user event carries the id, cwd, and
|
|
268
|
+
* timestamp the listing needs; the rest of the file (conversation) is not
|
|
269
|
+
* parsed. Files are append-only with torn-tail tolerance, and contents are
|
|
270
|
+
* always untrusted input.
|
|
271
|
+
* @module @khorsheed/dsh-local-agent-claude-code/records
|
|
272
|
+
*/
|
|
273
|
+
/** Session files live under this directory inside the scoped home. */
|
|
274
|
+
const PROJECTS_ROOT = "projects";
|
|
275
|
+
/** Bound on the head prefix read from one session file. */
|
|
276
|
+
const HEAD_BYTES = 65536;
|
|
277
|
+
/**
|
|
278
|
+
* Bound on the tail read when extracting a transcript's model. Assistant
|
|
279
|
+
* lines are frequent, so the last window almost always names one; a bigger
|
|
280
|
+
* conversation is never read whole.
|
|
281
|
+
*/
|
|
282
|
+
const TRANSCRIPT_TAIL_BYTES = 262144;
|
|
283
|
+
/** Cap on project directories the transcript reads scan. */
|
|
284
|
+
const TRANSCRIPT_SCAN_DIRS = 200;
|
|
285
|
+
/** Cap on transcript files the harness-level newest-scan stats. */
|
|
286
|
+
const TRANSCRIPT_SCAN_FILES = 500;
|
|
287
|
+
/** Cap on sidechain (`agent-*.jsonl`) candidates inspected for one session id. */
|
|
288
|
+
const TRANSCRIPT_SIDECHAIN_FILES = 100;
|
|
289
|
+
/** How many newest transcripts the harness-level read tries before giving up. */
|
|
290
|
+
const TRANSCRIPT_NEWEST_TRIES = 3;
|
|
291
|
+
/** Test-swappable process exec (the keychain read goes through `security`). */
|
|
292
|
+
const internals = { exec: promisify(execFile) };
|
|
293
|
+
/**
|
|
294
|
+
* The scoped `.claude.json`'s modification stamp (epoch ms), undefined when
|
|
295
|
+
* absent. A completed login rewrites this file, so the stamp distinguishes a
|
|
296
|
+
* fresh login from a leftover marker (a revoked token's record lingers).
|
|
297
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
298
|
+
* @returns the marker's mtime, or undefined when no scoped config exists.
|
|
299
|
+
*/
|
|
300
|
+
async function claudeCredentialStamp(homeDir) {
|
|
301
|
+
try {
|
|
302
|
+
return (await stat(join(homeDir, ".claude.json"))).mtimeMs;
|
|
303
|
+
} catch {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The keychain entry name current claude uses for a non-default config dir:
|
|
309
|
+
* the bare name belongs to `~/.claude`, other dirs get a path-hash suffix.
|
|
310
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
311
|
+
* @returns the macOS keychain service name holding the OAuth credential.
|
|
312
|
+
*/
|
|
313
|
+
function keychainService(homeDir) {
|
|
314
|
+
return `Claude Code-credentials-${createHash("sha256").update(homeDir).digest("hex").slice(0, 8)}`;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Whether a credential blob is USABLE: the token record nests under
|
|
318
|
+
* `claudeAiOauth`, and a blob whose access AND refresh tokens are both empty
|
|
319
|
+
* is a shell. macOS keeps historical items under the same service (an early
|
|
320
|
+
* claude wrote an `acct=unknown` placeholder with blank tokens and
|
|
321
|
+
* `expiresAt: 0`), and mirroring one into the credentials file makes every
|
|
322
|
+
* delegation report "OAuth session expired and could not be refreshed" —
|
|
323
|
+
* indistinguishable from a real expiry while login looks successful. A
|
|
324
|
+
* non-empty token is the bar for mirroring a blob or keeping a file.
|
|
325
|
+
* @param text - the raw credential JSON.
|
|
326
|
+
* @returns true when the blob carries a non-empty token.
|
|
327
|
+
*/
|
|
328
|
+
function credentialUsable(text) {
|
|
329
|
+
try {
|
|
330
|
+
const oauth = JSON.parse(text).claudeAiOauth;
|
|
331
|
+
return typeof oauth?.accessToken === "string" && oauth.accessToken !== "" || typeof oauth?.refreshToken === "string" && oauth.refreshToken !== "";
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A `security dump-keychain` timedate as epoch ms; undefined when the shape is
|
|
338
|
+
* not recognized (the item then sorts as oldest).
|
|
339
|
+
*
|
|
340
|
+
* The shape that matters is the COMPACT Zulu stamp `20260916040855Z`, because
|
|
341
|
+
* that is what the tool actually prints — with the attribute's trailing NUL
|
|
342
|
+
* rendered as a literal `\000` after the closing digit, so the match must not
|
|
343
|
+
* anchor at the end. Only the spaced `2026-09-01 02:03:04 +0000` form was
|
|
344
|
+
* parsed before, which no `security` build was observed to emit: every item
|
|
345
|
+
* fell to the `?? 0` default, the newest-first sort over all-equal keys
|
|
346
|
+
* degenerated to the dump's own order (a stable sort), and
|
|
347
|
+
* `readKeychainCredential` returned whichever usable item the dump happened to
|
|
348
|
+
* list first. A scoped home that had been logged in twice then kept serving the
|
|
349
|
+
* SUPERSEDED credential — the fresh login sat unread in the keychain while the
|
|
350
|
+
* runtime ran on a generation whose refresh token was already spent. The
|
|
351
|
+
* spaced form is still accepted in case some build does emit it.
|
|
352
|
+
* @param text - the quoted timedate string from the dump.
|
|
353
|
+
* @returns the epoch milliseconds, or undefined.
|
|
354
|
+
*/
|
|
355
|
+
function keychainTimestamp(text) {
|
|
356
|
+
const trimmed = text.trim();
|
|
357
|
+
const compact = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/.exec(trimmed);
|
|
358
|
+
if (compact !== null) {
|
|
359
|
+
const at = (index) => Number(compact[index]);
|
|
360
|
+
const ms = Date.UTC(at(1), at(2) - 1, at(3), at(4), at(5), at(6));
|
|
361
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
362
|
+
}
|
|
363
|
+
const spaced = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(trimmed);
|
|
364
|
+
if (spaced === null) return void 0;
|
|
365
|
+
const part = (index) => Number(spaced[index]);
|
|
366
|
+
const offsetMinutes = (part(8) * 60 + part(9)) * (spaced[7] === "+" ? 1 : -1);
|
|
367
|
+
const ms = Date.UTC(part(1), part(2) - 1, part(3), part(4), part(5), part(6)) - offsetMinutes * 6e4;
|
|
368
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Every account holding an item under the service, newest write first. macOS
|
|
372
|
+
* allows SEVERAL items per service, and an unscoped `find-generic-password
|
|
373
|
+
* -w` returns the FIRST match — which can be a historical empty shell (see
|
|
374
|
+
* `credentialUsable`) — so the sync enumerates instead of trusting the first
|
|
375
|
+
* hit. The dump prints metadata only (no secrets); a failing dump or a parse
|
|
376
|
+
* miss yields an empty list, and the caller then falls back to the unscoped
|
|
377
|
+
* read.
|
|
378
|
+
* @param service - the keychain service name.
|
|
379
|
+
* @returns the accounts, newest modification first.
|
|
380
|
+
*/
|
|
381
|
+
async function keychainItems(service) {
|
|
382
|
+
let stdout;
|
|
383
|
+
try {
|
|
384
|
+
({stdout} = await internals.exec("security", ["dump-keychain"]));
|
|
385
|
+
} catch {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
const items = [];
|
|
389
|
+
let svce;
|
|
390
|
+
let acct;
|
|
391
|
+
let mdat = 0;
|
|
392
|
+
const flush = () => {
|
|
393
|
+
if (svce === service && acct !== void 0) items.push({
|
|
394
|
+
acct,
|
|
395
|
+
mdat
|
|
396
|
+
});
|
|
397
|
+
svce = void 0;
|
|
398
|
+
acct = void 0;
|
|
399
|
+
mdat = 0;
|
|
400
|
+
};
|
|
401
|
+
for (const line of stdout.split("\n")) {
|
|
402
|
+
if (line.startsWith("keychain:")) {
|
|
403
|
+
flush();
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const trimmed = line.trim();
|
|
407
|
+
const svceMatch = /^"svce"<blob>="(.*)"$/.exec(trimmed);
|
|
408
|
+
if (svceMatch !== null) {
|
|
409
|
+
svce = svceMatch[1];
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const acctMatch = /^"acct"<blob>="(.*)"$/.exec(trimmed);
|
|
413
|
+
if (acctMatch !== null) {
|
|
414
|
+
acct = acctMatch[1];
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const mdatMatch = /^"mdat"<timedate>=0x[0-9a-fA-F]+\s+"(.*)"$/.exec(trimmed);
|
|
418
|
+
if (mdatMatch !== null) mdat = keychainTimestamp(mdatMatch[1] ?? "") ?? 0;
|
|
419
|
+
}
|
|
420
|
+
flush();
|
|
421
|
+
return items.sort((a, b) => b.mdat - a.mdat);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* One keychain read: the item for `acct` when given, else the first match.
|
|
425
|
+
* Returns the raw blob only when it is USABLE (non-empty token); anything
|
|
426
|
+
* else — missing item, unreadable payload, an empty shell — is undefined.
|
|
427
|
+
* @param service - the keychain service name.
|
|
428
|
+
* @param acct - the account to read, or undefined for the unscoped first match.
|
|
429
|
+
* @returns the credential blob, or undefined.
|
|
430
|
+
*/
|
|
431
|
+
async function readKeychainEntry(service, acct) {
|
|
432
|
+
try {
|
|
433
|
+
const args = [
|
|
434
|
+
"find-generic-password",
|
|
435
|
+
"-s",
|
|
436
|
+
service
|
|
437
|
+
];
|
|
438
|
+
if (acct !== void 0) args.push("-a", acct);
|
|
439
|
+
args.push("-w");
|
|
440
|
+
const { stdout } = await internals.exec("security", args);
|
|
441
|
+
const blob = stdout.trim();
|
|
442
|
+
return credentialUsable(blob) ? blob : void 0;
|
|
443
|
+
} catch {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The usable credential blob for this scoped home, or undefined. Every
|
|
449
|
+
* account under the service is tried newest-write-first and the first
|
|
450
|
+
* non-empty token wins; when enumeration itself is unavailable (not macOS, a
|
|
451
|
+
* failing dump) the legacy unscoped read still runs, held to the same
|
|
452
|
+
* non-empty-token bar.
|
|
453
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
454
|
+
* @returns the credential blob, or undefined when nothing usable is stored.
|
|
455
|
+
*/
|
|
456
|
+
async function readKeychainCredential(homeDir) {
|
|
457
|
+
const service = keychainService(homeDir);
|
|
458
|
+
const items = await keychainItems(service);
|
|
459
|
+
for (const item of items) {
|
|
460
|
+
const blob = await readKeychainEntry(service, item.acct);
|
|
461
|
+
if (blob !== void 0) return blob;
|
|
462
|
+
}
|
|
463
|
+
return items.length > 0 ? void 0 : readKeychainEntry(service, void 0);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* The stored credential's expiry (epoch ms), read from the USABLE macOS
|
|
467
|
+
* keychain item for this scoped home (see `readKeychainCredential`);
|
|
468
|
+
* undefined when no usable item exists or it carries no expiry (presence
|
|
469
|
+
* alone then decides, as before). The READ side takes the LATER of the
|
|
470
|
+
* access-token and refresh-token expiries: the CLI refreshes on use, so a
|
|
471
|
+
* live refresh token means the credential still works.
|
|
472
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
473
|
+
* @returns the expiry, or undefined when unknown.
|
|
474
|
+
*/
|
|
475
|
+
async function readCredentialExpiry(homeDir) {
|
|
476
|
+
const blob = await readKeychainCredential(homeDir);
|
|
477
|
+
return blob === void 0 ? void 0 : credentialFileExpiry(blob);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The ACCESS-token expiry (epoch ms) of a credential blob — the freshness key
|
|
481
|
+
* the sync compares the two stores on. Deliberately not
|
|
482
|
+
* {@link credentialFileExpiry}: that one answers "is this credential still
|
|
483
|
+
* usable" and takes the LATER of the two expiries, so two generations of one
|
|
484
|
+
* grant share a refresh expiry and compare equal. A refresh always mints a
|
|
485
|
+
* later ACCESS expiry, which is exactly what tells the generations apart.
|
|
486
|
+
* @param text - the raw credential JSON.
|
|
487
|
+
* @returns the access-token expiry, or undefined when absent or unparseable.
|
|
488
|
+
*/
|
|
489
|
+
function credentialAccessExpiry(text) {
|
|
490
|
+
try {
|
|
491
|
+
const access = JSON.parse(text).claudeAiOauth?.expiresAt;
|
|
492
|
+
return typeof access === "number" ? access : void 0;
|
|
493
|
+
} catch {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Reconcile the keychain credential with `<homeDir>/.credentials.json`,
|
|
499
|
+
* NEWER WINS. Claude 2.1.236 on macOS WRITES the scoped login to the hashed
|
|
500
|
+
* keychain entry but READS the credentials file at runtime (the same
|
|
501
|
+
* write/read split as the Linux #47661 bug) — a login that lands only in the
|
|
502
|
+
* keychain still answers "Not logged in", so the file has to be kept current.
|
|
503
|
+
*
|
|
504
|
+
* The file is not this process's private mirror, though: a containerized
|
|
505
|
+
* round bind-mounts this very directory read-write (the unit has no keychain,
|
|
506
|
+
* so the CLI inside it reads and writes the file), and the pre-spawn sync runs
|
|
507
|
+
* against the HOST path moments before the unit reads it through the mount.
|
|
508
|
+
* An unconditional mirror therefore makes this function the one participant
|
|
509
|
+
* that can move the credential chain BACKWARD: it hands the next run a refresh
|
|
510
|
+
* token the other writer already spent, the endpoint rejects it, and the CLI
|
|
511
|
+
* answers by CLEARING the file — which logs the instance out, because that
|
|
512
|
+
* file is the instance's credential. So the keychain blob is written only when
|
|
513
|
+
* it is not older than the file's. Undefined expiry on either side keeps the
|
|
514
|
+
* pre-newer-wins behavior (the keychain writes), and a file that is not USABLE
|
|
515
|
+
* never wins.
|
|
516
|
+
*
|
|
517
|
+
* Only a USABLE blob (non-empty token, see `readKeychainCredential`) is
|
|
518
|
+
* mirrored; with none in the keychain, a file holding an empty-token shell —
|
|
519
|
+
* debris from an earlier first-match read, or a cleared credential — is
|
|
520
|
+
* REMOVED, so the runtime says "Not logged in" instead of the misleading
|
|
521
|
+
* "OAuth session expired". Idempotent and content-compare before write.
|
|
522
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
523
|
+
* @param log - warn channel for the two decisions that used to be silent.
|
|
524
|
+
* @returns true when a usable credential is in the file after the call.
|
|
525
|
+
*/
|
|
526
|
+
async function syncClaudeCredentialFile(homeDir, log) {
|
|
527
|
+
const file = join(homeDir, ".credentials.json");
|
|
528
|
+
let existing;
|
|
529
|
+
try {
|
|
530
|
+
existing = await readFile(file, "utf8");
|
|
531
|
+
} catch {}
|
|
532
|
+
const fileUsable = existing !== void 0 && credentialUsable(existing);
|
|
533
|
+
const blob = await readKeychainCredential(homeDir);
|
|
534
|
+
if (blob === void 0) {
|
|
535
|
+
if (existing !== void 0 && !fileUsable) await rm(file, { force: true });
|
|
536
|
+
return fileUsable;
|
|
537
|
+
}
|
|
538
|
+
if (existing !== void 0 && existing.trim() === blob) return true;
|
|
539
|
+
if (fileUsable) {
|
|
540
|
+
const fileExpiry = credentialAccessExpiry(existing);
|
|
541
|
+
const blobExpiry = credentialAccessExpiry(blob);
|
|
542
|
+
if (fileExpiry !== void 0 && blobExpiry !== void 0 && fileExpiry > blobExpiry) {
|
|
543
|
+
log?.(`local-agent-claude-code: ${homeDir}/.credentials.json is AHEAD of the keychain (file access expiry ${new Date(fileExpiry).toISOString()}, keychain ${new Date(blobExpiry).toISOString()}) — keeping the file; something else refreshed this grant (a containerized round mounts this directory read-write)`);
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (existing !== void 0 && !fileUsable) log?.(`local-agent-claude-code: ${homeDir}/.credentials.json held a cleared credential (both tokens empty) and is being restored from the keychain (access expiry ${new Date(credentialAccessExpiry(blob) ?? 0).toISOString()}); if the next round fails to authenticate, this grant was already spent and the scope needs a fresh login`);
|
|
548
|
+
await writeFile(file, blob, { mode: 384 });
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The expiry (epoch ms) from a `.credentials.json` payload; the token record
|
|
553
|
+
* nests under `claudeAiOauth`. Returns the LATER of the access-token and
|
|
554
|
+
* refresh-token expiries — the CLI refreshes on use, so a live refresh token
|
|
555
|
+
* keeps the credential usable past the access expiry. Undefined when
|
|
556
|
+
* absent/unparseable.
|
|
557
|
+
* @param text - the file contents.
|
|
558
|
+
* @returns the expiry, or undefined.
|
|
559
|
+
*/
|
|
560
|
+
function credentialFileExpiry(text) {
|
|
561
|
+
try {
|
|
562
|
+
const parsed = JSON.parse(text);
|
|
563
|
+
const access = parsed.claudeAiOauth?.expiresAt;
|
|
564
|
+
const refresh = parsed.claudeAiOauth?.refreshTokenExpiresAt;
|
|
565
|
+
const accessMs = typeof access === "number" ? access : void 0;
|
|
566
|
+
const refreshMs = typeof refresh === "number" ? refresh : void 0;
|
|
567
|
+
if (accessMs === void 0 && refreshMs === void 0) return void 0;
|
|
568
|
+
return Math.max(accessMs ?? 0, refreshMs ?? 0);
|
|
569
|
+
} catch {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Whether the scoped home holds usable Claude Code credentials: the scoped
|
|
575
|
+
* `.claude.json` carries an `oauthAccount` record once a device-code login
|
|
576
|
+
* has completed, AND the keychain credential is not past its stored expiry
|
|
577
|
+
* (when the expiry is readable — a server-side REVOCATION is not visible
|
|
578
|
+
* locally; providers report those through `localAgent.reportAuthFailure`).
|
|
579
|
+
* The real credential lives in the macOS keychain under a hashed entry
|
|
580
|
+
* (`Claude Code-credentials-<sha256(path)[:8]>`, hashed from the config-dir
|
|
581
|
+
* path) or in the default `~/.claude/.credentials.json` on Linux (upstream
|
|
582
|
+
* bug #47661: `CLAUDE_CONFIG_DIR` does not isolate the credentials file
|
|
583
|
+
* there — it writes the scoped dir but READS the default home, so Linux
|
|
584
|
+
* credentials may exist even when the scoped home has none). Spawning the
|
|
585
|
+
* CLI to probe auth is avoided; the platform differences are documented in
|
|
586
|
+
* the README.
|
|
587
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
588
|
+
* @param log - warn channel handed to the credential reconcile below.
|
|
589
|
+
* @returns true when a completed login is recorded and not locally expired.
|
|
590
|
+
*/
|
|
591
|
+
async function claudeAuthenticated(homeDir, log) {
|
|
592
|
+
await syncClaudeCredentialFile(homeDir, log);
|
|
593
|
+
let fileExpiry;
|
|
594
|
+
let filePresent = false;
|
|
595
|
+
try {
|
|
596
|
+
fileExpiry = credentialFileExpiry(await readFile(join(homeDir, ".credentials.json"), "utf8"));
|
|
597
|
+
filePresent = true;
|
|
598
|
+
} catch {}
|
|
599
|
+
if (filePresent) return fileExpiry === void 0 || fileExpiry > Date.now();
|
|
600
|
+
let text;
|
|
601
|
+
try {
|
|
602
|
+
text = await readFile(join(homeDir, ".claude.json"), "utf8");
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (error.code === "ENOENT") return false;
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
let config;
|
|
608
|
+
try {
|
|
609
|
+
config = JSON.parse(text);
|
|
610
|
+
} catch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
if (config.oauthAccount === void 0 || config.oauthAccount === null) return false;
|
|
614
|
+
const expiry = await readCredentialExpiry(homeDir);
|
|
615
|
+
return expiry === void 0 || expiry > Date.now();
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Parse the head of a session file for the listing fields. The first `user`
|
|
619
|
+
* event carries `sessionId`, `cwd`, and `timestamp`; its `cwd` is the only
|
|
620
|
+
* trustworthy workspace source (the directory slug is lossy). Unknown or
|
|
621
|
+
* malformed values yield an undefined record rather than failing the list.
|
|
622
|
+
* @param text - the raw session file head (only the head is read).
|
|
623
|
+
* @returns the record, or undefined when no user event is found.
|
|
624
|
+
*/
|
|
625
|
+
function projectRecord(text) {
|
|
626
|
+
for (const raw of text.split("\n")) {
|
|
627
|
+
const line = raw.trim();
|
|
628
|
+
if (line === "") continue;
|
|
629
|
+
let event;
|
|
630
|
+
try {
|
|
631
|
+
event = JSON.parse(line);
|
|
632
|
+
} catch {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (event.type !== "user") continue;
|
|
636
|
+
if (typeof event.sessionId !== "string" || event.sessionId === "") return void 0;
|
|
637
|
+
if (typeof event.cwd !== "string") return void 0;
|
|
638
|
+
const record = {
|
|
639
|
+
id: event.sessionId,
|
|
640
|
+
workDir: event.cwd
|
|
641
|
+
};
|
|
642
|
+
const startedAt = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : void 0;
|
|
643
|
+
if (startedAt !== void 0 && Number.isFinite(startedAt)) record.startedAt = startedAt;
|
|
644
|
+
return record;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Read the scoped home's session files. The listing walks the projects
|
|
649
|
+
* directory and reads each jsonl head; a torn or malformed file is skipped
|
|
650
|
+
* rather than failing the whole list.
|
|
651
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
652
|
+
* @returns the parsed records, empty when no sessions exist yet.
|
|
653
|
+
*/
|
|
654
|
+
async function listClaudeSessions(homeDir) {
|
|
655
|
+
const records = [];
|
|
656
|
+
let projectDirs;
|
|
657
|
+
try {
|
|
658
|
+
projectDirs = await readdir(join(homeDir, PROJECTS_ROOT));
|
|
659
|
+
} catch (error) {
|
|
660
|
+
if (error.code === "ENOENT") return [];
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
for (const projectDir of projectDirs) {
|
|
664
|
+
let files;
|
|
665
|
+
try {
|
|
666
|
+
files = await readdir(join(homeDir, PROJECTS_ROOT, projectDir));
|
|
667
|
+
} catch {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
for (const name of files) {
|
|
671
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
672
|
+
try {
|
|
673
|
+
const handle = await open(join(homeDir, PROJECTS_ROOT, projectDir, name), "r");
|
|
674
|
+
let text;
|
|
675
|
+
try {
|
|
676
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
677
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
678
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
679
|
+
} finally {
|
|
680
|
+
await handle.close();
|
|
681
|
+
}
|
|
682
|
+
const record = projectRecord(text);
|
|
683
|
+
if (record !== void 0) records.push(record);
|
|
684
|
+
} catch {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return records;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Extract the model one transcript names, reading its TAIL (bounded, never
|
|
693
|
+
* the whole conversation) from the newest line backward: the first line
|
|
694
|
+
* carrying a model wins — the top-level `model` of the stream-json init
|
|
695
|
+
* event's shape, or the nested `message.model` of an assistant line. Newest
|
|
696
|
+
* first because a session's later rounds may run a different model than its
|
|
697
|
+
* first, and "what ran last" is the question the caller asks. A torn final
|
|
698
|
+
* line is skipped, never failed on.
|
|
699
|
+
* @param path - the transcript file.
|
|
700
|
+
* @returns the model identifier, or undefined when the window names none.
|
|
701
|
+
*/
|
|
702
|
+
async function transcriptFileModel(path) {
|
|
703
|
+
let text;
|
|
704
|
+
try {
|
|
705
|
+
const handle = await open(path, "r");
|
|
706
|
+
try {
|
|
707
|
+
const { size } = await handle.stat();
|
|
708
|
+
const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
709
|
+
const buffer = Buffer.alloc(length);
|
|
710
|
+
const { bytesRead } = await handle.read(buffer, 0, length, Math.max(0, size - length));
|
|
711
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
712
|
+
} finally {
|
|
713
|
+
await handle.close();
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const lines = text.split("\n");
|
|
719
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
720
|
+
const line = lines[index].trim();
|
|
721
|
+
if (line === "") continue;
|
|
722
|
+
let event;
|
|
723
|
+
try {
|
|
724
|
+
event = JSON.parse(line);
|
|
725
|
+
} catch {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (typeof event.model === "string" && event.model !== "") return event.model;
|
|
729
|
+
if (typeof event.message?.model === "string" && event.message.model !== "") return event.message.model;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/** The project directories under one scoped home, capped; empty on any failure. */
|
|
733
|
+
async function projectDirs(homeDir) {
|
|
734
|
+
try {
|
|
735
|
+
return (await readdir(join(homeDir, PROJECTS_ROOT))).slice(0, TRANSCRIPT_SCAN_DIRS);
|
|
736
|
+
} catch {
|
|
737
|
+
return [];
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** The jsonl file names inside one project directory; empty on any failure. */
|
|
741
|
+
async function projectFiles(homeDir, projectDir) {
|
|
742
|
+
try {
|
|
743
|
+
return (await readdir(join(homeDir, PROJECTS_ROOT, projectDir))).filter((name) => name.endsWith(".jsonl"));
|
|
744
|
+
} catch {
|
|
745
|
+
return [];
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Whether a sidechain (`agent-*.jsonl`) transcript belongs to one session:
|
|
750
|
+
* its head lines carry the PARENT session's id (`isSidechain: true`). Only
|
|
751
|
+
* the bounded head is read.
|
|
752
|
+
*/
|
|
753
|
+
async function sidechainSessionMatches(path, cliSessionId) {
|
|
754
|
+
let text;
|
|
755
|
+
try {
|
|
756
|
+
const handle = await open(path, "r");
|
|
757
|
+
try {
|
|
758
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
759
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
760
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
761
|
+
} finally {
|
|
762
|
+
await handle.close();
|
|
763
|
+
}
|
|
764
|
+
} catch {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
for (const raw of text.split("\n")) {
|
|
768
|
+
const line = raw.trim();
|
|
769
|
+
if (line === "") continue;
|
|
770
|
+
try {
|
|
771
|
+
if (JSON.parse(line).sessionId === cliSessionId) return true;
|
|
772
|
+
} catch {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The model a member's OWN CLI transcript names — the history backstop for
|
|
780
|
+
* the broker's `lastObserved` when no delegation record observed one (every
|
|
781
|
+
* member whose rounds predate the live-settle report). The live-settle
|
|
782
|
+
* channel remains primary (the record's `observedModel` wins when present);
|
|
783
|
+
* this reader only answers "what did this session last run" from the CLI's
|
|
784
|
+
* own files, read-only — nothing is backfilled into `delegations.jsonl`.
|
|
785
|
+
*
|
|
786
|
+
* Location rule: with `cliSessionId`, the main transcript
|
|
787
|
+
* `projects/<cwd-slug>/<cliSessionId>.jsonl` (found by NAME across the
|
|
788
|
+
* project dirs — the slug is lossy, so it is never computed); when it is
|
|
789
|
+
* absent, a sidechain `agent-*.jsonl` whose head names the session id
|
|
790
|
+
* (bounded candidate count) answers instead. Without `cliSessionId` (the
|
|
791
|
+
* settings card's memberless read), the NEWEST transcript across the
|
|
792
|
+
* projects tree (mtime, bounded dir/file scan) answers; a newest file that
|
|
793
|
+
* names no model yields to the next, up to a few tries. Every failure —
|
|
794
|
+
* missing tree, unreadable file, no model in the read window — is
|
|
795
|
+
* `undefined`, never a throw.
|
|
796
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
797
|
+
* @param cliSessionId - the delegation record's CLI session id, when the
|
|
798
|
+
* member's own transcript is wanted.
|
|
799
|
+
* @returns the model identifier, or undefined.
|
|
800
|
+
*/
|
|
801
|
+
async function readClaudeTranscriptModel(homeDir, cliSessionId) {
|
|
802
|
+
if (cliSessionId !== void 0) {
|
|
803
|
+
let main;
|
|
804
|
+
const sidechains = [];
|
|
805
|
+
for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
|
|
806
|
+
if (name === `${cliSessionId}.jsonl` && main === void 0) {
|
|
807
|
+
main = join(homeDir, PROJECTS_ROOT, projectDir, name);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (name.startsWith("agent-") && sidechains.length < TRANSCRIPT_SIDECHAIN_FILES) sidechains.push(join(homeDir, PROJECTS_ROOT, projectDir, name));
|
|
811
|
+
}
|
|
812
|
+
if (main !== void 0) {
|
|
813
|
+
const model = await transcriptFileModel(main);
|
|
814
|
+
if (model !== void 0) return model;
|
|
815
|
+
}
|
|
816
|
+
for (const path of sidechains) if (await sidechainSessionMatches(path, cliSessionId)) {
|
|
817
|
+
const model = await transcriptFileModel(path);
|
|
818
|
+
if (model !== void 0) return model;
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const candidates = [];
|
|
823
|
+
let scanned = 0;
|
|
824
|
+
for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
|
|
825
|
+
if (scanned >= TRANSCRIPT_SCAN_FILES) break;
|
|
826
|
+
scanned += 1;
|
|
827
|
+
const path = join(homeDir, PROJECTS_ROOT, projectDir, name);
|
|
828
|
+
try {
|
|
829
|
+
candidates.push({
|
|
830
|
+
path,
|
|
831
|
+
mtimeMs: (await stat(path)).mtimeMs
|
|
832
|
+
});
|
|
833
|
+
} catch {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
838
|
+
for (const candidate of candidates.slice(0, TRANSCRIPT_NEWEST_TRIES)) {
|
|
839
|
+
const model = await transcriptFileModel(candidate.path);
|
|
840
|
+
if (model !== void 0) return model;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region lib/types/live-driver.js
|
|
845
|
+
/**
|
|
846
|
+
* The claude provider's live driver: one resident Claude Code stream-json
|
|
847
|
+
* process per member (child session), driven over the vendor's
|
|
848
|
+
* `--input-format stream-json --output-format stream-json` realtime mode
|
|
849
|
+
* (protocol-probed against claude 2.1.236; the Agent SDK's streaming-input
|
|
850
|
+
* mode is this same channel, driven here directly so no SDK dependency
|
|
851
|
+
* lands). A delegation round is one `user` message on the resident process's
|
|
852
|
+
* stdin instead of a fresh `claude -p` — which buys what the process boundary
|
|
853
|
+
* could not: runtime-level graceful interrupt (`control_request
|
|
854
|
+
* {subtype:'interrupt'}`; the process survives and the session stays
|
|
855
|
+
* continuable) and the same-shape push stream folded live per turn.
|
|
856
|
+
*
|
|
857
|
+
* Auth discipline (a hard requirement): the resident process runs under the
|
|
858
|
+
* family's scoped `CLAUDE_CONFIG_DIR` and nothing else — the OAuth marker in
|
|
859
|
+
* the scoped `.claude.json` and the keychain entry hashed to that path are
|
|
860
|
+
* exactly what the exec mode's one-shot processes already use. The driver
|
|
861
|
+
* never touches the user's global `~/.claude`, never runs any `auth` verb,
|
|
862
|
+
* and only adds `ANTHROPIC_BASE_URL` when the plugin config supplies it (the
|
|
863
|
+
* exec path's exact env rule).
|
|
864
|
+
*
|
|
865
|
+
* Protocol facts (all probed): the first stdin `user` message triggers
|
|
866
|
+
* `system/init` carrying the server-assigned session id (EVERY turn emits a
|
|
867
|
+
* fresh init — the delegation records the first one); each turn closes with
|
|
868
|
+
* a `result` event (`is_error`, usage, session id); the interrupt control
|
|
869
|
+
* request answers with a `control_response` success; a new process with
|
|
870
|
+
* `--resume <session_id>` reattaches the on-disk session in the same mode
|
|
871
|
+
* (crash recovery); stdin EOF quiesces the process (the reclaim ladder).
|
|
872
|
+
*
|
|
873
|
+
* Fold: the resident stream speaks the exec path's exact event vocabulary
|
|
874
|
+
* (`system`/`assistant`/`user`/`result`), so each turn folds through the
|
|
875
|
+
* shared `ClaudeStreamParser` with the exec live mirror's hold-back rule (the
|
|
876
|
+
* volatile last line waits for `result`, which carries the round's usage).
|
|
877
|
+
* Every line folds 1:1. The always-enabled
|
|
878
|
+
* `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
|
|
879
|
+
* stream (native message id + block index, with ordered legacy fallback),
|
|
880
|
+
* which reserves a (turn, step) at its first delta and
|
|
881
|
+
* appends throttled snapshot `assistant/message`s there — the host folds
|
|
882
|
+
* repeated settles at one coordinate into one live-updating chat node, the
|
|
883
|
+
* only streaming channel left after 0.1.5 retired the durable per-chunk
|
|
884
|
+
* event. A streamed line's completion folds at its stream's reserved step and
|
|
885
|
+
* finalizes it (no duplicated content); a stream whose completion never
|
|
886
|
+
* arrives is force-finalized at settle (interrupted on a non-completed round,
|
|
887
|
+
* so a cancelled turn reads 已停止 legitimately), the last one carrying the
|
|
888
|
+
* round's usage when no folded line did.
|
|
889
|
+
*
|
|
890
|
+
* Lifecycle mirrors M1–M3's discipline: lazy spawn, one in-flight spawn per
|
|
891
|
+
* member, idle-timeout reclaim (stdin EOF → grace → SIGTERM ladder), crash
|
|
892
|
+
* re-spawn with `--resume`, a channel breaker with cooldown, `disposeAll` on
|
|
893
|
+
* unload, and cancellation honored in every window (abort listener before
|
|
894
|
+
* any await; the init wait races the abort signal).
|
|
895
|
+
*
|
|
896
|
+
* Two member-aware additions on top: the spawn binds the member's EFFECTIVE
|
|
897
|
+
* model (session-level override, then the round's delegation model, then the
|
|
898
|
+
* settings key — resolved by the config callback per spawn, scratched into
|
|
899
|
+
* the scoped settings.json because a `--resume` respawn honors the file over
|
|
900
|
+
* the flag), and a runtime whose bound model no longer matches is retired so
|
|
901
|
+
* the round respawns onto the right one; and server→client control requests
|
|
902
|
+
* (a `permissionMode: 'normal'` spawn's `can_use_tool` approval surface) are
|
|
903
|
+
* auto-answered so an unattended runtime never hangs on a permission prompt.
|
|
904
|
+
* @module @khorsheed/dsh-local-agent-claude-code/live-driver
|
|
905
|
+
*/
|
|
906
|
+
/** Default idle lifetime of an unused resident runtime before reclaim. */
|
|
907
|
+
const DEFAULT_LIVE_IDLE_MS = 18e5;
|
|
908
|
+
/** Default timeout for the turn's system/init ack (a cold boot can be slow). */
|
|
909
|
+
const DEFAULT_LIVE_INIT_TIMEOUT_MS = 6e4;
|
|
910
|
+
/** Bounded wait for an interrupted turn to converge while a run disposes. */
|
|
911
|
+
const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5e3;
|
|
912
|
+
/** How long a tripped channel breaker stays on the exec fallback. */
|
|
913
|
+
const DEFAULT_LIVE_CHANNEL_RETRY_MS = 3e5;
|
|
914
|
+
/** Grace between stdin EOF and SIGTERM when reclaiming a stream-json process. */
|
|
915
|
+
const RECLAIM_EOF_GRACE_MS = 1e3;
|
|
916
|
+
/** Default minimum interval between one streaming kind's snapshot messages. */
|
|
917
|
+
const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
|
|
918
|
+
/**
|
|
919
|
+
* The stream-json channel could not come up (spawn failure or the first
|
|
920
|
+
* turn's init never arrived). The provider catches exactly this and falls
|
|
921
|
+
* back to the exec one-shot; any other error is a real round failure.
|
|
922
|
+
*/
|
|
923
|
+
var LiveChannelUnavailableError = class extends Error {
|
|
924
|
+
constructor(message) {
|
|
925
|
+
super(message);
|
|
926
|
+
this.name = "LiveChannelUnavailableError";
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
const DEFAULT_TIMEOUTS = {
|
|
930
|
+
initMs: DEFAULT_LIVE_INIT_TIMEOUT_MS,
|
|
931
|
+
convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
|
|
932
|
+
channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS
|
|
933
|
+
};
|
|
934
|
+
function thrown$1(value) {
|
|
935
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
936
|
+
}
|
|
937
|
+
function delay(ms) {
|
|
938
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* One resident stream-json process: the NDJSON frame pump, the session id it
|
|
942
|
+
* carries, the per-session turn chain (one turn at a time — claude queues
|
|
943
|
+
* stdin user messages, but the cancelled turn's result must land before the
|
|
944
|
+
* next turn's message goes out, keeping the stop-then-rephrase gesture's
|
|
945
|
+
* streams unambiguous), and the reclaim ladder (stdin EOF → grace → SIGTERM).
|
|
946
|
+
*/
|
|
947
|
+
var ClaudeLiveRuntime = class {
|
|
948
|
+
child;
|
|
949
|
+
warn;
|
|
950
|
+
dead = false;
|
|
951
|
+
reclaimed = false;
|
|
952
|
+
/** The member's claude session id (first turn's system/init). */
|
|
953
|
+
sessionId;
|
|
954
|
+
/** The model this runtime's process was spawned with (undefined = no flag). */
|
|
955
|
+
configurationKey;
|
|
956
|
+
model;
|
|
957
|
+
/** Serializes turns per member (converge-before-next-message). */
|
|
958
|
+
turnChain = Promise.resolve();
|
|
959
|
+
/** The active round's event sink; installed per round, cleared at settle. */
|
|
960
|
+
onEvent;
|
|
961
|
+
controls = new ClaudeControlRequests((message) => this.send(message));
|
|
962
|
+
onDead;
|
|
963
|
+
buffer = "";
|
|
964
|
+
decoder = new StringDecoder("utf8");
|
|
965
|
+
stderrTail = "";
|
|
966
|
+
stderrDecoder = new StringDecoder("utf8");
|
|
967
|
+
constructor(child, warn) {
|
|
968
|
+
this.child = child;
|
|
969
|
+
this.warn = warn;
|
|
970
|
+
child.stdout?.on("data", (chunk) => {
|
|
971
|
+
this.feed(this.decoder.write(chunk));
|
|
972
|
+
});
|
|
973
|
+
child.stderr?.on("data", (chunk) => {
|
|
974
|
+
this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
|
|
975
|
+
});
|
|
976
|
+
child.done.then(() => {
|
|
977
|
+
this.markDead();
|
|
978
|
+
}, () => {
|
|
979
|
+
this.markDead();
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
get diagnostics() {
|
|
983
|
+
return this.stderrTail.trim();
|
|
984
|
+
}
|
|
985
|
+
feed(text) {
|
|
986
|
+
this.buffer += text;
|
|
987
|
+
let index = this.buffer.indexOf("\n");
|
|
988
|
+
while (index >= 0) {
|
|
989
|
+
const line = this.buffer.slice(0, index);
|
|
990
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
991
|
+
index = this.buffer.indexOf("\n");
|
|
992
|
+
if (line.trim() === "") continue;
|
|
993
|
+
let event;
|
|
994
|
+
try {
|
|
995
|
+
event = JSON.parse(line);
|
|
996
|
+
} catch {
|
|
997
|
+
this.warn("subagent-claude live: ignored a malformed stream line");
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
this.dispatchEvent(event);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
this.warn(`subagent-claude live: stream handler failed: ${thrown$1(error).message}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
dispatchEvent(event) {
|
|
1008
|
+
if (this.controls.accept(event)) return;
|
|
1009
|
+
if (event["type"] === "control_request") {
|
|
1010
|
+
this.answerControlRequest(event);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
this.onEvent?.(event);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Answer a server→client control request so the runtime never hangs on
|
|
1017
|
+
* one. A `permissionMode: 'normal'` spawn omits
|
|
1018
|
+
* `--dangerously-skip-permissions`, so the CLI routes its approval surface
|
|
1019
|
+
* (`can_use_tool`) here — and a subagent has no human to approve. The
|
|
1020
|
+
* auto-allow mirrors the exec path's effective behavior (its one-shot
|
|
1021
|
+
* spawns pre-allow the member bridge tool and otherwise run the same
|
|
1022
|
+
* unattended policy) and the kimi live driver's auto-allow of
|
|
1023
|
+
* `session/request_permission`. Any other subtype answers with an error so
|
|
1024
|
+
* the CLI's own fallback path runs instead of waiting forever.
|
|
1025
|
+
*/
|
|
1026
|
+
answerControlRequest(event) {
|
|
1027
|
+
const requestId = event["request_id"];
|
|
1028
|
+
if (typeof requestId !== "string") return;
|
|
1029
|
+
const request = event["request"];
|
|
1030
|
+
const subtype = request?.["subtype"];
|
|
1031
|
+
if (subtype === "can_use_tool") {
|
|
1032
|
+
const input = request?.["input"];
|
|
1033
|
+
this.send({
|
|
1034
|
+
type: "control_response",
|
|
1035
|
+
response: {
|
|
1036
|
+
subtype: "success",
|
|
1037
|
+
request_id: requestId,
|
|
1038
|
+
response: {
|
|
1039
|
+
behavior: "allow",
|
|
1040
|
+
updatedInput: typeof input === "object" && input !== null ? input : {}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
this.send({
|
|
1047
|
+
type: "control_response",
|
|
1048
|
+
response: {
|
|
1049
|
+
subtype: "error",
|
|
1050
|
+
request_id: requestId,
|
|
1051
|
+
error: `subagent-claude live: unsupported control request ${String(subtype)}`
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
/** Write one stdin frame. */
|
|
1056
|
+
send(message) {
|
|
1057
|
+
this.child.stdin?.write(JSON.stringify(message) + "\n");
|
|
1058
|
+
}
|
|
1059
|
+
/** Native initialization and model binding precede any user prompt. */
|
|
1060
|
+
async configure(configuration, signal) {
|
|
1061
|
+
const entry = claudeDirectory(await this.controls.request({
|
|
1062
|
+
subtype: "initialize",
|
|
1063
|
+
hooks: {}
|
|
1064
|
+
}, signal)).entries.find((entry) => entry.value === configuration.model || entry.resolvedModel === configuration.model);
|
|
1065
|
+
if (configuration.effort !== void 0 && !entry?.reasoning?.options.some((option) => option.value === configuration.effort)) throw new Error("The running Claude CLI does not advertise the requested model/effort combination");
|
|
1066
|
+
if (configuration.model !== void 0) await this.controls.request({
|
|
1067
|
+
subtype: "set_model",
|
|
1068
|
+
model: configuration.model
|
|
1069
|
+
}, signal);
|
|
1070
|
+
}
|
|
1071
|
+
/** The graceful runtime interrupt, bounded and checked for an actual success ack. */
|
|
1072
|
+
async interrupt() {
|
|
1073
|
+
if (this.dead) return;
|
|
1074
|
+
await this.controls.request({ subtype: "interrupt" });
|
|
1075
|
+
}
|
|
1076
|
+
markDead() {
|
|
1077
|
+
if (this.dead) return;
|
|
1078
|
+
this.dead = true;
|
|
1079
|
+
this.controls.close();
|
|
1080
|
+
this.onDead?.();
|
|
1081
|
+
}
|
|
1082
|
+
/** Graceful teardown: EOF stdin, grace, then the terminate ladder. */
|
|
1083
|
+
async reclaim() {
|
|
1084
|
+
if (this.reclaimed) return;
|
|
1085
|
+
this.reclaimed = true;
|
|
1086
|
+
if (!this.dead) {
|
|
1087
|
+
this.child.stdin?.end();
|
|
1088
|
+
await Promise.race([this.child.done.catch(() => {}), delay(RECLAIM_EOF_GRACE_MS)]);
|
|
1089
|
+
}
|
|
1090
|
+
if (!this.dead) {
|
|
1091
|
+
this.child.terminate();
|
|
1092
|
+
await this.child.waitForExit();
|
|
1093
|
+
}
|
|
1094
|
+
await this.child.done.catch(() => {});
|
|
1095
|
+
this.markDead();
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
/**
|
|
1099
|
+
* The claude live driver owns every resident stream-json runtime of this
|
|
1100
|
+
* provider generation. Created only when `live: true`; disposed with the
|
|
1101
|
+
* plugin.
|
|
1102
|
+
*/
|
|
1103
|
+
var ClaudeLiveDriver = class {
|
|
1104
|
+
ctx;
|
|
1105
|
+
config;
|
|
1106
|
+
timeouts;
|
|
1107
|
+
runtimes = /* @__PURE__ */ new Map();
|
|
1108
|
+
ensuring = /* @__PURE__ */ new Map();
|
|
1109
|
+
idleTimers = /* @__PURE__ */ new Map();
|
|
1110
|
+
/** Per-member round serialization (the resume lock covers resume-vs-resume only). */
|
|
1111
|
+
roundChains = /* @__PURE__ */ new Map();
|
|
1112
|
+
channelBrokenAt;
|
|
1113
|
+
disposed = false;
|
|
1114
|
+
/**
|
|
1115
|
+
* Set by drain() (a settings-driven generation handoff): new rounds are
|
|
1116
|
+
* refused so the provider falls back to exec, while in-flight rounds finish
|
|
1117
|
+
* on their runtime undisturbed. Unlike `disposed`, the driver still serves
|
|
1118
|
+
* what it already accepted.
|
|
1119
|
+
*/
|
|
1120
|
+
draining = false;
|
|
1121
|
+
disposeController = new AbortController();
|
|
1122
|
+
constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
|
|
1123
|
+
this.ctx = ctx;
|
|
1124
|
+
this.config = config;
|
|
1125
|
+
this.timeouts = timeouts;
|
|
1126
|
+
}
|
|
1127
|
+
/** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
|
|
1128
|
+
get disabled() {
|
|
1129
|
+
return this.channelBrokenAt !== void 0 && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? 3e5);
|
|
1130
|
+
}
|
|
1131
|
+
/** Count of resident runtimes currently registered (zombie accounting in tests). */
|
|
1132
|
+
get liveCount() {
|
|
1133
|
+
return this.runtimes.size;
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* True while the member's runtime exists or is being spawned. The settings
|
|
1137
|
+
* controller gates a new generation on this: a fresh driver must not serve
|
|
1138
|
+
* a member whose retiring generation still hosts the (same) claude session.
|
|
1139
|
+
*/
|
|
1140
|
+
hasRuntime(key) {
|
|
1141
|
+
return this.runtimes.has(key) || this.ensuring.has(key);
|
|
1142
|
+
}
|
|
1143
|
+
/** @deprecated Compatibility no-op: live output is always incremental. */
|
|
1144
|
+
setLiveMirrorGranularity(_granularity) {}
|
|
1145
|
+
/**
|
|
1146
|
+
* Drain for a settings-driven generation handoff: refuse new rounds (the
|
|
1147
|
+
* provider's catch falls back to exec), let every in-flight round finish on
|
|
1148
|
+
* its runtime, then reclaim. Resolves when no runtime or spawn remains.
|
|
1149
|
+
* Unlike disposeAll, in-flight work is never interrupted.
|
|
1150
|
+
*/
|
|
1151
|
+
async drain() {
|
|
1152
|
+
this.draining = true;
|
|
1153
|
+
const keys = /* @__PURE__ */ new Set([
|
|
1154
|
+
...this.runtimes.keys(),
|
|
1155
|
+
...this.ensuring.keys(),
|
|
1156
|
+
...this.roundChains.keys()
|
|
1157
|
+
]);
|
|
1158
|
+
await Promise.all([...keys].map(async (key) => {
|
|
1159
|
+
await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => void 0);
|
|
1160
|
+
await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => void 0);
|
|
1161
|
+
await this.reclaim(key);
|
|
1162
|
+
}));
|
|
1163
|
+
}
|
|
1164
|
+
clearIdleTimer(key) {
|
|
1165
|
+
const timer = this.idleTimers.get(key);
|
|
1166
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1167
|
+
this.idleTimers.delete(key);
|
|
1168
|
+
}
|
|
1169
|
+
armIdleTimer(key) {
|
|
1170
|
+
this.clearIdleTimer(key);
|
|
1171
|
+
const idleMs = this.config.liveIdleMs ?? 18e5;
|
|
1172
|
+
const timer = setTimeout(() => {
|
|
1173
|
+
this.reclaim(key);
|
|
1174
|
+
}, idleMs);
|
|
1175
|
+
timer.unref();
|
|
1176
|
+
this.idleTimers.set(key, timer);
|
|
1177
|
+
}
|
|
1178
|
+
async reclaim(key) {
|
|
1179
|
+
const runtime = this.runtimes.get(key);
|
|
1180
|
+
this.clearIdleTimer(key);
|
|
1181
|
+
if (runtime === void 0) return;
|
|
1182
|
+
this.runtimes.delete(key);
|
|
1183
|
+
await runtime.reclaim();
|
|
1184
|
+
}
|
|
1185
|
+
/** Reclaim every runtime (plugin unload); no process survives the profile. */
|
|
1186
|
+
async disposeAll() {
|
|
1187
|
+
this.disposed = true;
|
|
1188
|
+
this.disposeController.abort();
|
|
1189
|
+
await Promise.all([...this.ensuring.values()].map((pending) => pending.catch(() => void 0)));
|
|
1190
|
+
for (const key of [...this.runtimes.keys()]) await this.reclaim(key);
|
|
1191
|
+
}
|
|
1192
|
+
ensureRuntime(spec, signal) {
|
|
1193
|
+
const key = String(spec.childSession.id);
|
|
1194
|
+
const existing = this.runtimes.get(key);
|
|
1195
|
+
if (existing !== void 0 && !existing.dead && existing.model === this.spawnModel(key, spec) && existing.configurationKey === (spec.configuration === void 0 ? void 0 : JSON.stringify(spec.configuration))) {
|
|
1196
|
+
this.clearIdleTimer(key);
|
|
1197
|
+
return Promise.resolve(existing);
|
|
1198
|
+
}
|
|
1199
|
+
if (existing !== void 0) this.runtimes.delete(key);
|
|
1200
|
+
const pending = this.ensuring.get(key);
|
|
1201
|
+
if (pending !== void 0) return pending;
|
|
1202
|
+
const spawn = (existing !== void 0 && !existing.dead ? existing.reclaim() : Promise.resolve()).then(() => this.spawnRuntime(spec, signal)).finally(() => {
|
|
1203
|
+
this.ensuring.delete(key);
|
|
1204
|
+
});
|
|
1205
|
+
this.ensuring.set(key, spawn);
|
|
1206
|
+
return spawn;
|
|
1207
|
+
}
|
|
1208
|
+
/** The model a spawn for this round would bind (the argv `--model` value). */
|
|
1209
|
+
spawnModel(key, spec) {
|
|
1210
|
+
if (spec.configuration !== void 0) return spec.configuration.model;
|
|
1211
|
+
const model = this.config.model?.(key, spec.model)?.trim();
|
|
1212
|
+
return model === void 0 || model === "" ? void 0 : model;
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* The model the member's resident runtime was spawned with, or undefined
|
|
1216
|
+
* when the member has no live runtime (or it binds none). The model broker
|
|
1217
|
+
* compares this against a switch's new effective model.
|
|
1218
|
+
*/
|
|
1219
|
+
runtimeModel(childSessionId) {
|
|
1220
|
+
return this.runtimes.get(childSessionId)?.model;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Retire the member's resident runtime so the next round respawns with its
|
|
1224
|
+
* new effective model (the model broker's switch path). The caller refuses
|
|
1225
|
+
* switches while a round is in flight, so the reclaim never kills a run;
|
|
1226
|
+
* the CLI session survives on disk and the respawn resumes it.
|
|
1227
|
+
*/
|
|
1228
|
+
async retireRuntime(childSessionId) {
|
|
1229
|
+
await (this.ensuring.get(childSessionId) ?? Promise.resolve()).catch(() => void 0);
|
|
1230
|
+
await this.reclaim(childSessionId);
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Spawn the member's resident stream-json process. There is no handshake
|
|
1234
|
+
* message — the channel proves itself with the first turn's system/init —
|
|
1235
|
+
* so spawn failure is the only breaker trip here; the init wait lives in
|
|
1236
|
+
* the round's accept window.
|
|
1237
|
+
*/
|
|
1238
|
+
async spawnRuntime(spec, signal) {
|
|
1239
|
+
const key = String(spec.childSession.id);
|
|
1240
|
+
if (this.disposed) throw new LiveChannelUnavailableError("the live driver is disposed");
|
|
1241
|
+
await syncClaudeCredentialFile(spec.homeDir, (message) => {
|
|
1242
|
+
this.ctx.logger.warn(message);
|
|
1243
|
+
}).catch(() => false);
|
|
1244
|
+
const member = registerClaudeMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
|
|
1245
|
+
const model = this.spawnModel(key, spec);
|
|
1246
|
+
if (spec.configuration === void 0 && this.config.provisionModel !== void 0) await this.config.provisionModel(spec.homeDir, model).catch(() => void 0);
|
|
1247
|
+
const spawnSpec = {
|
|
1248
|
+
argv: [
|
|
1249
|
+
"claude",
|
|
1250
|
+
"-p",
|
|
1251
|
+
"--verbose",
|
|
1252
|
+
...model === void 0 ? [] : ["--model", model],
|
|
1253
|
+
"--input-format",
|
|
1254
|
+
"stream-json",
|
|
1255
|
+
"--output-format",
|
|
1256
|
+
"stream-json",
|
|
1257
|
+
"--include-partial-messages",
|
|
1258
|
+
...(this.config.permissionMode ?? "skip") === "skip" ? ["--dangerously-skip-permissions"] : [],
|
|
1259
|
+
...spec.resume === void 0 ? [] : ["--resume", spec.resume.cliSessionId],
|
|
1260
|
+
...member === void 0 ? [] : [
|
|
1261
|
+
"--mcp-config",
|
|
1262
|
+
member.mcpConfig,
|
|
1263
|
+
"--allowedTools",
|
|
1264
|
+
member.allowedTool,
|
|
1265
|
+
"--"
|
|
1266
|
+
]
|
|
1267
|
+
],
|
|
1268
|
+
cwd: spec.cwd,
|
|
1269
|
+
stdio: {
|
|
1270
|
+
stdin: "pipe",
|
|
1271
|
+
stdout: "pipe",
|
|
1272
|
+
stderr: "pipe"
|
|
1273
|
+
},
|
|
1274
|
+
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
1275
|
+
env: delegationEnv({
|
|
1276
|
+
CLAUDE_CONFIG_DIR: spec.homeDir,
|
|
1277
|
+
...spec.configuration === void 0 ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? "auto" },
|
|
1278
|
+
...this.config.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.config.baseUrl }
|
|
1279
|
+
})
|
|
1280
|
+
};
|
|
1281
|
+
let child;
|
|
1282
|
+
try {
|
|
1283
|
+
child = this.ctx.subprocess.spawn(spawnSpec);
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
member?.release();
|
|
1286
|
+
this.markChannelBroken();
|
|
1287
|
+
throw new LiveChannelUnavailableError(`the stream-json process failed to spawn: ${thrown$1(error).message}`);
|
|
1288
|
+
}
|
|
1289
|
+
const runtime = new ClaudeLiveRuntime(child, (message) => {
|
|
1290
|
+
this.ctx.logger.warn(message);
|
|
1291
|
+
});
|
|
1292
|
+
runtime.model = model;
|
|
1293
|
+
runtime.configurationKey = spec.configuration === void 0 ? void 0 : JSON.stringify(spec.configuration);
|
|
1294
|
+
runtime.onDead = () => {
|
|
1295
|
+
if (this.runtimes.get(key) === runtime) this.runtimes.delete(key);
|
|
1296
|
+
this.clearIdleTimer(key);
|
|
1297
|
+
member?.release();
|
|
1298
|
+
};
|
|
1299
|
+
if (signal.aborted || this.disposeController.signal.aborted) {
|
|
1300
|
+
await runtime.reclaim();
|
|
1301
|
+
if (signal.aborted) throw new Error("subagent-claude: run cancelled locally");
|
|
1302
|
+
throw new LiveChannelUnavailableError("the live driver was disposed during spawn");
|
|
1303
|
+
}
|
|
1304
|
+
if (spec.configuration !== void 0) try {
|
|
1305
|
+
await runtime.configure(spec.configuration, signal);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
await runtime.reclaim();
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
this.channelBrokenAt = void 0;
|
|
1311
|
+
this.runtimes.set(key, runtime);
|
|
1312
|
+
return runtime;
|
|
1313
|
+
}
|
|
1314
|
+
markChannelBroken() {
|
|
1315
|
+
this.channelBrokenAt = Date.now();
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Drive one delegation round on the member's resident stream-json process.
|
|
1319
|
+
* Mirrors the exec run's settlement contract exactly (settleRunResult +
|
|
1320
|
+
* turn/end bookkeeping); cancel is the interrupt control request and the
|
|
1321
|
+
* process survives.
|
|
1322
|
+
*/
|
|
1323
|
+
/** Initialize native controls without sending a user prompt. Native history starts on its first real turn. */
|
|
1324
|
+
async prepare(spec, signal) {
|
|
1325
|
+
signal.throwIfAborted();
|
|
1326
|
+
if (this.draining || this.disabled) throw new LiveChannelUnavailableError("the live driver is unavailable for preparation");
|
|
1327
|
+
try {
|
|
1328
|
+
await this.ensureRuntime(spec, signal);
|
|
1329
|
+
signal.throwIfAborted();
|
|
1330
|
+
this.armIdleTimer(String(spec.childSession.id));
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
await this.reclaim(String(spec.childSession.id));
|
|
1333
|
+
throw error;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async startRound(request, spec) {
|
|
1337
|
+
if (this.draining) throw new LiveChannelUnavailableError("the live driver is draining (a settings change retired this generation)");
|
|
1338
|
+
const key = String(spec.childSession.id);
|
|
1339
|
+
const round = (this.roundChains.get(key) ?? Promise.resolve()).catch(() => {}).then(() => this.startRoundLocked(request, spec));
|
|
1340
|
+
this.roundChains.set(key, round.then((handle) => handle.result.catch(() => ({})), () => ({})));
|
|
1341
|
+
return round;
|
|
1342
|
+
}
|
|
1343
|
+
/** The serialized round body. */
|
|
1344
|
+
async startRoundLocked(request, spec) {
|
|
1345
|
+
const task = textTask(request.prompt);
|
|
1346
|
+
if (request.signal.aborted) throw new Error("subagent-claude: request was aborted before the run started");
|
|
1347
|
+
if (this.draining) throw new LiveChannelUnavailableError("the live driver is draining (a settings change retired this generation)");
|
|
1348
|
+
if (this.disposed) throw new Error("subagent-claude: the live driver is disposed");
|
|
1349
|
+
const turn = spec.resume?.turn ?? 1;
|
|
1350
|
+
const childSession = spec.childSession;
|
|
1351
|
+
const localAgent = this.ctx.get("localAgent");
|
|
1352
|
+
const runAbort = new AbortController();
|
|
1353
|
+
let roundSettled = false;
|
|
1354
|
+
let runtime;
|
|
1355
|
+
/** True while this round's turn is in flight (interrupt targets it). */
|
|
1356
|
+
let turnInFlight = false;
|
|
1357
|
+
/** True from the message going out until its result lands — the sink must
|
|
1358
|
+
* survive settlement in that window so the chain can converge. */
|
|
1359
|
+
let awaitingResult = false;
|
|
1360
|
+
let turnOpened = false;
|
|
1361
|
+
/** This turn's fold (the exec live mirror's exact parser). */
|
|
1362
|
+
const parser = new ClaudeStreamParser();
|
|
1363
|
+
let mirrored = 0;
|
|
1364
|
+
/**
|
|
1365
|
+
* The model and CLI build this turn's system/init named — the live
|
|
1366
|
+
* drive's half of the exec settle parse's read-back, reported as the
|
|
1367
|
+
* round's settled observation. EVERY turn re-emits its init, so a round
|
|
1368
|
+
* always observes its own.
|
|
1369
|
+
*/
|
|
1370
|
+
let roundModel;
|
|
1371
|
+
let roundCliVersion;
|
|
1372
|
+
/** Whether a folded line already carries the round's usage (no backfill event exists). */
|
|
1373
|
+
let usageCarried = false;
|
|
1374
|
+
/**
|
|
1375
|
+
* The step ledger: `reservedSteps` holds every step a stream reservation
|
|
1376
|
+
* consumed (in increasing order — permanently, whether the stream later
|
|
1377
|
+
* completes or stays an orphan snapshot), and a sequential line folds at
|
|
1378
|
+
* its index shifted past every reservation before it. A tool completed
|
|
1379
|
+
* mid-stream therefore folds ABOVE the stream's step and the projection
|
|
1380
|
+
* renders the round's chronological order.
|
|
1381
|
+
*/
|
|
1382
|
+
const reservedSteps = [];
|
|
1383
|
+
/**
|
|
1384
|
+
* The streaming kinds seen this round by stream key:
|
|
1385
|
+
* deltas accumulate into throttled snapshot assistant/messages appended
|
|
1386
|
+
* at the kind's reserved (turn, step) — the host folds repeated settles
|
|
1387
|
+
* at one coordinate into one live-updating chat node, which is the only
|
|
1388
|
+
* streaming channel left after 0.1.5 retired the durable per-chunk event.
|
|
1389
|
+
* The kind's completed line folds at the same step and finalizes it; an
|
|
1390
|
+
* entry whose completion never arrives is force-finalized at settle.
|
|
1391
|
+
*/
|
|
1392
|
+
const streams = /* @__PURE__ */ new Map();
|
|
1393
|
+
/** The stream deltas currently accumulate into (kinds stream sequentially). */
|
|
1394
|
+
let activeStream;
|
|
1395
|
+
let persistQueue = Promise.resolve();
|
|
1396
|
+
const persist = () => {
|
|
1397
|
+
persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
|
|
1398
|
+
this.ctx.logger.warn(`subagent-claude: live persistence failed: ${thrown$1(error).message}`);
|
|
1399
|
+
});
|
|
1400
|
+
};
|
|
1401
|
+
const requestCancel = () => {
|
|
1402
|
+
if (roundSettled || runAbort.signal.aborted) return;
|
|
1403
|
+
runAbort.abort(/* @__PURE__ */ new Error("subagent-claude: run cancelled locally"));
|
|
1404
|
+
if (turnInFlight && runtime !== void 0 && !runtime.dead) runtime.interrupt().catch(() => {});
|
|
1405
|
+
};
|
|
1406
|
+
const onAbort = () => {
|
|
1407
|
+
requestCancel();
|
|
1408
|
+
};
|
|
1409
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
1410
|
+
const abortBranch = new Promise((_, reject) => {
|
|
1411
|
+
runAbort.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("subagent-claude: run cancelled locally")), { once: true });
|
|
1412
|
+
});
|
|
1413
|
+
const collectOutput = () => {
|
|
1414
|
+
const text = parser.text?.trim();
|
|
1415
|
+
return text === void 0 || text === "" ? [] : [{
|
|
1416
|
+
type: "text",
|
|
1417
|
+
text: parser.text
|
|
1418
|
+
}];
|
|
1419
|
+
};
|
|
1420
|
+
let messageKey;
|
|
1421
|
+
let legacyMessage = 0;
|
|
1422
|
+
const streamKey = (kind, index) => `${messageKey ?? `legacy-${turn}-${legacyMessage}`}:${typeof index === "number" ? index : kind}`;
|
|
1423
|
+
/**
|
|
1424
|
+
* Append one snapshot of a streaming kind at its reserved (turn, step).
|
|
1425
|
+
* Batched per stream on a bounded deadline unless `force`; the forced
|
|
1426
|
+
* final snapshot carries `interrupted` (a cancelled turn reads 已停止
|
|
1427
|
+
* legitimately) and, when `withUsage` and no folded line carried it, the
|
|
1428
|
+
* round's usage.
|
|
1429
|
+
*/
|
|
1430
|
+
const streamPublisher = localAgent?.liveStreams === void 0 ? void 0 : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, (error) => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
|
|
1431
|
+
const liveFlush = new LiveFlush((error) => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
|
|
1432
|
+
const pendingStreamArrival = /* @__PURE__ */ new Map();
|
|
1433
|
+
const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
|
|
1434
|
+
if (stream.text.trim() === "") return;
|
|
1435
|
+
const now = Date.now();
|
|
1436
|
+
if (!force) {
|
|
1437
|
+
if (!pendingStreamArrival.has(stream.step)) pendingStreamArrival.set(stream.step, now);
|
|
1438
|
+
liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
liveFlush.cancel(stream.step);
|
|
1442
|
+
const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
|
|
1443
|
+
pendingStreamArrival.delete(stream.step);
|
|
1444
|
+
if (streamPublisher !== void 0 && !final) {
|
|
1445
|
+
if (!stream.opened) {
|
|
1446
|
+
childSession.append("step/start", {
|
|
1447
|
+
turn,
|
|
1448
|
+
step: stream.step
|
|
1449
|
+
});
|
|
1450
|
+
stream.opened = true;
|
|
1451
|
+
}
|
|
1452
|
+
streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
|
|
1453
|
+
stream.lastSnapshotAt = now;
|
|
1454
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (!stream.opened) {
|
|
1458
|
+
childSession.append("step/start", {
|
|
1459
|
+
turn,
|
|
1460
|
+
step: stream.step
|
|
1461
|
+
});
|
|
1462
|
+
stream.opened = true;
|
|
1463
|
+
}
|
|
1464
|
+
const usage = parser.usage;
|
|
1465
|
+
childSession.append("assistant/message", {
|
|
1466
|
+
turn,
|
|
1467
|
+
step: stream.step,
|
|
1468
|
+
message: assistantEvent([stream.kind === "think" ? {
|
|
1469
|
+
type: "reasoning",
|
|
1470
|
+
text: stream.text
|
|
1471
|
+
} : {
|
|
1472
|
+
type: "text",
|
|
1473
|
+
text: stream.text
|
|
1474
|
+
}]),
|
|
1475
|
+
stream: [],
|
|
1476
|
+
...withUsage && !usageCarried && usage !== void 0 ? { usage } : {},
|
|
1477
|
+
...interrupted ? { interrupted: true } : {}
|
|
1478
|
+
}, { surfaceOp: "append" });
|
|
1479
|
+
if (withUsage && !usageCarried && usage !== void 0) usageCarried = true;
|
|
1480
|
+
stream.lastSnapshotAt = now;
|
|
1481
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
1482
|
+
persist();
|
|
1483
|
+
};
|
|
1484
|
+
/**
|
|
1485
|
+
* Reserve the step one streaming kind will occupy — past every completed
|
|
1486
|
+
* line (folded or held back) and every earlier reservation. A different
|
|
1487
|
+
* kind's deltas force one final snapshot of the current stream (its
|
|
1488
|
+
* completion still folds at its own reserved step when it lands).
|
|
1489
|
+
*/
|
|
1490
|
+
const reserveStream = (itemId, kind) => {
|
|
1491
|
+
if (activeStream !== void 0 && activeStream !== itemId) {
|
|
1492
|
+
const previous = streams.get(activeStream);
|
|
1493
|
+
if (previous !== void 0 && previous.text.length !== previous.lastSnapshotLen) appendStreamSnapshot(previous, true, false);
|
|
1494
|
+
activeStream = void 0;
|
|
1495
|
+
}
|
|
1496
|
+
let stream = streams.get(itemId);
|
|
1497
|
+
if (stream === void 0) {
|
|
1498
|
+
const step = parser.lines.length + reservedSteps.length + 1;
|
|
1499
|
+
reservedSteps.push(step);
|
|
1500
|
+
stream = {
|
|
1501
|
+
itemId,
|
|
1502
|
+
kind,
|
|
1503
|
+
step,
|
|
1504
|
+
text: "",
|
|
1505
|
+
lastSnapshotAt: 0,
|
|
1506
|
+
lastSnapshotLen: 0,
|
|
1507
|
+
opened: false
|
|
1508
|
+
};
|
|
1509
|
+
streams.set(itemId, stream);
|
|
1510
|
+
}
|
|
1511
|
+
activeStream = itemId;
|
|
1512
|
+
};
|
|
1513
|
+
/** The sequential fold step for `parser.lines[index]`: its position shifted past every reservation before it. */
|
|
1514
|
+
const foldStep = (index) => {
|
|
1515
|
+
let step = index + 1;
|
|
1516
|
+
for (const reserved of reservedSteps) if (reserved <= step) step += 1;
|
|
1517
|
+
else break;
|
|
1518
|
+
return step;
|
|
1519
|
+
};
|
|
1520
|
+
/** Mirror folded lines [mirrored, upto); the last line is held back until `result`. */
|
|
1521
|
+
const mirrorUpTo = (upto, withUsage) => {
|
|
1522
|
+
let usageIndex = -1;
|
|
1523
|
+
if (withUsage) {
|
|
1524
|
+
for (let index = 0; index < parser.lines.length; index += 1) if (parser.lines[index]?.kind !== "tool") usageIndex = index;
|
|
1525
|
+
}
|
|
1526
|
+
for (let index = mirrored; index < upto; index += 1) {
|
|
1527
|
+
const line = parser.lines[index];
|
|
1528
|
+
if (line === void 0) continue;
|
|
1529
|
+
const lineUsage = withUsage && index === usageIndex ? parser.usage : void 0;
|
|
1530
|
+
if (lineUsage !== void 0) usageCarried = true;
|
|
1531
|
+
const stream = line.kind === "tool" ? void 0 : (line.streamId === void 0 ? void 0 : streams.get(line.streamId)) ?? [...streams.values()].find((candidate) => candidate.kind === line.kind && (line.streamId === void 0 || candidate.itemId.startsWith("legacy-")));
|
|
1532
|
+
const itemId = stream?.itemId;
|
|
1533
|
+
if (itemId !== void 0 && stream !== void 0) {
|
|
1534
|
+
liveFlush.cancel(stream.step);
|
|
1535
|
+
streams.delete(itemId);
|
|
1536
|
+
if (activeStream === itemId) activeStream = void 0;
|
|
1537
|
+
if (!stream.opened) childSession.append("step/start", {
|
|
1538
|
+
turn,
|
|
1539
|
+
step: stream.step
|
|
1540
|
+
});
|
|
1541
|
+
const blocks = stream.kind === "think" ? [{
|
|
1542
|
+
type: "reasoning",
|
|
1543
|
+
text: line.kind === "tool" ? "" : line.text
|
|
1544
|
+
}] : [{
|
|
1545
|
+
type: "text",
|
|
1546
|
+
text: line.kind === "tool" ? "" : line.text
|
|
1547
|
+
}];
|
|
1548
|
+
childSession.append("assistant/message", {
|
|
1549
|
+
turn,
|
|
1550
|
+
step: stream.step,
|
|
1551
|
+
message: assistantEvent(blocks),
|
|
1552
|
+
stream: [],
|
|
1553
|
+
...lineUsage === void 0 ? {} : { usage: lineUsage }
|
|
1554
|
+
}, { surfaceOp: "append" });
|
|
1555
|
+
streamPublisher?.finish(stream.step);
|
|
1556
|
+
childSession.append("step/end", {
|
|
1557
|
+
turn,
|
|
1558
|
+
step: stream.step
|
|
1559
|
+
});
|
|
1560
|
+
} else appendClaudeTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
|
|
1561
|
+
mirrored = index + 1;
|
|
1562
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1563
|
+
kind: "delta",
|
|
1564
|
+
text: claudeLineText(line)
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
if (upto > 0) persist();
|
|
1568
|
+
};
|
|
1569
|
+
let resolveResult;
|
|
1570
|
+
const resultEvent = new Promise((resolve) => {
|
|
1571
|
+
resolveResult = resolve;
|
|
1572
|
+
});
|
|
1573
|
+
let resolveInit;
|
|
1574
|
+
let rejectInit;
|
|
1575
|
+
const initArrived = new Promise((resolve, reject) => {
|
|
1576
|
+
resolveInit = resolve;
|
|
1577
|
+
rejectInit = reject;
|
|
1578
|
+
});
|
|
1579
|
+
/** The round's event sink (installed on the runtime while it runs). */
|
|
1580
|
+
const onEvent = (event) => {
|
|
1581
|
+
const type = event["type"];
|
|
1582
|
+
if (type === "system" && typeof event["session_id"] === "string") {
|
|
1583
|
+
if (runtime !== void 0 && runtime.sessionId === void 0) {
|
|
1584
|
+
runtime.sessionId = event["session_id"];
|
|
1585
|
+
resolveInit(runtime.sessionId);
|
|
1586
|
+
}
|
|
1587
|
+
if (typeof event["model"] === "string" && event["model"] !== "") roundModel = event["model"];
|
|
1588
|
+
roundCliVersion = claudeVersionFromInit(event["claude_code_version"]) ?? roundCliVersion;
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (type === "result") {
|
|
1592
|
+
awaitingResult = false;
|
|
1593
|
+
parser.push(JSON.stringify(event) + "\n");
|
|
1594
|
+
mirrorUpTo(parser.lines.length, true);
|
|
1595
|
+
resolveResult(event);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (type === "stream_event") {
|
|
1599
|
+
const inner = event["event"];
|
|
1600
|
+
if (inner?.["type"] === "message_start") {
|
|
1601
|
+
const message = inner["message"];
|
|
1602
|
+
messageKey = typeof message?.["id"] === "string" ? message["id"] : `legacy-${turn}-${++legacyMessage}`;
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const delta = inner?.["delta"];
|
|
1606
|
+
const deltaType = delta?.["type"];
|
|
1607
|
+
const text = delta?.["text"] ?? delta?.["thinking"];
|
|
1608
|
+
if ((deltaType === "text_delta" || deltaType === "thinking_delta") && typeof text === "string" && text !== "") {
|
|
1609
|
+
const kind = deltaType === "thinking_delta" ? "think" : "text";
|
|
1610
|
+
reserveStream(streamKey(kind, inner?.["index"]), kind);
|
|
1611
|
+
const stream = activeStream === void 0 ? void 0 : streams.get(activeStream);
|
|
1612
|
+
if (stream !== void 0) {
|
|
1613
|
+
stream.text += text;
|
|
1614
|
+
appendStreamSnapshot(stream, false, false);
|
|
1615
|
+
}
|
|
1616
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1617
|
+
kind: "delta",
|
|
1618
|
+
text
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (type !== "assistant" && type !== "user") return;
|
|
1624
|
+
parser.push(JSON.stringify(event) + "\n");
|
|
1625
|
+
mirrorUpTo(parser.lines.length - 1, false);
|
|
1626
|
+
if (type === "assistant") {
|
|
1627
|
+
messageKey = void 0;
|
|
1628
|
+
legacyMessage++;
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
const sendAndInit = async () => {
|
|
1632
|
+
if (runAbort.signal.aborted) throw new Error("subagent-claude: run cancelled locally");
|
|
1633
|
+
const rt = runtime;
|
|
1634
|
+
rt.onEvent = onEvent;
|
|
1635
|
+
turnInFlight = true;
|
|
1636
|
+
awaitingResult = true;
|
|
1637
|
+
rt.send({
|
|
1638
|
+
type: "user",
|
|
1639
|
+
message: {
|
|
1640
|
+
role: "user",
|
|
1641
|
+
content: [{
|
|
1642
|
+
type: "text",
|
|
1643
|
+
text: task
|
|
1644
|
+
}]
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
if (rt.sessionId === void 0) {
|
|
1648
|
+
const initWatchdog = setTimeout(() => {
|
|
1649
|
+
(async () => {
|
|
1650
|
+
this.markChannelBroken();
|
|
1651
|
+
await this.reclaim(String(childSession.id));
|
|
1652
|
+
rejectInit(new LiveChannelUnavailableError("the stream-json init never arrived"));
|
|
1653
|
+
})();
|
|
1654
|
+
}, this.timeouts.initMs);
|
|
1655
|
+
initWatchdog.unref();
|
|
1656
|
+
try {
|
|
1657
|
+
const cancelled = new Promise((_, reject) => {
|
|
1658
|
+
runAbort.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("subagent-claude: run cancelled locally")), { once: true });
|
|
1659
|
+
});
|
|
1660
|
+
const sessionId = await Promise.race([initArrived, cancelled]).catch(async (error) => {
|
|
1661
|
+
if (runAbort.signal.aborted && !(error instanceof LiveChannelUnavailableError)) await this.reclaim(String(childSession.id));
|
|
1662
|
+
throw error;
|
|
1663
|
+
});
|
|
1664
|
+
spec.onSessionId?.(sessionId);
|
|
1665
|
+
} finally {
|
|
1666
|
+
clearTimeout(initWatchdog);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
const accepted = (async () => {
|
|
1671
|
+
const rt = await this.ensureRuntime(spec, request.signal);
|
|
1672
|
+
runtime = rt;
|
|
1673
|
+
if (runAbort.signal.aborted) {
|
|
1674
|
+
await this.reclaim(String(childSession.id));
|
|
1675
|
+
throw new Error("subagent-claude: run cancelled locally");
|
|
1676
|
+
}
|
|
1677
|
+
childSession.append("turn/start", { turn });
|
|
1678
|
+
childSession.append("user/message", createUserMessage({
|
|
1679
|
+
content: [{
|
|
1680
|
+
type: "text",
|
|
1681
|
+
text: task
|
|
1682
|
+
}],
|
|
1683
|
+
source: { kind: "user" }
|
|
1684
|
+
}), { surfaceOp: "append" });
|
|
1685
|
+
turnOpened = true;
|
|
1686
|
+
persist();
|
|
1687
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1688
|
+
kind: "delta",
|
|
1689
|
+
text: task
|
|
1690
|
+
});
|
|
1691
|
+
rt.turnChain = rt.turnChain.catch(() => {}).then(sendAndInit);
|
|
1692
|
+
await rt.turnChain;
|
|
1693
|
+
})();
|
|
1694
|
+
const attempt = accepted.then(async () => {
|
|
1695
|
+
if (runAbort.signal.aborted) throw new Error("subagent-claude: run cancelled locally");
|
|
1696
|
+
const rt = runtime;
|
|
1697
|
+
const processFailure = rt.child.done.then((outcome) => Promise.reject(/* @__PURE__ */ new Error(`subagent-claude: the live runtime exited mid-round (code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown$1(error)));
|
|
1698
|
+
processFailure.catch(() => {});
|
|
1699
|
+
const resultPromise = Promise.race([resultEvent, processFailure]);
|
|
1700
|
+
rt.turnChain = resultPromise;
|
|
1701
|
+
const event = await resultPromise;
|
|
1702
|
+
if (event["is_error"] === true) {
|
|
1703
|
+
const message = typeof event["error"] === "string" ? event["error"] : "claude reported an error";
|
|
1704
|
+
throw new Error(`subagent-claude live: ${message}`);
|
|
1705
|
+
}
|
|
1706
|
+
const output = collectOutput();
|
|
1707
|
+
if (output.length === 0) throw new Error("subagent-claude live: the turn completed but produced no answer");
|
|
1708
|
+
return {
|
|
1709
|
+
output,
|
|
1710
|
+
stopReason: "completed"
|
|
1711
|
+
};
|
|
1712
|
+
});
|
|
1713
|
+
const result = settleRunResult({
|
|
1714
|
+
attempt: () => Promise.race([attempt, abortBranch]),
|
|
1715
|
+
collectOutput,
|
|
1716
|
+
cancelled: () => runAbort.signal.aborted,
|
|
1717
|
+
onError: (error, stopReason) => {
|
|
1718
|
+
const diagnostics = runtime?.diagnostics ?? "";
|
|
1719
|
+
const suffix = diagnostics === "" ? "" : `; ${diagnostics}`;
|
|
1720
|
+
this.ctx.logger.warn(`subagent-claude: live round failed (${stopReason}): ${error.message}${suffix}`);
|
|
1721
|
+
},
|
|
1722
|
+
signal: request.signal,
|
|
1723
|
+
onAbort
|
|
1724
|
+
}).then(async (settled) => {
|
|
1725
|
+
roundSettled = true;
|
|
1726
|
+
liveFlush.dispose();
|
|
1727
|
+
turnInFlight = false;
|
|
1728
|
+
if (turnOpened) {
|
|
1729
|
+
if (mirrored < parser.lines.length) mirrorUpTo(parser.lines.length, true);
|
|
1730
|
+
if (streams.size > 0) {
|
|
1731
|
+
const remaining = [...streams.values()];
|
|
1732
|
+
for (const [position, stream] of remaining.entries()) {
|
|
1733
|
+
appendStreamSnapshot(stream, true, settled.stopReason !== "completed", position === remaining.length - 1, true);
|
|
1734
|
+
streamPublisher?.finish(stream.step);
|
|
1735
|
+
if (stream.opened) childSession.append("step/end", {
|
|
1736
|
+
turn,
|
|
1737
|
+
step: stream.step
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
streams.clear();
|
|
1741
|
+
activeStream = void 0;
|
|
1742
|
+
}
|
|
1743
|
+
if (settled.stopReason === "completed") childSession.append("turn/end", {
|
|
1744
|
+
turn,
|
|
1745
|
+
reason: { kind: "completed" }
|
|
1746
|
+
});
|
|
1747
|
+
else if (settled.stopReason === "aborted") childSession.append("turn/end", {
|
|
1748
|
+
turn,
|
|
1749
|
+
reason: {
|
|
1750
|
+
kind: "aborted",
|
|
1751
|
+
reason: { kind: "parent" }
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
else childSession.append("turn/end", {
|
|
1755
|
+
turn,
|
|
1756
|
+
reason: {
|
|
1757
|
+
kind: "error",
|
|
1758
|
+
error: {
|
|
1759
|
+
message: "the live round did not complete",
|
|
1760
|
+
code: "UNKNOWN"
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
if (runtime !== void 0 && runtime.onEvent === onEvent && !awaitingResult) runtime.onEvent = void 0;
|
|
1766
|
+
streamPublisher?.dispose();
|
|
1767
|
+
if (turnOpened) {
|
|
1768
|
+
persist();
|
|
1769
|
+
await persistQueue;
|
|
1770
|
+
}
|
|
1771
|
+
return settled;
|
|
1772
|
+
});
|
|
1773
|
+
result.then(async () => {
|
|
1774
|
+
try {
|
|
1775
|
+
if (turnOpened) {
|
|
1776
|
+
await persistQueue.catch(() => {});
|
|
1777
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1778
|
+
kind: "mirror",
|
|
1779
|
+
mirroredLines: mirrored
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
} finally {
|
|
1783
|
+
if (turnOpened) {
|
|
1784
|
+
const toolCalls = toolCallsOf(parser.toolCalls);
|
|
1785
|
+
const round = {
|
|
1786
|
+
...roundModel === void 0 ? {} : { observedModel: roundModel },
|
|
1787
|
+
...roundCliVersion === void 0 ? {} : { cliVersion: roundCliVersion },
|
|
1788
|
+
...parser.usage === void 0 ? {} : { usage: parser.usage },
|
|
1789
|
+
...toolCalls === void 0 ? {} : { toolCalls }
|
|
1790
|
+
};
|
|
1791
|
+
localAgent?.recordRoundSettled?.(childSession.id, round);
|
|
1792
|
+
}
|
|
1793
|
+
this.armIdleTimer(String(childSession.id));
|
|
1794
|
+
}
|
|
1795
|
+
});
|
|
1796
|
+
await Promise.race([accepted, abortBranch.catch(() => void 0)]);
|
|
1797
|
+
return subprocessRunHandle({
|
|
1798
|
+
id: childSession.id,
|
|
1799
|
+
result,
|
|
1800
|
+
signal: request.signal,
|
|
1801
|
+
onAbort,
|
|
1802
|
+
requestCancel,
|
|
1803
|
+
teardown: async () => {
|
|
1804
|
+
if (roundSettled) return;
|
|
1805
|
+
await Promise.race([resultEvent.catch(() => ({})), delay(this.timeouts.convergeMs)]);
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
//#endregion
|
|
1811
|
+
//#region lib/types/claude-cli-provider.js
|
|
1812
|
+
/**
|
|
1813
|
+
* One-shot Claude Code CLI subagent lifecycle: spawn `claude -p "<task>"`
|
|
1814
|
+
* through the subprocess seam under the harness's scoped home, capture its
|
|
1815
|
+
* JSON result as the run output, and dispose to whole-tree quiescence.
|
|
1816
|
+
* Mirrors the kimi/codex providers: every accepted run is a fresh process
|
|
1817
|
+
* and a fresh claude session; there is no continuation across runs.
|
|
1818
|
+
*
|
|
1819
|
+
* The provider name `claude-local` avoids colliding with the official
|
|
1820
|
+
* `subagent-claude-code` package's provider name `claude-code` — a
|
|
1821
|
+
* composition that mounts both would fail loud with DUPLICATE_PROVIDER. The
|
|
1822
|
+
* tool row in this bundle's patch takes the official model-facing name
|
|
1823
|
+
* `subagent_claude_code` instead: the official preset row ships disabled,
|
|
1824
|
+
* and the patch disables it too (a deliberate user re-enable conflicts
|
|
1825
|
+
* loud, by design).
|
|
1826
|
+
* @module @khorsheed/dsh-local-agent-claude-code/claude-cli-provider
|
|
1827
|
+
*/
|
|
1828
|
+
/** Default POSIX grace between subprocess termination tiers. */
|
|
1829
|
+
const DEFAULT_DISPOSE_GRACE_MS = 3e3;
|
|
1830
|
+
/**
|
|
1831
|
+
* Auth-shaped failure signatures in claude's result error or stderr: the
|
|
1832
|
+
* endpoint rejected the credential (401/403, revoked token). Narrow on
|
|
1833
|
+
* purpose — matched against provider-controlled error strings only.
|
|
1834
|
+
*/
|
|
1835
|
+
const CLAUDE_AUTH_FAILURE = /failed to authenticate|authentication_failed|oauth access token/i;
|
|
1836
|
+
/** Register one member run with the channel; see {@link ClaudeMemberRunHandle}. */
|
|
1837
|
+
function registerClaudeMemberRun(ctx, childSessionId, parentSessionId) {
|
|
1838
|
+
const registry = ctx.localAgent;
|
|
1839
|
+
if (typeof registry.registerMemberRun !== "function" || typeof registry.memberBridgeSocketPath !== "function" || typeof registry.memberBridgeCommand !== "function") return void 0;
|
|
1840
|
+
const token = registry.registerMemberRun({
|
|
1841
|
+
childSessionId,
|
|
1842
|
+
parentSessionId,
|
|
1843
|
+
provider: "claude-local"
|
|
1844
|
+
});
|
|
1845
|
+
const serverName = `dsh-member-${token.slice(0, 8)}`;
|
|
1846
|
+
const bridge = registry.memberBridgeCommand();
|
|
1847
|
+
const mcpConfig = JSON.stringify({ mcpServers: { [serverName]: {
|
|
1848
|
+
command: bridge.command,
|
|
1849
|
+
args: bridge.args,
|
|
1850
|
+
env: {
|
|
1851
|
+
[MEMBER_BRIDGE_SOCKET_ENV]: registry.memberBridgeSocketPath(),
|
|
1852
|
+
[MEMBER_BRIDGE_TOKEN_ENV]: token
|
|
1853
|
+
}
|
|
1854
|
+
} } });
|
|
1855
|
+
let released = false;
|
|
1856
|
+
return {
|
|
1857
|
+
token,
|
|
1858
|
+
mcpConfig,
|
|
1859
|
+
allowedTool: `mcp__${serverName}__member_message`,
|
|
1860
|
+
release: () => {
|
|
1861
|
+
if (released) return;
|
|
1862
|
+
released = true;
|
|
1863
|
+
registry.unregisterMemberRun(token);
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* One-shot and resumable Claude Code CLI subagent provider: every accepted
|
|
1869
|
+
* FRESH run starts a `claude -p` process in the delegating Session's
|
|
1870
|
+
* workspace, under the harness scoped home; a resume round (the family tool's
|
|
1871
|
+
* staged resume intent) continues the SAME session with `claude -p --resume
|
|
1872
|
+
* <session_id>` inside the SAME dsh child session. With the live driver
|
|
1873
|
+
* configured (`live: true`), rounds instead go to the resident stream-json
|
|
1874
|
+
* process (see live-driver.ts); the exec path below stays the fallback.
|
|
1875
|
+
*/
|
|
1876
|
+
/**
|
|
1877
|
+
* Mark the harness credential verified after a completed round, degrading
|
|
1878
|
+
* silently on a core that predates the mark (the family's companion-pair
|
|
1879
|
+
* rule: a provider paired with an older core loses the grade, never the run).
|
|
1880
|
+
* @param ctx - host context carrying the family registry.
|
|
1881
|
+
*/
|
|
1882
|
+
function markCredentialVerified(ctx, scope) {
|
|
1883
|
+
const registry = ctx.get("localAgent");
|
|
1884
|
+
if (registry === void 0 || typeof registry.reportAuthSuccess !== "function") return;
|
|
1885
|
+
registry.reportAuthSuccess("claude-code", scope);
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* The version `claude --version` reports, probed through the shared subprocess
|
|
1889
|
+
* seam against the SCOPED config directory (never the user's own
|
|
1890
|
+
* installation) and cached by the family probe against the executable's
|
|
1891
|
+
* identity. The effective-settings snapshot uses it; a settled round prefers
|
|
1892
|
+
* the version its own stream named.
|
|
1893
|
+
* @param ctx - host context carrying the subprocess seam.
|
|
1894
|
+
* @param homeDir - the harness's scoped home.
|
|
1895
|
+
* @returns the version, or undefined when the CLI cannot be asked.
|
|
1896
|
+
*/
|
|
1897
|
+
function claudeCliVersion(ctx, homeDir) {
|
|
1898
|
+
const subprocess = ctx.get("subprocess");
|
|
1899
|
+
if (subprocess === void 0) return Promise.resolve(void 0);
|
|
1900
|
+
return probeCliVersion({
|
|
1901
|
+
argv: ["claude", "--version"],
|
|
1902
|
+
cwd: homeDir,
|
|
1903
|
+
spawn: (spec) => subprocess.spawn(spec),
|
|
1904
|
+
env: delegationEnv({ CLAUDE_CONFIG_DIR: homeDir })
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
var ClaudeCliProvider = class {
|
|
1908
|
+
ctx;
|
|
1909
|
+
permissionMode;
|
|
1910
|
+
baseUrl;
|
|
1911
|
+
live;
|
|
1912
|
+
model;
|
|
1913
|
+
name = "claude-local";
|
|
1914
|
+
capabilities = NO_START_CAPABILITIES;
|
|
1915
|
+
inheritsParentContext = false;
|
|
1916
|
+
/**
|
|
1917
|
+
* @param live - the live driver, or a resolver returning the current
|
|
1918
|
+
* generation's driver per member (the settings toggle swaps generations;
|
|
1919
|
+
* a resolver may return undefined to steer one member's round to exec
|
|
1920
|
+
* while a retiring generation still hosts it).
|
|
1921
|
+
* @param model - resolver for the member's effective model, read PER ROUND
|
|
1922
|
+
* so a settings-card write or a composer switch takes effect on the next
|
|
1923
|
+
* delegation without a reload. Receives the member (the child session id)
|
|
1924
|
+
* and the round's delegation-layer model, and answers in the family's
|
|
1925
|
+
* order — session-level override, delegation, settings. Absent (the
|
|
1926
|
+
* resolver not passed), the delegation's own model still rides, exactly
|
|
1927
|
+
* as before the key existed.
|
|
1928
|
+
*/
|
|
1929
|
+
constructor(ctx, permissionMode = "skip", baseUrl, live, model) {
|
|
1930
|
+
this.ctx = ctx;
|
|
1931
|
+
this.permissionMode = permissionMode;
|
|
1932
|
+
this.baseUrl = baseUrl;
|
|
1933
|
+
this.live = live;
|
|
1934
|
+
this.model = model;
|
|
1935
|
+
}
|
|
1936
|
+
/** Resolve the live driver for one round's member, if live is on for it. */
|
|
1937
|
+
liveDriver(childSessionId) {
|
|
1938
|
+
const live = this.live;
|
|
1939
|
+
if (live === void 0) return void 0;
|
|
1940
|
+
return typeof live === "function" ? live(childSessionId) : live;
|
|
1941
|
+
}
|
|
1942
|
+
/** The round's model fragment for the exec spawn spec (see {@link resolveRoundModel}). */
|
|
1943
|
+
roundModel(childSessionId, requestedModel) {
|
|
1944
|
+
return resolveRoundModel(this.model === void 0 ? requestedModel : this.model(childSessionId, requestedModel));
|
|
1945
|
+
}
|
|
1946
|
+
/** Per-round member-channel registration for the exec path (see {@link registerClaudeMemberRun}). */
|
|
1947
|
+
memberRun(childSessionId, parentSessionId) {
|
|
1948
|
+
return registerClaudeMemberRun(this.ctx, childSessionId, parentSessionId);
|
|
1949
|
+
}
|
|
1950
|
+
async start(request) {
|
|
1951
|
+
const intent = this.ctx.localAgent.takeDelegationIntent(request.parent.session.id, this.name);
|
|
1952
|
+
const scope = intent?.scope;
|
|
1953
|
+
const homeDir = this.ctx.localAgent.homeDir("claude-code", scope);
|
|
1954
|
+
const cwd = resolveChildCwd(request.parent.session.header.cwd, intent?.cwd);
|
|
1955
|
+
if (cwd === void 0) throw new Error("subagent-claude: the parent session has no working directory to run the CLI in");
|
|
1956
|
+
const exec = intent?.exec;
|
|
1957
|
+
if (exec !== void 0) containerScopedHome(exec, "CLAUDE_CONFIG_DIR", "subagent-claude");
|
|
1958
|
+
if (intent !== void 0 && intent.kind === "resume") {
|
|
1959
|
+
const record = this.ctx.localAgent.getDelegation(intent.childSessionId);
|
|
1960
|
+
assertResumeCwdUnchanged(record, cwd, "subagent-claude");
|
|
1961
|
+
assertResumeScopeUnchanged(record, scope, "subagent-claude");
|
|
1962
|
+
if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") return this.startClaudeResume(request, intent, cwd, homeDir, exec, scope, record?.model);
|
|
1963
|
+
return this.ctx.localAgent.withMemberConfigurationRound({
|
|
1964
|
+
childSessionId: intent.childSessionId,
|
|
1965
|
+
provider: this.name,
|
|
1966
|
+
parentSessionId: request.parent.session.id,
|
|
1967
|
+
cwd,
|
|
1968
|
+
...scope === void 0 ? {} : { scope },
|
|
1969
|
+
...record?.model === void 0 ? {} : { model: record.model },
|
|
1970
|
+
...record?.effort === void 0 ? {} : { effort: record.effort },
|
|
1971
|
+
...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
|
|
1972
|
+
}, (configuration) => this.startClaudeResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
|
|
1973
|
+
}
|
|
1974
|
+
const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
|
|
1975
|
+
if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
|
|
1976
|
+
if (intent?.effort !== void 0) throw new Error("Claude effort requires the configuration admission core");
|
|
1977
|
+
return this.startClaudeFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
|
|
1978
|
+
}
|
|
1979
|
+
return this.ctx.localAgent.withMemberConfigurationRound({
|
|
1980
|
+
childSessionId,
|
|
1981
|
+
provider: this.name,
|
|
1982
|
+
parentSessionId: request.parent.session.id,
|
|
1983
|
+
cwd,
|
|
1984
|
+
...scope === void 0 ? {} : { scope },
|
|
1985
|
+
...intent?.model === void 0 ? {} : { model: intent.model },
|
|
1986
|
+
...intent?.effort === void 0 ? {} : { effort: intent.effort },
|
|
1987
|
+
...intent?.configurationLock === void 0 ? {} : { configurationLock: intent.configurationLock }
|
|
1988
|
+
}, (configuration) => this.startClaudeFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId, configuration), request.signal, intent?.onAdmitted);
|
|
1989
|
+
}
|
|
1990
|
+
/** Fresh round: record the child session, spawn `claude -p`, append after settle. */
|
|
1991
|
+
async startClaudeFresh(request, cwd, homeDir, exec, scope, requestedModel, runId, configuration) {
|
|
1992
|
+
let childSession;
|
|
1993
|
+
try {
|
|
1994
|
+
const sessions = this.ctx.get("sessions");
|
|
1995
|
+
if (sessions === void 0) throw new Error("the sessions service is not mounted");
|
|
1996
|
+
childSession = sessions.get(runId) ?? sessions.create(runId, { meta: {
|
|
1997
|
+
cwd,
|
|
1998
|
+
parentSession: request.parent.session.id,
|
|
1999
|
+
origin: "subagent",
|
|
2000
|
+
delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
|
|
2001
|
+
} });
|
|
2002
|
+
const harness = this.ctx.localAgent.get("claude-code");
|
|
2003
|
+
const label = subagentDelegationLabel(harness?.displayName ?? "Claude Code", request.descriptor.label);
|
|
2004
|
+
childSession.append("subagent/descriptor", {
|
|
2005
|
+
...request.descriptor,
|
|
2006
|
+
label
|
|
2007
|
+
});
|
|
2008
|
+
establishSubagentCatalogChild(request.parent.session, childSession.header, label);
|
|
2009
|
+
} catch (error) {
|
|
2010
|
+
this.ctx.logger.warn(`subagent-claude: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2011
|
+
}
|
|
2012
|
+
const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
|
|
2013
|
+
this.ctx.logger.info(`subagent-claude: delegating via ${effectiveBaseUrl ?? "claude default endpoint"}`);
|
|
2014
|
+
const live = exec === void 0 ? this.liveDriver(runId) : void 0;
|
|
2015
|
+
if (live !== void 0 && childSession !== void 0 && !live.disabled) try {
|
|
2016
|
+
return await live.startRound(request, {
|
|
2017
|
+
cwd,
|
|
2018
|
+
homeDir,
|
|
2019
|
+
childSession,
|
|
2020
|
+
...configuration === void 0 ? {} : { configuration: configuration.resolved },
|
|
2021
|
+
parentSessionId: request.parent.session.id,
|
|
2022
|
+
...requestedModel === void 0 ? {} : { model: requestedModel },
|
|
2023
|
+
onSessionId: (sessionId) => {
|
|
2024
|
+
this.ctx.localAgent.recordDelegation({
|
|
2025
|
+
childSessionId: runId,
|
|
2026
|
+
provider: this.name,
|
|
2027
|
+
parentSessionId: request.parent.session.id,
|
|
2028
|
+
cliSessionId: sessionId,
|
|
2029
|
+
cwd,
|
|
2030
|
+
...scope === void 0 ? {} : { scope },
|
|
2031
|
+
...requestedModel === void 0 ? {} : { model: requestedModel }
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
|
|
2037
|
+
this.ctx.logger.warn(`subagent-claude: live driver unavailable, using the exec one-shot: ${error.message}`);
|
|
2038
|
+
}
|
|
2039
|
+
const member = exec === void 0 ? this.memberRun(runId, request.parent.session.id) : void 0;
|
|
2040
|
+
try {
|
|
2041
|
+
const run = await startClaudeCliRun(request, {
|
|
2042
|
+
cwd,
|
|
2043
|
+
env: delegationEnv({
|
|
2044
|
+
CLAUDE_CONFIG_DIR: homeDir,
|
|
2045
|
+
...this.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.baseUrl }
|
|
2046
|
+
}),
|
|
2047
|
+
...exec === void 0 ? {} : { exec },
|
|
2048
|
+
endpointLabel: effectiveBaseUrl,
|
|
2049
|
+
permissionMode: this.permissionMode,
|
|
2050
|
+
...configuration?.resolved ?? this.roundModel(runId, requestedModel),
|
|
2051
|
+
...configuration === void 0 ? {} : { controlled: true },
|
|
2052
|
+
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
2053
|
+
spawn: (spec) => this.ctx.subprocess.spawn(spec),
|
|
2054
|
+
onError: (error, stopReason) => {
|
|
2055
|
+
this.ctx.logger.warn(`subagent-claude: child run failed (${stopReason}) via ${effectiveBaseUrl ?? "claude default endpoint"}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2056
|
+
},
|
|
2057
|
+
onAuthFailure: (detail) => {
|
|
2058
|
+
this.ctx.localAgent.reportAuthFailure("claude-code", detail, scope);
|
|
2059
|
+
},
|
|
2060
|
+
onAuthSuccess: () => {
|
|
2061
|
+
markCredentialVerified(this.ctx, scope);
|
|
2062
|
+
},
|
|
2063
|
+
cliVersion: () => claudeCliVersion(this.ctx, homeDir),
|
|
2064
|
+
...member === void 0 ? {} : { member: {
|
|
2065
|
+
mcpConfig: member.mcpConfig,
|
|
2066
|
+
allowedTool: member.allowedTool
|
|
2067
|
+
} },
|
|
2068
|
+
childSession,
|
|
2069
|
+
ctx: this.ctx,
|
|
2070
|
+
onSessionId: (sessionId) => {
|
|
2071
|
+
if (sessionId === void 0) return;
|
|
2072
|
+
this.ctx.localAgent.recordDelegation({
|
|
2073
|
+
childSessionId: runId,
|
|
2074
|
+
provider: this.name,
|
|
2075
|
+
parentSessionId: request.parent.session.id,
|
|
2076
|
+
cliSessionId: sessionId,
|
|
2077
|
+
cwd,
|
|
2078
|
+
...scope === void 0 ? {} : { scope },
|
|
2079
|
+
...requestedModel === void 0 ? {} : { model: requestedModel }
|
|
2080
|
+
});
|
|
2081
|
+
},
|
|
2082
|
+
onRoundSettled: (round) => {
|
|
2083
|
+
this.ctx.localAgent.recordRoundSettled(runId, round);
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
if (member !== void 0) run.result.then(member.release, member.release);
|
|
2087
|
+
return run;
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
member?.release();
|
|
2090
|
+
throw error;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
/** Resume round: continue the recorded claude session inside the existing child session. */
|
|
2094
|
+
async startClaudeResume(request, intent, cwd, homeDir, exec, scope, requestedModel, configuration) {
|
|
2095
|
+
if (!this.ctx.localAgent.acquireResumeLock(intent.childSessionId)) throw new Error(`subagent-claude: 该子会话有进行中的委派,等其完成后再追问 (child session ${intent.childSessionId})`);
|
|
2096
|
+
try {
|
|
2097
|
+
const childSession = this.ctx.get("sessions")?.get(SessionId(intent.childSessionId));
|
|
2098
|
+
if (childSession === void 0) throw new Error(`subagent-claude: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
|
|
2099
|
+
const nextTurn = childSession.snapshotEvents().filter((event) => event.type === "turn/start").length + 1;
|
|
2100
|
+
const live = exec === void 0 ? this.liveDriver(intent.childSessionId) : void 0;
|
|
2101
|
+
if (live !== void 0 && !live.disabled) try {
|
|
2102
|
+
const liveRun = await live.startRound(request, {
|
|
2103
|
+
cwd,
|
|
2104
|
+
homeDir,
|
|
2105
|
+
childSession,
|
|
2106
|
+
...configuration === void 0 ? {} : { configuration: configuration.resolved },
|
|
2107
|
+
parentSessionId: request.parent.session.id,
|
|
2108
|
+
resume: {
|
|
2109
|
+
cliSessionId: intent.cliSessionId,
|
|
2110
|
+
turn: nextTurn
|
|
2111
|
+
},
|
|
2112
|
+
...requestedModel === void 0 ? {} : { model: requestedModel }
|
|
2113
|
+
});
|
|
2114
|
+
liveRun.result.then(() => {
|
|
2115
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2116
|
+
}, () => {
|
|
2117
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2118
|
+
});
|
|
2119
|
+
return liveRun;
|
|
2120
|
+
} catch (error) {
|
|
2121
|
+
if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
|
|
2122
|
+
this.ctx.logger.warn(`subagent-claude: live driver unavailable, using the exec one-shot: ${error.message}`);
|
|
2123
|
+
}
|
|
2124
|
+
const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
|
|
2125
|
+
this.ctx.logger.info(`subagent-claude: resuming via ${effectiveBaseUrl ?? "claude default endpoint"}`);
|
|
2126
|
+
const member = exec === void 0 ? this.memberRun(intent.childSessionId, request.parent.session.id) : void 0;
|
|
2127
|
+
let run;
|
|
2128
|
+
try {
|
|
2129
|
+
run = await startClaudeCliRun(request, {
|
|
2130
|
+
cwd,
|
|
2131
|
+
env: delegationEnv({
|
|
2132
|
+
CLAUDE_CONFIG_DIR: homeDir,
|
|
2133
|
+
...this.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.baseUrl }
|
|
2134
|
+
}),
|
|
2135
|
+
...exec === void 0 ? {} : { exec },
|
|
2136
|
+
endpointLabel: effectiveBaseUrl,
|
|
2137
|
+
permissionMode: this.permissionMode,
|
|
2138
|
+
...configuration?.resolved ?? this.roundModel(intent.childSessionId, requestedModel),
|
|
2139
|
+
...configuration === void 0 ? {} : { controlled: true },
|
|
2140
|
+
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
2141
|
+
spawn: (spec) => this.ctx.subprocess.spawn(spec),
|
|
2142
|
+
onError: (error, stopReason) => {
|
|
2143
|
+
this.ctx.logger.warn(`subagent-claude: child run failed (${stopReason}) via ${effectiveBaseUrl ?? "claude default endpoint"}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2144
|
+
},
|
|
2145
|
+
onAuthFailure: (detail) => {
|
|
2146
|
+
this.ctx.localAgent.reportAuthFailure("claude-code", detail, scope);
|
|
2147
|
+
},
|
|
2148
|
+
onAuthSuccess: () => {
|
|
2149
|
+
markCredentialVerified(this.ctx, scope);
|
|
2150
|
+
},
|
|
2151
|
+
cliVersion: () => claudeCliVersion(this.ctx, homeDir),
|
|
2152
|
+
...member === void 0 ? {} : { member: {
|
|
2153
|
+
mcpConfig: member.mcpConfig,
|
|
2154
|
+
allowedTool: member.allowedTool
|
|
2155
|
+
} },
|
|
2156
|
+
childSession,
|
|
2157
|
+
ctx: this.ctx,
|
|
2158
|
+
resume: {
|
|
2159
|
+
cliSessionId: intent.cliSessionId,
|
|
2160
|
+
turn: nextTurn
|
|
2161
|
+
},
|
|
2162
|
+
onRoundSettled: (round) => {
|
|
2163
|
+
this.ctx.localAgent.recordRoundSettled(intent.childSessionId, round);
|
|
2164
|
+
}
|
|
2165
|
+
});
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
member?.release();
|
|
2168
|
+
throw error;
|
|
2169
|
+
}
|
|
2170
|
+
run.result.then(() => {
|
|
2171
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2172
|
+
member?.release();
|
|
2173
|
+
}, () => {
|
|
2174
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2175
|
+
member?.release();
|
|
2176
|
+
});
|
|
2177
|
+
return run;
|
|
2178
|
+
} catch (error) {
|
|
2179
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
2180
|
+
throw error;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
function thrown(value) {
|
|
2185
|
+
/* v8 ignore next -- typed subprocess failures reject with Error. */
|
|
2186
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Validate and join the one-shot task before crossing the process boundary.
|
|
2190
|
+
* @param prompt - task content accepted from the shared subagent service.
|
|
2191
|
+
* @returns the joined non-empty text.
|
|
2192
|
+
*/
|
|
2193
|
+
function textTask(prompt) {
|
|
2194
|
+
if (prompt.length === 0) throw new Error("subagent-claude: the one-shot task must contain only text blocks");
|
|
2195
|
+
const texts = [];
|
|
2196
|
+
for (const block of prompt) {
|
|
2197
|
+
if (block.type !== "text") throw new Error("subagent-claude: the one-shot task must contain only text blocks");
|
|
2198
|
+
texts.push(block.text);
|
|
2199
|
+
}
|
|
2200
|
+
if (texts.every((text) => text.trim().length === 0)) throw new Error("subagent-claude: the one-shot task must not be empty");
|
|
2201
|
+
return texts.join("\n");
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Fold a tool-call counter into the settled shape: `{count, byName}` with the
|
|
2205
|
+
* per-name values summing to `count`. An empty map yields undefined — a
|
|
2206
|
+
* stream that named no tool call reports nothing rather than a zero it did
|
|
2207
|
+
* not observe.
|
|
2208
|
+
* @param counts - the fold's per-name tally.
|
|
2209
|
+
* @returns the settled accounting, or undefined when nothing was counted.
|
|
2210
|
+
*/
|
|
2211
|
+
function toolCallsOf(counts) {
|
|
2212
|
+
if (counts.size === 0) return void 0;
|
|
2213
|
+
let count = 0;
|
|
2214
|
+
const byName = {};
|
|
2215
|
+
for (const [name, value] of counts) {
|
|
2216
|
+
count += value;
|
|
2217
|
+
byName[name] = value;
|
|
2218
|
+
}
|
|
2219
|
+
return {
|
|
2220
|
+
count,
|
|
2221
|
+
byName
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Translate one TodoWrite tool input into the dsh whole-list snapshot.
|
|
2226
|
+
* Claude's documented schema is `{todos: [{content, status, activeForm}]}`
|
|
2227
|
+
* with status `pending | in_progress | completed` — dsh's TodoItem vocabulary
|
|
2228
|
+
* minus `activeForm` (display-only). Unknown statuses map to `pending`; a
|
|
2229
|
+
* shape-skewed input returns undefined and the caller degrades to the plain
|
|
2230
|
+
* text fold — shape skew must never throw into the mirror.
|
|
2231
|
+
* @param input - the tool_use block's `input` payload.
|
|
2232
|
+
* @returns the dsh todo list, or undefined on shape skew.
|
|
2233
|
+
*/
|
|
2234
|
+
function todosFromTodoWrite(input) {
|
|
2235
|
+
if (typeof input !== "object" || input === null) return void 0;
|
|
2236
|
+
const todos = input.todos;
|
|
2237
|
+
if (!Array.isArray(todos)) return void 0;
|
|
2238
|
+
const out = [];
|
|
2239
|
+
for (const item of todos) {
|
|
2240
|
+
if (typeof item !== "object" || item === null) return void 0;
|
|
2241
|
+
const content = item.content;
|
|
2242
|
+
if (typeof content !== "string" || content.trim() === "") return void 0;
|
|
2243
|
+
const status = item.status;
|
|
2244
|
+
out.push({
|
|
2245
|
+
content,
|
|
2246
|
+
status: status === "in_progress" || status === "completed" ? status : "pending"
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
return out;
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Append the todo/write snapshot unless the child session's last snapshot is
|
|
2253
|
+
* identical — the M2 dsh mirror's JSON-comparison idempotency: repeated passes
|
|
2254
|
+
* over the same state never duplicate it. The comparison base is the whole
|
|
2255
|
+
* log, not the current round: a standing whole-list snapshot has no round
|
|
2256
|
+
* scope, so a resume round with an unchanged list re-appends nothing and the
|
|
2257
|
+
* earlier round's list keeps standing.
|
|
2258
|
+
* @param childSession - the parent-side dsh subagent session.
|
|
2259
|
+
* @param todos - the translated whole list.
|
|
2260
|
+
* @returns whether a snapshot was appended.
|
|
2261
|
+
*/
|
|
2262
|
+
function appendTodosIfChanged(childSession, todos) {
|
|
2263
|
+
const last = childSession.snapshotEvents().filter((event) => event.type === "todo/write").at(-1);
|
|
2264
|
+
if (last !== void 0 && JSON.stringify(last.data) === JSON.stringify({ todos })) return false;
|
|
2265
|
+
childSession.append("todo/write", { todos });
|
|
2266
|
+
return true;
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* The CLI version named by a `system` init event's `claude_code_version`.
|
|
2270
|
+
* claude 2.1.x serializes its whole build-info module there
|
|
2271
|
+
* (`{ VERSION, BUILD_TIME, GIT_SHA, … }`), so the version is a field of it; a
|
|
2272
|
+
* future release that emits a bare string is accepted unchanged. Anything
|
|
2273
|
+
* else yields undefined and the probe answers instead.
|
|
2274
|
+
* @param value - the event's `claude_code_version` field.
|
|
2275
|
+
* @returns the version string, or undefined.
|
|
2276
|
+
*/
|
|
2277
|
+
function claudeVersionFromInit(value) {
|
|
2278
|
+
if (typeof value === "string" && value !== "") return value;
|
|
2279
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
2280
|
+
const version = value.VERSION;
|
|
2281
|
+
return typeof version === "string" && version !== "" ? version : void 0;
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Fold one NDJSON line into the stream state. Shared by
|
|
2285
|
+
* {@link parseClaudeStreamJson} (settle-time whole-stream parse) and
|
|
2286
|
+
* {@link ClaudeStreamParser} (live incremental parse) so the two paths cannot
|
|
2287
|
+
* drift.
|
|
2288
|
+
*/
|
|
2289
|
+
function foldClaudeStreamLine(state, raw) {
|
|
2290
|
+
const line = raw.trim();
|
|
2291
|
+
if (line === "") return;
|
|
2292
|
+
let event;
|
|
2293
|
+
try {
|
|
2294
|
+
event = JSON.parse(line);
|
|
2295
|
+
} catch {
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
if (event.type === "system" && typeof event.session_id === "string") {
|
|
2299
|
+
state.sessionId = event.session_id;
|
|
2300
|
+
if (typeof event.model === "string" && event.model !== "") state.model = event.model;
|
|
2301
|
+
const version = claudeVersionFromInit(event.claude_code_version);
|
|
2302
|
+
if (version !== void 0) state.cliVersion = version;
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
if (event.type === "result") {
|
|
2306
|
+
state.completed = true;
|
|
2307
|
+
if (event.is_error === true) state.error = typeof event.result === "string" ? event.result : typeof event.error === "string" ? event.error : "claude -p reported an error";
|
|
2308
|
+
if (typeof event.session_id === "string") state.sessionId = event.session_id;
|
|
2309
|
+
if (typeof event.model === "string" && event.model !== "") state.model = event.model;
|
|
2310
|
+
if (event.usage !== void 0) state.usage = usageFromClaude(event.usage);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (event.type !== "assistant" && event.type !== "user") return;
|
|
2314
|
+
const blocks = event.message?.content ?? [];
|
|
2315
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
2316
|
+
if (typeof block !== "object" || block === null) continue;
|
|
2317
|
+
const record = block;
|
|
2318
|
+
const identity = event.type === "assistant" && typeof event.message?.id === "string" ? { streamId: `${event.message.id}:${blockIndex}` } : {};
|
|
2319
|
+
const kind = record["type"];
|
|
2320
|
+
if (kind === "text" && typeof record["text"] === "string" && record["text"].trim() !== "") {
|
|
2321
|
+
state.lines.push({
|
|
2322
|
+
kind: "text",
|
|
2323
|
+
text: record["text"],
|
|
2324
|
+
...identity
|
|
2325
|
+
});
|
|
2326
|
+
if (event.type === "assistant") state.text = record["text"];
|
|
2327
|
+
} else if (kind === "thinking" && typeof record["thinking"] === "string" && record["thinking"].trim() !== "") state.lines.push({
|
|
2328
|
+
kind: "think",
|
|
2329
|
+
text: record["thinking"],
|
|
2330
|
+
...identity
|
|
2331
|
+
});
|
|
2332
|
+
else if (kind === "redacted_thinking") state.lines.push({
|
|
2333
|
+
kind: "think",
|
|
2334
|
+
text: REDACTED_THINKING_TEXT,
|
|
2335
|
+
...identity
|
|
2336
|
+
});
|
|
2337
|
+
else if (kind === "tool_use" || kind === "server_tool_use") {
|
|
2338
|
+
const name = typeof record["name"] === "string" ? record["name"] : "tool";
|
|
2339
|
+
state.toolCalls.set(name, (state.toolCalls.get(name) ?? 0) + 1);
|
|
2340
|
+
if (name === "TodoWrite") {
|
|
2341
|
+
const todos = todosFromTodoWrite(record["input"]);
|
|
2342
|
+
if (todos !== void 0) {
|
|
2343
|
+
state.todos = todos;
|
|
2344
|
+
continue;
|
|
2345
|
+
}
|
|
2346
|
+
state.todoSkew = true;
|
|
2347
|
+
}
|
|
2348
|
+
const id = typeof record["id"] === "string" ? record["id"] : void 0;
|
|
2349
|
+
if (id !== void 0) state.callsById.set(id, state.lines.length);
|
|
2350
|
+
const edit = kind === "tool_use" ? editToolDetail(name, record["input"]) : void 0;
|
|
2351
|
+
if (edit !== void 0) state.lines.push({
|
|
2352
|
+
kind: "tool",
|
|
2353
|
+
id: id ?? `claude-tool-${state.lines.length}`,
|
|
2354
|
+
name: "ApplyPatch",
|
|
2355
|
+
args: edit.args,
|
|
2356
|
+
...edit.patch === void 0 ? {} : { result: edit.patch }
|
|
2357
|
+
});
|
|
2358
|
+
else {
|
|
2359
|
+
const detail = inputDetail(record["input"]);
|
|
2360
|
+
state.lines.push({
|
|
2361
|
+
kind: "tool",
|
|
2362
|
+
id: id ?? `claude-tool-${state.lines.length}`,
|
|
2363
|
+
name: kind === "server_tool_use" ? SERVER_TOOL_CARD_NAMES[name] ?? name : name,
|
|
2364
|
+
...detail === void 0 ? {} : { args: detail }
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
} else if (kind === "tool_result") {
|
|
2368
|
+
const resultText = toolResultText(record["content"]);
|
|
2369
|
+
if (resultText !== void 0 && resultText.trim() !== "") attachToolResult(state, record["tool_use_id"], resultText, record["is_error"] === true);
|
|
2370
|
+
} else if (kind === "web_search_tool_result" || kind === "web_fetch_tool_result") {
|
|
2371
|
+
const resultText = serverToolResultText(record);
|
|
2372
|
+
if (resultText !== void 0 && resultText.trim() !== "") attachToolResult(state, record["tool_use_id"], resultText, false);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Merge a tool result into its call line: pair by tool_use_id when the stream
|
|
2378
|
+
* carries it, else the most recent tool line so an id-less stream still lands
|
|
2379
|
+
* the result on its call. A line already carrying a body (an edit tool's
|
|
2380
|
+
* patch) keeps it — the status text adds nothing to a successful edit — but
|
|
2381
|
+
* an error result always appends, since a failed edit's message is the point.
|
|
2382
|
+
*/
|
|
2383
|
+
function attachToolResult(state, useId, resultText, isError) {
|
|
2384
|
+
let target = typeof useId !== "string" ? void 0 : state.callsById.get(useId);
|
|
2385
|
+
if (target === void 0) {
|
|
2386
|
+
for (let index = state.lines.length - 1; index >= 0; index -= 1) if (state.lines[index]?.kind === "tool") {
|
|
2387
|
+
target = index;
|
|
2388
|
+
break;
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
if (target === void 0) return;
|
|
2392
|
+
const last = state.lines[target];
|
|
2393
|
+
if (last === void 0 || last.kind !== "tool") return;
|
|
2394
|
+
if (last.result === void 0) state.lines[target] = {
|
|
2395
|
+
...last,
|
|
2396
|
+
result: resultText
|
|
2397
|
+
};
|
|
2398
|
+
else if (isError) state.lines[target] = {
|
|
2399
|
+
...last,
|
|
2400
|
+
result: `${last.result}\n${resultText}`
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Incremental `claude --output-format stream-json` parser: feed stdout chunks
|
|
2405
|
+
* as they arrive and the folded transcript accumulates line by line. The last
|
|
2406
|
+
* line is volatile until the terminal `result` event — a `tool_result` merges
|
|
2407
|
+
* into a pending tool line — so live consumers hold it back (see the run's
|
|
2408
|
+
* live mirror).
|
|
2409
|
+
*/
|
|
2410
|
+
var ClaudeStreamParser = class {
|
|
2411
|
+
buffer = "";
|
|
2412
|
+
lines = [];
|
|
2413
|
+
callsById = /* @__PURE__ */ new Map();
|
|
2414
|
+
text;
|
|
2415
|
+
usage;
|
|
2416
|
+
sessionId;
|
|
2417
|
+
model;
|
|
2418
|
+
cliVersion;
|
|
2419
|
+
error;
|
|
2420
|
+
completed = false;
|
|
2421
|
+
todos;
|
|
2422
|
+
todoSkew = false;
|
|
2423
|
+
toolCalls = /* @__PURE__ */ new Map();
|
|
2424
|
+
/** Fold every complete NDJSON line in the chunk; the tail stays buffered. */
|
|
2425
|
+
push(chunk) {
|
|
2426
|
+
this.buffer += chunk;
|
|
2427
|
+
const parts = this.buffer.split("\n");
|
|
2428
|
+
this.buffer = parts.pop() ?? "";
|
|
2429
|
+
for (const raw of parts) foldClaudeStreamLine(this, raw);
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
/**
|
|
2433
|
+
* Parse a `claude -p --verbose --output-format stream-json` NDJSON stream.
|
|
2434
|
+
* Each line is one event: `system` init (carries the session id and the
|
|
2435
|
+
* model), `assistant` (content blocks: `text`, `tool_use`, `thinking`), `user`
|
|
2436
|
+
* (a `tool_result` block), and a terminal `result` (final usage, error flag,
|
|
2437
|
+
* session id). Content blocks fold into an ordered transcript (thinking,
|
|
2438
|
+
* reply text, tool calls with results), the last assistant `text` becomes the
|
|
2439
|
+
* run output, and the `result` event's usage/session id ride the parse. The
|
|
2440
|
+
* model identifier comes from the init (or result) event's `model` field. A
|
|
2441
|
+
* malformed stream or an `is_error` result yields the error instead.
|
|
2442
|
+
* @param output - the collected stdout NDJSON text.
|
|
2443
|
+
* @returns the ordered transcript, final answer text, usage, session id, the
|
|
2444
|
+
* observed model, and the error when the run reported one.
|
|
2445
|
+
*/
|
|
2446
|
+
function parseClaudeStreamJson(output) {
|
|
2447
|
+
const state = {
|
|
2448
|
+
lines: [],
|
|
2449
|
+
callsById: /* @__PURE__ */ new Map(),
|
|
2450
|
+
toolCalls: /* @__PURE__ */ new Map(),
|
|
2451
|
+
text: void 0,
|
|
2452
|
+
usage: void 0,
|
|
2453
|
+
sessionId: void 0,
|
|
2454
|
+
model: void 0,
|
|
2455
|
+
cliVersion: void 0,
|
|
2456
|
+
error: void 0,
|
|
2457
|
+
completed: false,
|
|
2458
|
+
todos: void 0,
|
|
2459
|
+
todoSkew: false
|
|
2460
|
+
};
|
|
2461
|
+
for (const raw of output.split("\n")) foldClaudeStreamLine(state, raw);
|
|
2462
|
+
const toolCalls = toolCallsOf(state.toolCalls);
|
|
2463
|
+
return {
|
|
2464
|
+
lines: state.lines,
|
|
2465
|
+
...state.text === void 0 ? {} : { text: state.text },
|
|
2466
|
+
...state.usage === void 0 ? {} : { usage: state.usage },
|
|
2467
|
+
...state.error === void 0 ? {} : { error: state.error },
|
|
2468
|
+
...state.sessionId === void 0 ? {} : { sessionId: state.sessionId },
|
|
2469
|
+
...state.cliVersion === void 0 ? {} : { cliVersion: state.cliVersion },
|
|
2470
|
+
...state.model === void 0 ? {} : { model: state.model },
|
|
2471
|
+
...state.todos === void 0 ? {} : { todos: state.todos },
|
|
2472
|
+
...state.todoSkew === false ? {} : { todoSkew: true },
|
|
2473
|
+
...toolCalls === void 0 ? {} : { toolCalls }
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
/** The placeholder think line for a `redacted_thinking` block (content is server-encrypted by design). */
|
|
2477
|
+
const REDACTED_THINKING_TEXT = "(思考内容已由服务端隐藏:redacted_thinking,内容按设计不可见,此行仅标示它存在)";
|
|
2478
|
+
/** Server-side tool_use names map to the card names of their local-tool twins. */
|
|
2479
|
+
const SERVER_TOOL_CARD_NAMES = {
|
|
2480
|
+
web_search: "WebSearch",
|
|
2481
|
+
web_fetch: "WebFetch"
|
|
2482
|
+
};
|
|
2483
|
+
/** Render a tool_use input payload as a compact command/query line. */
|
|
2484
|
+
function inputDetail(input) {
|
|
2485
|
+
if (input === void 0) return void 0;
|
|
2486
|
+
if (typeof input === "string") return input.trim() === "" ? void 0 : input.trim();
|
|
2487
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return void 0;
|
|
2488
|
+
const record = input;
|
|
2489
|
+
for (const key of [
|
|
2490
|
+
"command",
|
|
2491
|
+
"query",
|
|
2492
|
+
"url",
|
|
2493
|
+
"path"
|
|
2494
|
+
]) {
|
|
2495
|
+
const value = record[key];
|
|
2496
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
2497
|
+
}
|
|
2498
|
+
const json = JSON.stringify(record);
|
|
2499
|
+
if (json === "{}") return void 0;
|
|
2500
|
+
return json.length <= 160 ? json : `${json.slice(0, 160)}…`;
|
|
2501
|
+
}
|
|
2502
|
+
/**
|
|
2503
|
+
* The actual edit behind an Edit/Write/MultiEdit/NotebookEdit tool_use, for
|
|
2504
|
+
* the apply-patch-style card (codex's fileChange idiom: `kind: path` args,
|
|
2505
|
+
* the patch as the body). Edit and MultiEdit synthesize `-`-/`+`-prefixed
|
|
2506
|
+
* old/new lines; Write and NotebookEdit carry the whole new body verbatim.
|
|
2507
|
+
* Undefined for any other tool or a shape-skewed input (the caller then
|
|
2508
|
+
* keeps the one-scalar summary).
|
|
2509
|
+
* @param name - the tool_use block's `name`.
|
|
2510
|
+
* @param input - the tool_use block's `input` payload.
|
|
2511
|
+
* @returns the card args and patch body, or undefined.
|
|
2512
|
+
*/
|
|
2513
|
+
function editToolDetail(name, input) {
|
|
2514
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return void 0;
|
|
2515
|
+
const record = input;
|
|
2516
|
+
const pathOf = (key) => {
|
|
2517
|
+
const value = record[key];
|
|
2518
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
2519
|
+
};
|
|
2520
|
+
const hunk = (oldString, newString) => {
|
|
2521
|
+
if (typeof oldString !== "string" || typeof newString !== "string") return void 0;
|
|
2522
|
+
return [...oldString.split("\n").map((line) => `- ${line}`), ...newString.split("\n").map((line) => `+ ${line}`)].join("\n");
|
|
2523
|
+
};
|
|
2524
|
+
if (name === "Edit") {
|
|
2525
|
+
const path = pathOf("file_path");
|
|
2526
|
+
if (path === void 0) return void 0;
|
|
2527
|
+
const patch = hunk(record["old_string"], record["new_string"]);
|
|
2528
|
+
return {
|
|
2529
|
+
args: `update: ${path}`,
|
|
2530
|
+
...patch === void 0 ? {} : { patch }
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
if (name === "MultiEdit") {
|
|
2534
|
+
const path = pathOf("file_path");
|
|
2535
|
+
if (path === void 0) return void 0;
|
|
2536
|
+
const patch = (Array.isArray(record["edits"]) ? record["edits"] : []).map((edit) => hunk(edit["old_string"], edit["new_string"])).filter((entry) => entry !== void 0).join("\n\n");
|
|
2537
|
+
return {
|
|
2538
|
+
args: `update: ${path}`,
|
|
2539
|
+
...patch === "" ? {} : { patch }
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
if (name === "Write") {
|
|
2543
|
+
const path = pathOf("file_path");
|
|
2544
|
+
if (path === void 0) return void 0;
|
|
2545
|
+
const content = record["content"];
|
|
2546
|
+
return {
|
|
2547
|
+
args: `update: ${path}`,
|
|
2548
|
+
...typeof content === "string" && content !== "" ? { patch: content } : {}
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
if (name === "NotebookEdit") {
|
|
2552
|
+
const path = pathOf("notebook_path");
|
|
2553
|
+
if (path === void 0) return void 0;
|
|
2554
|
+
const source = record["new_source"];
|
|
2555
|
+
return {
|
|
2556
|
+
args: `update: ${path}`,
|
|
2557
|
+
...typeof source === "string" && source !== "" ? { patch: source } : {}
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
/** Cap for a fetched page's text on a WebFetch card — the log stays a log. */
|
|
2562
|
+
const WEB_FETCH_TEXT_LIMIT = 4e3;
|
|
2563
|
+
/**
|
|
2564
|
+
* Flatten a server-side web tool result block to card text. A search folds
|
|
2565
|
+
* its hits as `title — url` lines (an error block as `error: <code>`); a
|
|
2566
|
+
* fetch folds the page text capped at {@link WEB_FETCH_TEXT_LIMIT}. Undefined
|
|
2567
|
+
* when the block carries nothing readable — the pending call line then simply
|
|
2568
|
+
* stays result-less, never dropped.
|
|
2569
|
+
* @param record - the `web_search_tool_result` / `web_fetch_tool_result` block.
|
|
2570
|
+
* @returns the card text, or undefined.
|
|
2571
|
+
*/
|
|
2572
|
+
function serverToolResultText(record) {
|
|
2573
|
+
const content = record["content"];
|
|
2574
|
+
if (Array.isArray(content)) {
|
|
2575
|
+
const lines = content.map((item) => {
|
|
2576
|
+
if (typeof item !== "object" || item === null) return void 0;
|
|
2577
|
+
const entry = item;
|
|
2578
|
+
if (entry["type"] === "web_search_tool_result_error") return `error: ${typeof entry["error_code"] === "string" ? entry["error_code"] : "unknown"}`;
|
|
2579
|
+
if (entry["type"] === "web_search_result") {
|
|
2580
|
+
const title = typeof entry["title"] === "string" ? entry["title"] : "";
|
|
2581
|
+
const url = typeof entry["url"] === "string" ? entry["url"] : "";
|
|
2582
|
+
if (title === "" && url === "") return void 0;
|
|
2583
|
+
return title === "" ? url : `${title} — ${url}`;
|
|
2584
|
+
}
|
|
2585
|
+
}).filter((line) => line !== void 0);
|
|
2586
|
+
return lines.length === 0 ? void 0 : lines.join("\n");
|
|
2587
|
+
}
|
|
2588
|
+
if (typeof content === "object" && content !== null) {
|
|
2589
|
+
const entry = content;
|
|
2590
|
+
if (entry["type"] === "web_fetch_tool_result_error") return `error: ${typeof entry["error_code"] === "string" ? entry["error_code"] : "unknown"}`;
|
|
2591
|
+
const url = typeof entry["url"] === "string" ? entry["url"] : void 0;
|
|
2592
|
+
const body = typeof entry["content"] === "string" ? entry["content"] : void 0;
|
|
2593
|
+
return [url, body === void 0 ? void 0 : body.length <= WEB_FETCH_TEXT_LIMIT ? body : `${body.slice(0, WEB_FETCH_TEXT_LIMIT)}\n…`].filter((part) => part !== void 0 && part !== "").join("\n") || void 0;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
/** Flatten a tool_result content payload (string or block array) to text. */
|
|
2597
|
+
function toolResultText(content) {
|
|
2598
|
+
if (typeof content === "string") return content;
|
|
2599
|
+
if (!Array.isArray(content)) return void 0;
|
|
2600
|
+
return content.filter((block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string").map((block) => block.text ?? "").join("\n");
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Map claude's usage payload onto the shared usage contract. Claude's
|
|
2604
|
+
* `input_tokens` is the uncached input; cache reads and cache creation are
|
|
2605
|
+
* reported separately, so each maps to its own bucket with no subtraction.
|
|
2606
|
+
* @param usage - the raw claude usage object.
|
|
2607
|
+
* @returns the shared usage record.
|
|
2608
|
+
*/
|
|
2609
|
+
function usageFromClaude(usage) {
|
|
2610
|
+
const raw = usage;
|
|
2611
|
+
if (raw === void 0 || typeof raw !== "object") return {
|
|
2612
|
+
inputTokens: 0,
|
|
2613
|
+
outputTokens: 0
|
|
2614
|
+
};
|
|
2615
|
+
const num = (value) => {
|
|
2616
|
+
const n = Number(value);
|
|
2617
|
+
return Number.isFinite(n) ? n : void 0;
|
|
2618
|
+
};
|
|
2619
|
+
const input = num(raw.input_tokens);
|
|
2620
|
+
const output = num(raw.output_tokens);
|
|
2621
|
+
const cacheRead = num(raw.cache_read_input_tokens);
|
|
2622
|
+
const cacheWrite = num(raw.cache_creation_input_tokens);
|
|
2623
|
+
const usageRecord = {
|
|
2624
|
+
inputTokens: input ?? 0,
|
|
2625
|
+
outputTokens: output ?? 0
|
|
2626
|
+
};
|
|
2627
|
+
if (cacheRead !== void 0 && cacheRead > 0) usageRecord.cacheReadTokens = cacheRead;
|
|
2628
|
+
if (cacheWrite !== void 0 && cacheWrite > 0) usageRecord.cacheWriteTokens = cacheWrite;
|
|
2629
|
+
return usageRecord;
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Start the real `claude -p` child and publish its run. The claude
|
|
2633
|
+
* result arrives as an NDJSON event stream on stdout (`--verbose
|
|
2634
|
+
* --output-format stream-json`): assistant content blocks (text, tool_use,
|
|
2635
|
+
* thinking), user tool_result blocks, and a terminal result event carrying
|
|
2636
|
+
* usage and session id. stderr is piped for diagnostics and never folded
|
|
2637
|
+
* into the run output. A resume round spawns `claude -p --resume
|
|
2638
|
+
* <session_id>` instead, continuing the same session. The run id is the
|
|
2639
|
+
* child session id for a session-backed run.
|
|
2640
|
+
* @param request - resolved shared subagent request.
|
|
2641
|
+
* @param spec - workspace, environment, process service, and diagnostic policy.
|
|
2642
|
+
* @returns the published run after the child starts.
|
|
2643
|
+
*/
|
|
2644
|
+
async function startClaudeCliRun(request, spec) {
|
|
2645
|
+
const task = textTask(request.prompt);
|
|
2646
|
+
if (request.signal.aborted) throw new Error("subagent-claude: request was aborted before the CLI started");
|
|
2647
|
+
const configDir = spec.env["CLAUDE_CONFIG_DIR"];
|
|
2648
|
+
if (configDir !== void 0) await syncClaudeCredentialFile(configDir, (message) => {
|
|
2649
|
+
spec.ctx?.logger.warn(message);
|
|
2650
|
+
}).catch(() => false);
|
|
2651
|
+
const turn = spec.resume?.turn ?? 1;
|
|
2652
|
+
const memberArgv = spec.member === void 0 ? [] : [
|
|
2653
|
+
"--mcp-config",
|
|
2654
|
+
spec.member.mcpConfig,
|
|
2655
|
+
"--allowedTools",
|
|
2656
|
+
spec.member.allowedTool,
|
|
2657
|
+
"--"
|
|
2658
|
+
];
|
|
2659
|
+
const modelArgv = spec.model === void 0 ? [] : ["--model", spec.model];
|
|
2660
|
+
const argv = spec.resume === void 0 ? spec.permissionMode === "skip" ? [
|
|
2661
|
+
"claude",
|
|
2662
|
+
"-p",
|
|
2663
|
+
"--dangerously-skip-permissions",
|
|
2664
|
+
"--verbose",
|
|
2665
|
+
...modelArgv,
|
|
2666
|
+
"--output-format",
|
|
2667
|
+
"stream-json",
|
|
2668
|
+
...memberArgv,
|
|
2669
|
+
task
|
|
2670
|
+
] : [
|
|
2671
|
+
"claude",
|
|
2672
|
+
"-p",
|
|
2673
|
+
"--verbose",
|
|
2674
|
+
...modelArgv,
|
|
2675
|
+
"--output-format",
|
|
2676
|
+
"stream-json",
|
|
2677
|
+
...memberArgv,
|
|
2678
|
+
task
|
|
2679
|
+
] : spec.permissionMode === "skip" ? [
|
|
2680
|
+
"claude",
|
|
2681
|
+
"-p",
|
|
2682
|
+
"--dangerously-skip-permissions",
|
|
2683
|
+
"--verbose",
|
|
2684
|
+
...modelArgv,
|
|
2685
|
+
"--resume",
|
|
2686
|
+
spec.resume.cliSessionId,
|
|
2687
|
+
"--output-format",
|
|
2688
|
+
"stream-json",
|
|
2689
|
+
...memberArgv,
|
|
2690
|
+
task
|
|
2691
|
+
] : [
|
|
2692
|
+
"claude",
|
|
2693
|
+
"-p",
|
|
2694
|
+
"--verbose",
|
|
2695
|
+
...modelArgv,
|
|
2696
|
+
"--resume",
|
|
2697
|
+
spec.resume.cliSessionId,
|
|
2698
|
+
"--output-format",
|
|
2699
|
+
"stream-json",
|
|
2700
|
+
...memberArgv,
|
|
2701
|
+
task
|
|
2702
|
+
];
|
|
2703
|
+
const env = spec.controlled ? {
|
|
2704
|
+
...spec.env,
|
|
2705
|
+
CLAUDE_CODE_EFFORT_LEVEL: spec.effort ?? "auto"
|
|
2706
|
+
} : spec.env;
|
|
2707
|
+
const launch = spec.exec === void 0 ? {
|
|
2708
|
+
argv,
|
|
2709
|
+
env
|
|
2710
|
+
} : containerExecSpawn(spec.exec, {
|
|
2711
|
+
argv,
|
|
2712
|
+
env
|
|
2713
|
+
}, "subagent-claude");
|
|
2714
|
+
const child = spec.spawn({
|
|
2715
|
+
argv: launch.argv,
|
|
2716
|
+
cwd: spec.cwd,
|
|
2717
|
+
stdio: {
|
|
2718
|
+
stdin: "ignore",
|
|
2719
|
+
stdout: "pipe",
|
|
2720
|
+
stderr: "pipe"
|
|
2721
|
+
},
|
|
2722
|
+
graceMs: spec.disposeGraceMs,
|
|
2723
|
+
env: launch.env
|
|
2724
|
+
});
|
|
2725
|
+
spec.childSession?.append("turn/start", { turn });
|
|
2726
|
+
const liveMirror = spec.childSession !== void 0 && spec.ctx !== void 0 ? createClaudeLiveMirror(spec, task, turn) : void 0;
|
|
2727
|
+
let output = "";
|
|
2728
|
+
child.stdout?.on("data", (chunk) => {
|
|
2729
|
+
const text = chunk.toString();
|
|
2730
|
+
output += text;
|
|
2731
|
+
liveMirror?.push(text);
|
|
2732
|
+
});
|
|
2733
|
+
let stderr = "";
|
|
2734
|
+
child.stderr?.on("data", (chunk) => {
|
|
2735
|
+
stderr += chunk.toString();
|
|
2736
|
+
});
|
|
2737
|
+
const disposeProcess = async () => {
|
|
2738
|
+
child.terminate();
|
|
2739
|
+
await child.waitForExit().catch(() => false);
|
|
2740
|
+
await child.done.catch(() => {});
|
|
2741
|
+
};
|
|
2742
|
+
const runAbort = new AbortController();
|
|
2743
|
+
const requestCancel = () => {
|
|
2744
|
+
if (runAbort.signal.aborted) return;
|
|
2745
|
+
runAbort.abort(/* @__PURE__ */ new Error("subagent-claude: run cancelled locally"));
|
|
2746
|
+
};
|
|
2747
|
+
const onAbort = () => {
|
|
2748
|
+
requestCancel();
|
|
2749
|
+
};
|
|
2750
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
2751
|
+
const abortBranch = new Promise((_, reject) => {
|
|
2752
|
+
runAbort.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("subagent-claude: run cancelled locally")), { once: true });
|
|
2753
|
+
});
|
|
2754
|
+
const processFailure = child.done.then((outcome) => Promise.reject(/* @__PURE__ */ new Error(`subagent-claude: CLI exited before the run settled (code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
|
|
2755
|
+
processFailure.catch(() => {});
|
|
2756
|
+
const collectOutput = () => {
|
|
2757
|
+
const drained = child.collected.stdout?.readFrom(0).text;
|
|
2758
|
+
const text = parseClaudeStreamJson(drained !== void 0 && drained !== "" ? drained : output).text?.trim();
|
|
2759
|
+
return text === void 0 || text === "" ? [] : [{
|
|
2760
|
+
type: "text",
|
|
2761
|
+
text
|
|
2762
|
+
}];
|
|
2763
|
+
};
|
|
2764
|
+
let exitCode = null;
|
|
2765
|
+
const result = settleRunResult({
|
|
2766
|
+
attempt: () => Promise.race([
|
|
2767
|
+
child.done.then((outcome) => {
|
|
2768
|
+
if (outcome.exitCode !== 0) {
|
|
2769
|
+
const via = spec.endpointLabel ?? "claude default endpoint";
|
|
2770
|
+
exitCode = outcome.exitCode;
|
|
2771
|
+
throw new Error(`subagent-claude: claude -p exited with code ${String(outcome.exitCode)} via ${via}`);
|
|
2772
|
+
}
|
|
2773
|
+
const output = collectOutput();
|
|
2774
|
+
if (output.length === 0) throw new Error("subagent-claude: claude -p exited 0 but produced no answer");
|
|
2775
|
+
return {
|
|
2776
|
+
output,
|
|
2777
|
+
stopReason: "completed"
|
|
2778
|
+
};
|
|
2779
|
+
}),
|
|
2780
|
+
processFailure,
|
|
2781
|
+
abortBranch
|
|
2782
|
+
]),
|
|
2783
|
+
collectOutput,
|
|
2784
|
+
cancelled: () => runAbort.signal.aborted,
|
|
2785
|
+
onError: spec.onError,
|
|
2786
|
+
signal: request.signal,
|
|
2787
|
+
onAbort
|
|
2788
|
+
}).then((settled) => {
|
|
2789
|
+
if (settled.stopReason === "completed") spec.onAuthSuccess?.();
|
|
2790
|
+
if (spec.childSession !== void 0) {
|
|
2791
|
+
if (settled.stopReason === "completed") spec.childSession.append("turn/end", {
|
|
2792
|
+
turn,
|
|
2793
|
+
reason: { kind: "completed" }
|
|
2794
|
+
});
|
|
2795
|
+
else if (settled.stopReason === "aborted") spec.childSession.append("turn/end", {
|
|
2796
|
+
turn,
|
|
2797
|
+
reason: {
|
|
2798
|
+
kind: "aborted",
|
|
2799
|
+
reason: { kind: "parent" }
|
|
2800
|
+
}
|
|
2801
|
+
});
|
|
2802
|
+
else {
|
|
2803
|
+
const parsed = parseClaudeStreamJson(output);
|
|
2804
|
+
spec.childSession.append("turn/end", {
|
|
2805
|
+
turn,
|
|
2806
|
+
reason: {
|
|
2807
|
+
kind: "error",
|
|
2808
|
+
error: {
|
|
2809
|
+
message: parsed.error ?? `claude -p exited with code ${String(exitCode)}`,
|
|
2810
|
+
code: "UNKNOWN"
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
return settled;
|
|
2817
|
+
});
|
|
2818
|
+
result.then(() => child.done).then(() => {
|
|
2819
|
+
if (spec.onAuthFailure !== void 0) {
|
|
2820
|
+
const drainedStderr = child.collected.stderr?.readFrom(0).text || stderr;
|
|
2821
|
+
const authDetail = `${parseClaudeStreamJson(child.collected.stdout?.readFrom(0).text || output).error ?? ""}\n${drainedStderr}`;
|
|
2822
|
+
if (CLAUDE_AUTH_FAILURE.test(authDetail)) spec.onAuthFailure(authDetail.split("\n").find((line) => line.trim() !== "") ?? "auth failure");
|
|
2823
|
+
}
|
|
2824
|
+
return mirrorClaudeAfterExit(spec, task, turn, output, liveMirror);
|
|
2825
|
+
}, () => {});
|
|
2826
|
+
return Promise.resolve(subprocessRunHandle({
|
|
2827
|
+
id: spec.childSession?.id ?? SessionId(randomUUID()),
|
|
2828
|
+
result,
|
|
2829
|
+
signal: request.signal,
|
|
2830
|
+
onAbort,
|
|
2831
|
+
requestCancel,
|
|
2832
|
+
teardown: disposeProcess
|
|
2833
|
+
}));
|
|
2834
|
+
}
|
|
2835
|
+
/** One assistant-role message event, attributed to the claude route. */
|
|
2836
|
+
function assistantEvent(blocks) {
|
|
2837
|
+
return createAssistantMessage({
|
|
2838
|
+
content: blocks,
|
|
2839
|
+
source: {
|
|
2840
|
+
provider: "claude-local",
|
|
2841
|
+
model: "claude"
|
|
2842
|
+
}
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Fold one transcript line into the child session as one assistant step,
|
|
2847
|
+
* wrapped in the step/start–step/end boundary pair the live conversation
|
|
2848
|
+
* assembler requires: without a boundary the step never enters the location
|
|
2849
|
+
* index, the line's assistant message resolves to a turn-level location, and
|
|
2850
|
+
* the real-time view renders nothing (only a full rebuild, which mints step
|
|
2851
|
+
* drafts from the explicit coordinates, recovers it). Tool cards escape that
|
|
2852
|
+
* fate only because their definition does not consult the step location.
|
|
2853
|
+
*/
|
|
2854
|
+
function appendClaudeTranscriptLine(childSession, turn, step, line, usage) {
|
|
2855
|
+
childSession.append("step/start", {
|
|
2856
|
+
turn,
|
|
2857
|
+
step
|
|
2858
|
+
});
|
|
2859
|
+
if (line.kind === "tool") {
|
|
2860
|
+
const call = childSession.append("tool/call", {
|
|
2861
|
+
turn,
|
|
2862
|
+
step,
|
|
2863
|
+
callId: line.id,
|
|
2864
|
+
name: line.name,
|
|
2865
|
+
arguments: line.args ?? ""
|
|
2866
|
+
});
|
|
2867
|
+
if (line.result !== void 0) childSession.append("tool/result", {
|
|
2868
|
+
turn,
|
|
2869
|
+
step,
|
|
2870
|
+
message: createToolResultMessage({
|
|
2871
|
+
callId: line.id,
|
|
2872
|
+
content: [{
|
|
2873
|
+
type: "text",
|
|
2874
|
+
text: line.result
|
|
2875
|
+
}],
|
|
2876
|
+
isError: false
|
|
2877
|
+
})
|
|
2878
|
+
}, {
|
|
2879
|
+
surfaceOp: "append",
|
|
2880
|
+
sourceEventSeqs: [call.seq]
|
|
2881
|
+
});
|
|
2882
|
+
childSession.append("step/end", {
|
|
2883
|
+
turn,
|
|
2884
|
+
step
|
|
2885
|
+
});
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
const blocks = line.kind === "think" ? [{
|
|
2889
|
+
type: "reasoning",
|
|
2890
|
+
text: line.text
|
|
2891
|
+
}] : [{
|
|
2892
|
+
type: "text",
|
|
2893
|
+
text: line.text
|
|
2894
|
+
}];
|
|
2895
|
+
childSession.append("assistant/message", {
|
|
2896
|
+
turn,
|
|
2897
|
+
step,
|
|
2898
|
+
message: assistantEvent(blocks),
|
|
2899
|
+
stream: [],
|
|
2900
|
+
...usage === void 0 ? {} : { usage }
|
|
2901
|
+
}, { surfaceOp: "append" });
|
|
2902
|
+
childSession.append("step/end", {
|
|
2903
|
+
turn,
|
|
2904
|
+
step
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
/**
|
|
2908
|
+
* Fold one transcript line into the run's child session as one assistant step.
|
|
2909
|
+
*/
|
|
2910
|
+
function appendClaudeLine(spec, turn, step, line, usage) {
|
|
2911
|
+
if (spec.childSession === void 0) return;
|
|
2912
|
+
appendClaudeTranscriptLine(spec.childSession, turn, step, line, usage);
|
|
2913
|
+
}
|
|
2914
|
+
/** The delta-progress text for one transcript line. */
|
|
2915
|
+
function claudeLineText(line) {
|
|
2916
|
+
return line.kind === "tool" ? `[工具 ${line.name}]${line.args !== void 0 ? ` ${line.args}` : ""}${line.result !== void 0 ? ` → ${line.result}` : ""}` : line.text;
|
|
2917
|
+
}
|
|
2918
|
+
/** Create the per-run live mirror over the incremental parser. */
|
|
2919
|
+
function createClaudeLiveMirror(spec, task, turn) {
|
|
2920
|
+
const parser = new ClaudeStreamParser();
|
|
2921
|
+
const childSession = spec.childSession;
|
|
2922
|
+
const ctx = spec.ctx;
|
|
2923
|
+
let mirrored = 0;
|
|
2924
|
+
let userMirrored = false;
|
|
2925
|
+
let lastTodos;
|
|
2926
|
+
let todoSkewWarned = false;
|
|
2927
|
+
let queue = Promise.resolve();
|
|
2928
|
+
const mirror = {
|
|
2929
|
+
get mirroredLines() {
|
|
2930
|
+
return mirrored;
|
|
2931
|
+
},
|
|
2932
|
+
get userMirrored() {
|
|
2933
|
+
return userMirrored;
|
|
2934
|
+
},
|
|
2935
|
+
enqueue(task) {
|
|
2936
|
+
queue = queue.then(task);
|
|
2937
|
+
return queue;
|
|
2938
|
+
},
|
|
2939
|
+
push(chunk) {
|
|
2940
|
+
parser.push(chunk);
|
|
2941
|
+
if (parser.todos !== void 0 && parser.todos !== lastTodos) {
|
|
2942
|
+
lastTodos = parser.todos;
|
|
2943
|
+
appendTodosIfChanged(childSession, parser.todos);
|
|
2944
|
+
}
|
|
2945
|
+
if (parser.todoSkew && !todoSkewWarned) {
|
|
2946
|
+
todoSkewWarned = true;
|
|
2947
|
+
ctx.logger.warn("subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line");
|
|
2948
|
+
}
|
|
2949
|
+
const upto = parser.completed ? parser.lines.length : parser.lines.length - 1;
|
|
2950
|
+
if (upto <= mirrored) return;
|
|
2951
|
+
mirror.enqueue(async () => {
|
|
2952
|
+
try {
|
|
2953
|
+
const localAgent = ctx.get("localAgent");
|
|
2954
|
+
if (!userMirrored) {
|
|
2955
|
+
userMirrored = true;
|
|
2956
|
+
childSession.append("user/message", createUserMessage({
|
|
2957
|
+
content: [{
|
|
2958
|
+
type: "text",
|
|
2959
|
+
text: task
|
|
2960
|
+
}],
|
|
2961
|
+
source: { kind: "user" }
|
|
2962
|
+
}), { surfaceOp: "append" });
|
|
2963
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2964
|
+
kind: "delta",
|
|
2965
|
+
text: task
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
let carrier = -1;
|
|
2969
|
+
if (parser.completed && parser.usage !== void 0) {
|
|
2970
|
+
for (let scan = 0; scan < parser.lines.length; scan += 1) if (parser.lines[scan]?.kind !== "tool") carrier = scan;
|
|
2971
|
+
}
|
|
2972
|
+
for (let index = mirrored; index < upto; index += 1) {
|
|
2973
|
+
const line = parser.lines[index];
|
|
2974
|
+
if (line === void 0) continue;
|
|
2975
|
+
const usage = index === carrier ? parser.usage : void 0;
|
|
2976
|
+
appendClaudeLine(spec, turn, index + 1, line, usage);
|
|
2977
|
+
mirrored = index + 1;
|
|
2978
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2979
|
+
kind: "delta",
|
|
2980
|
+
text: claudeLineText(line)
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
await persistChildSession(ctx, childSession);
|
|
2984
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
2985
|
+
kind: "mirror",
|
|
2986
|
+
mirroredLines: mirrored
|
|
2987
|
+
});
|
|
2988
|
+
} catch (error) {
|
|
2989
|
+
ctx.logger.warn(`subagent-claude: live mirror failed: ${thrown(error).message}`);
|
|
2990
|
+
}
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
};
|
|
2994
|
+
return mirror;
|
|
2995
|
+
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Mirror the delegation's user prompt and the claude stream transcript into
|
|
2998
|
+
* the dsh subagent session, then persist. Runs detached from the settle race —
|
|
2999
|
+
* the run result settles with the child exit, and a failure here is
|
|
3000
|
+
* diagnostic-only. The transcript's thinking folds to `reasoning` blocks,
|
|
3001
|
+
* reply text and tool activity (call with command plus result) to text
|
|
3002
|
+
* blocks, and the final assistant message carries the round's usage when
|
|
3003
|
+
* claude reported it, so the tokenUsage projection counts the delegation.
|
|
3004
|
+
* @param spec - the run spec carrying the child session and host context.
|
|
3005
|
+
* @param task - the one-shot task text (the user prompt).
|
|
3006
|
+
* @param turn - the round's turn number (1 for a fresh round, incremented on resume).
|
|
3007
|
+
* @param parsed - the parsed stream transcript, final output, and usage.
|
|
3008
|
+
* @param fromLines - transcript lines the live mirror already appended.
|
|
3009
|
+
* @param userMirrored - whether the live mirror already appended the prompt.
|
|
3010
|
+
*/
|
|
3011
|
+
async function appendClaudeResponse(spec, task, turn, parsed, fromLines = 0, userMirrored = false) {
|
|
3012
|
+
if (spec.childSession === void 0 || spec.ctx === void 0) return;
|
|
3013
|
+
const childSession = spec.childSession;
|
|
3014
|
+
const localAgent = spec.ctx.get("localAgent");
|
|
3015
|
+
if (!userMirrored) {
|
|
3016
|
+
childSession.append("user/message", createUserMessage({
|
|
3017
|
+
content: [{
|
|
3018
|
+
type: "text",
|
|
3019
|
+
text: task
|
|
3020
|
+
}],
|
|
3021
|
+
source: { kind: "user" }
|
|
3022
|
+
}), { surfaceOp: "append" });
|
|
3023
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
3024
|
+
kind: "delta",
|
|
3025
|
+
text: task
|
|
3026
|
+
});
|
|
3027
|
+
}
|
|
3028
|
+
let usageIndex = -1;
|
|
3029
|
+
for (let index = 0; index < parsed.lines.length; index += 1) if (parsed.lines[index]?.kind !== "tool") usageIndex = index;
|
|
3030
|
+
let step = fromLines + 1;
|
|
3031
|
+
for (const line of parsed.lines.slice(fromLines)) {
|
|
3032
|
+
appendClaudeLine(spec, turn, step, line, step - 1 === usageIndex ? parsed.usage : void 0);
|
|
3033
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
3034
|
+
kind: "delta",
|
|
3035
|
+
text: claudeLineText(line)
|
|
3036
|
+
});
|
|
3037
|
+
step += 1;
|
|
3038
|
+
}
|
|
3039
|
+
if (parsed.usage !== void 0 && usageIndex !== -1 && usageIndex < fromLines) spec.ctx.logger.warn(`subagent-claude: round ${turn} usage arrived after its carrier line was mirrored; the accounting is dropped`);
|
|
3040
|
+
await persistChildSession(spec.ctx, childSession);
|
|
3041
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
3042
|
+
kind: "mirror",
|
|
3043
|
+
mirroredLines: parsed.lines.length
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
/**
|
|
3047
|
+
* Mirror the claude stream-json into the child session AFTER the CLI process
|
|
3048
|
+
* has exited, whatever its stop reason. A cancelled or failed round still
|
|
3049
|
+
* preserves the events the stream already emitted (thinking, tool calls with
|
|
3050
|
+
* results, replies, usage) instead of leaving the child blank — the run result
|
|
3051
|
+
* settles 'aborted'/'error' at the cancel moment, but claude may have produced
|
|
3052
|
+
* content before the kill landed. Also records the session id (fresh rounds)
|
|
3053
|
+
* so a later resume can continue the partial session.
|
|
3054
|
+
* @param spec - the run spec carrying the child session and host context.
|
|
3055
|
+
* @param task - the one-shot task text (the user prompt).
|
|
3056
|
+
* @param turn - the round's turn number.
|
|
3057
|
+
* @param output - the collected stream-json stdout.
|
|
3058
|
+
*/
|
|
3059
|
+
async function mirrorClaudeAfterExit(spec, task, turn, output, live) {
|
|
3060
|
+
if (spec.childSession === void 0 || spec.ctx === void 0) return;
|
|
3061
|
+
const childSession = spec.childSession;
|
|
3062
|
+
const work = async () => {
|
|
3063
|
+
const parsed = parseClaudeStreamJson(output);
|
|
3064
|
+
if (spec.resume === void 0) spec.onSessionId?.(parsed.sessionId);
|
|
3065
|
+
if (parsed.todoSkew === true) spec.ctx?.logger.warn("subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line");
|
|
3066
|
+
if (parsed.todos !== void 0) appendTodosIfChanged(childSession, parsed.todos);
|
|
3067
|
+
const fromLines = live?.mirroredLines ?? 0;
|
|
3068
|
+
const userMirrored = live?.userMirrored ?? false;
|
|
3069
|
+
const cliVersion = parsed.cliVersion ?? await spec.cliVersion?.();
|
|
3070
|
+
spec.onRoundSettled?.({
|
|
3071
|
+
...parsed.model === void 0 ? {} : { observedModel: parsed.model },
|
|
3072
|
+
...cliVersion === void 0 ? {} : { cliVersion },
|
|
3073
|
+
...parsed.usage === void 0 ? {} : { usage: parsed.usage },
|
|
3074
|
+
...parsed.toolCalls === void 0 ? {} : { toolCalls: parsed.toolCalls }
|
|
3075
|
+
});
|
|
3076
|
+
if (parsed.lines.length === 0 && !userMirrored) return;
|
|
3077
|
+
const trimmed = parsed.text?.trim();
|
|
3078
|
+
await appendClaudeResponse(spec, task, turn, {
|
|
3079
|
+
lines: parsed.lines,
|
|
3080
|
+
output: trimmed === void 0 || trimmed === "" ? [] : [{
|
|
3081
|
+
type: "text",
|
|
3082
|
+
text: trimmed
|
|
3083
|
+
}],
|
|
3084
|
+
...parsed.usage === void 0 ? {} : { usage: parsed.usage }
|
|
3085
|
+
}, fromLines, userMirrored);
|
|
3086
|
+
};
|
|
3087
|
+
try {
|
|
3088
|
+
if (live === void 0) await work();
|
|
3089
|
+
else await live.enqueue(work);
|
|
3090
|
+
} catch (error) {
|
|
3091
|
+
spec.onError?.(thrown(error), "error");
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
//#endregion
|
|
3095
|
+
//#region lib/types/live-switch.js
|
|
3096
|
+
/**
|
|
3097
|
+
* The settings-driven live-driver switch: owns the active driver generation
|
|
3098
|
+
* and retires old ones on a settings change, without a plugin reload.
|
|
3099
|
+
*
|
|
3100
|
+
* Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
|
|
3101
|
+
* new rounds are refused (the provider's catch falls back to exec), in-flight
|
|
3102
|
+
* rounds finish on their runtime, idle runtimes are reclaimed at once.
|
|
3103
|
+
* Toggling ON builds the next generation lazily (no process until the first
|
|
3104
|
+
* round). Legacy granularity settings are ignored.
|
|
3105
|
+
*
|
|
3106
|
+
* @module @khorsheed/dsh-local-agent-claude-code — internal, unit-tested directly.
|
|
3107
|
+
*/
|
|
3108
|
+
var LiveDriverSwitch = class {
|
|
3109
|
+
ctx;
|
|
3110
|
+
driverBase;
|
|
3111
|
+
liveOn = false;
|
|
3112
|
+
active;
|
|
3113
|
+
/** Retiring generations still draining their in-flight rounds. */
|
|
3114
|
+
draining = /* @__PURE__ */ new Set();
|
|
3115
|
+
unwatch;
|
|
3116
|
+
constructor(ctx, face, driverBase) {
|
|
3117
|
+
this.ctx = ctx;
|
|
3118
|
+
this.driverBase = driverBase;
|
|
3119
|
+
this.apply(face.get());
|
|
3120
|
+
this.unwatch = face.watch((next) => {
|
|
3121
|
+
this.apply(next);
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* Resolve the live driver for one round's member (the provider's call
|
|
3126
|
+
* site). A retiring generation still hosting the member gates the new one —
|
|
3127
|
+
* the round falls back to exec rather than double-loading the same claude
|
|
3128
|
+
* session into two processes.
|
|
3129
|
+
*/
|
|
3130
|
+
resolve = (childSessionId) => {
|
|
3131
|
+
if (this.active === void 0) return void 0;
|
|
3132
|
+
for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return void 0;
|
|
3133
|
+
return this.active;
|
|
3134
|
+
};
|
|
3135
|
+
/**
|
|
3136
|
+
* The generation currently hosting the member's runtime — the active one,
|
|
3137
|
+
* or a retiring generation still draining it (the model broker retires the
|
|
3138
|
+
* member's runtime through whichever generation owns it on a model switch).
|
|
3139
|
+
*/
|
|
3140
|
+
hostingDriver(childSessionId) {
|
|
3141
|
+
if (this.active?.hasRuntime(childSessionId) === true) return this.active;
|
|
3142
|
+
for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return retiring;
|
|
3143
|
+
}
|
|
3144
|
+
/** Mirror the resolved settings into the driver generation. */
|
|
3145
|
+
apply(next) {
|
|
3146
|
+
if (next.live === this.liveOn) return;
|
|
3147
|
+
this.liveOn = next.live;
|
|
3148
|
+
const retiring = this.active;
|
|
3149
|
+
this.active = next.live ? new ClaudeLiveDriver(this.ctx, { ...this.driverBase }) : void 0;
|
|
3150
|
+
if (retiring !== void 0) {
|
|
3151
|
+
this.draining.add(retiring);
|
|
3152
|
+
retiring.drain().then(() => {
|
|
3153
|
+
this.draining.delete(retiring);
|
|
3154
|
+
}, (error) => {
|
|
3155
|
+
this.draining.delete(retiring);
|
|
3156
|
+
this.ctx.logger.warn(`local-agent-claude-code: live driver drain failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3157
|
+
});
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
/** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
|
|
3161
|
+
dispose() {
|
|
3162
|
+
this.unwatch();
|
|
3163
|
+
this.active?.disposeAll();
|
|
3164
|
+
for (const retiring of this.draining) retiring.disposeAll();
|
|
3165
|
+
this.draining.clear();
|
|
3166
|
+
}
|
|
3167
|
+
};
|
|
3168
|
+
//#endregion
|
|
3169
|
+
//#region lib/types/provision.js
|
|
3170
|
+
/**
|
|
3171
|
+
* Claude Code scoped-home provisioning. Unlike codex (whose credential store
|
|
3172
|
+
* must be pinned to a file) and kimi (which needs a provider config),
|
|
3173
|
+
* claude's `CLAUDE_CONFIG_DIR` scoped home needs nothing beyond the
|
|
3174
|
+
* directory itself: login via `claude auth login` writes `.claude.json`
|
|
3175
|
+
* (with the `oauthAccount` record) into it, and the real credential lands in
|
|
3176
|
+
* the macOS keychain under a hashed entry or — on Linux, despite upstream
|
|
3177
|
+
* bug #47661 — in the default `~/.claude/.credentials.json` while the config
|
|
3178
|
+
* stays scoped. Sign-out removes the scoped config so `status` reports not
|
|
3179
|
+
* authenticated; the keychain entry on macOS persists until the CLI removes
|
|
3180
|
+
* it, which the framework's file-based logout cannot reach.
|
|
3181
|
+
* @module @khorsheed/dsh-local-agent-claude-code/provision
|
|
3182
|
+
*/
|
|
3183
|
+
/** The scoped config file holding the oauthAccount auth marker. */
|
|
3184
|
+
const SCOPED_CONFIG = ".claude.json";
|
|
3185
|
+
/** The scoped settings file carrying the CLI's env block. */
|
|
3186
|
+
const SCOPED_SETTINGS = "settings.json";
|
|
3187
|
+
/**
|
|
3188
|
+
* Provision the scoped home directory. Claude creates it lazily on first
|
|
3189
|
+
* use, but creating it eagerly keeps the harness's homeDir contract uniform
|
|
3190
|
+
* with the other harnesses. When `proxyUrl` is configured, the scoped
|
|
3191
|
+
* `settings.json` gains an env block pointing the CLI's own traffic at it —
|
|
3192
|
+
* claude reads proxy settings from this file, so the child works even when
|
|
3193
|
+
* the host process environment carries no proxy (a supervisor-spawned
|
|
3194
|
+
* instance's env is not the user's shell env). An existing settings file is
|
|
3195
|
+
* merged, not overwritten; only the two proxy keys are managed.
|
|
3196
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3197
|
+
* @param proxyUrl - optional HTTP proxy URL for the child CLI's traffic.
|
|
3198
|
+
*/
|
|
3199
|
+
async function provisionClaudeHome(homeDir, proxyUrl) {
|
|
3200
|
+
await mkdir(homeDir, { recursive: true });
|
|
3201
|
+
if (proxyUrl === void 0) return;
|
|
3202
|
+
const path = join(homeDir, SCOPED_SETTINGS);
|
|
3203
|
+
let settings = {};
|
|
3204
|
+
try {
|
|
3205
|
+
settings = JSON.parse(await readFile(path, "utf8"));
|
|
3206
|
+
} catch {}
|
|
3207
|
+
const env = settings.env ?? {};
|
|
3208
|
+
if (env.https_proxy === proxyUrl && env.http_proxy === proxyUrl) return;
|
|
3209
|
+
env.https_proxy = proxyUrl;
|
|
3210
|
+
env.http_proxy = proxyUrl;
|
|
3211
|
+
settings.env = env;
|
|
3212
|
+
await writeFile(path, JSON.stringify(settings, void 0, 2) + "\n");
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* Read the scoped settings' configured model: the `model` key of
|
|
3216
|
+
* `<homeDir>/settings.json` — the model a delegation round pins when the
|
|
3217
|
+
* scoped settings name one at all. Claude's own default model is decided by
|
|
3218
|
+
* the CLI and is deliberately NOT guessed here. Only the scoped file is
|
|
3219
|
+
* consulted (never the host's `~/.claude`); absent or malformed content
|
|
3220
|
+
* yields undefined.
|
|
3221
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3222
|
+
* @returns the configured model identifier, or undefined when none is set.
|
|
3223
|
+
*/
|
|
3224
|
+
async function readClaudeConfiguredModel(homeDir) {
|
|
3225
|
+
let text;
|
|
3226
|
+
try {
|
|
3227
|
+
text = await readFile(join(homeDir, SCOPED_SETTINGS), "utf8");
|
|
3228
|
+
} catch {
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
3231
|
+
try {
|
|
3232
|
+
const settings = JSON.parse(text);
|
|
3233
|
+
return typeof settings.model === "string" && settings.model !== "" ? settings.model : void 0;
|
|
3234
|
+
} catch {
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
/** Read the scoped native effort setting without changing the settings file. */
|
|
3239
|
+
async function readClaudeConfiguredEffort(homeDir) {
|
|
3240
|
+
try {
|
|
3241
|
+
const settings = JSON.parse(await readFile(join(homeDir, SCOPED_SETTINGS), "utf8"));
|
|
3242
|
+
return typeof settings.effortLevel === "string" && settings.effortLevel.trim() !== "" ? settings.effortLevel.trim() : void 0;
|
|
3243
|
+
} catch {
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Write (or, with undefined, remove) the scoped settings.json `model` key,
|
|
3249
|
+
* merging the rest of the file exactly as {@link provisionClaudeHome} merges
|
|
3250
|
+
* its env block. Only the live driver calls this: a resident runtime needs
|
|
3251
|
+
* its member's effective model IN THE FILE, because a `--resume` respawn
|
|
3252
|
+
* restores the session's own stored model over the `--model` flag. The exec
|
|
3253
|
+
* path never calls it — its `--model` flag always applies, and the file
|
|
3254
|
+
* stays exactly as the person edited it.
|
|
3255
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3256
|
+
* @param model - the model to scratch in, or undefined to remove the key.
|
|
3257
|
+
*/
|
|
3258
|
+
async function writeClaudeScopedModel(homeDir, model) {
|
|
3259
|
+
const path = join(homeDir, SCOPED_SETTINGS);
|
|
3260
|
+
let settings = {};
|
|
3261
|
+
try {
|
|
3262
|
+
settings = JSON.parse(await readFile(path, "utf8"));
|
|
3263
|
+
} catch {}
|
|
3264
|
+
if (model === void 0) {
|
|
3265
|
+
if (!("model" in settings)) return;
|
|
3266
|
+
delete settings["model"];
|
|
3267
|
+
} else {
|
|
3268
|
+
if (settings["model"] === model) return;
|
|
3269
|
+
settings["model"] = model;
|
|
3270
|
+
}
|
|
3271
|
+
await writeFile(path, JSON.stringify(settings, void 0, 2) + "\n");
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Sign out of the scoped account: remove the scoped config file, so
|
|
3275
|
+
* `/<name> status` reports not authenticated and the next login authorizes
|
|
3276
|
+
* a fresh account. The macOS keychain entry (a hashed
|
|
3277
|
+
* `Claude Code-credentials-<sha256(path)[:8]>` record keyed to the scoped
|
|
3278
|
+
* home path) is NOT removed — the CLI's `claude auth logout` owns that, and
|
|
3279
|
+
* the harness logout contract is file-based; the stale entry is harmless
|
|
3280
|
+
* because a fresh login rewrites the same hashed slot.
|
|
3281
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3282
|
+
*/
|
|
3283
|
+
async function claudeLogout(homeDir) {
|
|
3284
|
+
await rm(join(homeDir, SCOPED_CONFIG), { force: true });
|
|
3285
|
+
}
|
|
3286
|
+
//#endregion
|
|
3287
|
+
//#region lib/types/model-broker.js
|
|
3288
|
+
/**
|
|
3289
|
+
* The claude-code harness's model broker: the family gateway routes
|
|
3290
|
+
* `harnessModel` / `memberModel` / `setMemberModel` here, and the broker
|
|
3291
|
+
* answers the family's fixed resolution order — the session-level override
|
|
3292
|
+
* (the composer's picker), the delegation's recorded model, the plugin-config
|
|
3293
|
+
* `model` key (the settings card), the scoped settings.json's own `model`,
|
|
3294
|
+
* and last the CLI's built-in default, which names nothing.
|
|
3295
|
+
*
|
|
3296
|
+
* The override map is IN-MEMORY by design (it deliberately does not survive a
|
|
3297
|
+
* host restart) and SHARED: index.ts hands the same map to the provider's and
|
|
3298
|
+
* the live driver's model resolvers, so a composer switch reaches the next
|
|
3299
|
+
* round through every drive. With the live driver on, switching to a
|
|
3300
|
+
* DIFFERENT model retires the member's resident runtime so the next round
|
|
3301
|
+
* respawns onto the new model — the CLI session itself resumes on disk, so
|
|
3302
|
+
* the conversation carries over. A switch while a round is in flight is
|
|
3303
|
+
* refused: retiring a runtime mid-round would kill the run.
|
|
3304
|
+
*
|
|
3305
|
+
* `lastObserved` (the cli-builtin layer's display hint): the delegation
|
|
3306
|
+
* record's `observedModel` answers when the live-settle channel reported
|
|
3307
|
+
* one; the member's own CLI transcript (`transcriptModel`, a bounded read of
|
|
3308
|
+
* the projects tree) is the history backstop for rounds that predate that
|
|
3309
|
+
* channel — read-only, TTL-cached, never a write into `delegations.jsonl`.
|
|
3310
|
+
*
|
|
3311
|
+
* @module @khorsheed/dsh-local-agent-claude-code/model-broker — internal, unit-tested directly.
|
|
3312
|
+
*/
|
|
3313
|
+
/**
|
|
3314
|
+
* The scoped settings.json `model` key across live-spawn scratch writes. The
|
|
3315
|
+
* live driver writes a member's effective model into the file before a
|
|
3316
|
+
* resident spawn (a `--resume` respawn restores the session's stored model
|
|
3317
|
+
* over the `--model` flag), and this memory keeps the person's own configured
|
|
3318
|
+
* value straight across those writes: what the file held before our first
|
|
3319
|
+
* write is the `cli-config` layer, and a spawn with NO effective model
|
|
3320
|
+
* restores that value instead of leaking the previous member's model into
|
|
3321
|
+
* the next runtime. In-memory on purpose: a restart re-reads the file as it
|
|
3322
|
+
* stands. Writes serialize on an internal queue so concurrent member spawns
|
|
3323
|
+
* cannot lose each other's read-modify-write.
|
|
3324
|
+
*/
|
|
3325
|
+
var ClaudeScopedModelMemory = class {
|
|
3326
|
+
/** The value we last scratched in and believe the file still holds. */
|
|
3327
|
+
written;
|
|
3328
|
+
/** What the file held before our first write; null = it named no model. */
|
|
3329
|
+
previous = null;
|
|
3330
|
+
queue = Promise.resolve();
|
|
3331
|
+
/**
|
|
3332
|
+
* Bring the scoped settings.json's `model` key to the effective model a
|
|
3333
|
+
* spawn is about to bind — writing it, or restoring the person's value when
|
|
3334
|
+
* the spawn binds none and the file still holds our earlier scratch.
|
|
3335
|
+
* @param homeDir - the scoped home the spawn runs against.
|
|
3336
|
+
* @param model - the spawn's effective model, or undefined for none.
|
|
3337
|
+
*/
|
|
3338
|
+
provision(homeDir, model) {
|
|
3339
|
+
const run = this.queue.then(() => this.provisionLocked(homeDir, model));
|
|
3340
|
+
this.queue = run.catch(() => void 0);
|
|
3341
|
+
return run;
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* The person's configured model — the `cli-config` layer. A file value we
|
|
3345
|
+
* did not write reports as read; our own scratch value reports what the
|
|
3346
|
+
* file held before we wrote it.
|
|
3347
|
+
* @param homeDir - the scoped home to read.
|
|
3348
|
+
* @returns the person's configured model, or undefined when none.
|
|
3349
|
+
*/
|
|
3350
|
+
async cliDefault(homeDir) {
|
|
3351
|
+
const file = await readClaudeConfiguredModel(homeDir);
|
|
3352
|
+
if (file !== this.written) {
|
|
3353
|
+
this.previous = file ?? null;
|
|
3354
|
+
this.written = void 0;
|
|
3355
|
+
return file;
|
|
3356
|
+
}
|
|
3357
|
+
return this.previous ?? void 0;
|
|
3358
|
+
}
|
|
3359
|
+
async provisionLocked(homeDir, model) {
|
|
3360
|
+
const file = await readClaudeConfiguredModel(homeDir);
|
|
3361
|
+
if (file !== this.written) {
|
|
3362
|
+
this.previous = file ?? null;
|
|
3363
|
+
this.written = void 0;
|
|
3364
|
+
}
|
|
3365
|
+
if (model === void 0) {
|
|
3366
|
+
if (this.written === void 0) return;
|
|
3367
|
+
await writeClaudeScopedModel(homeDir, this.previous ?? void 0);
|
|
3368
|
+
this.written = void 0;
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
if (file === model) return;
|
|
3372
|
+
await writeClaudeScopedModel(homeDir, model);
|
|
3373
|
+
this.written = model;
|
|
3374
|
+
}
|
|
3375
|
+
};
|
|
3376
|
+
var ClaudeModelBroker = class {
|
|
3377
|
+
deps;
|
|
3378
|
+
constructor(deps) {
|
|
3379
|
+
this.deps = deps;
|
|
3380
|
+
}
|
|
3381
|
+
configurationAdapter(binding) {
|
|
3382
|
+
const context = this.deps.configurationContext?.(binding);
|
|
3383
|
+
if (context === void 0) throw new Error("Claude member configuration context is unavailable");
|
|
3384
|
+
const resolve = async (selection) => {
|
|
3385
|
+
let directory = await context.directory();
|
|
3386
|
+
if (directory.status === "loading") directory = await context.directory(true);
|
|
3387
|
+
const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel() ?? await readClaudeConfiguredModel(context.home) ?? directory.defaultModel;
|
|
3388
|
+
const entry = directory.entries.find((entry) => entry.value === model || entry.resolvedModel === model);
|
|
3389
|
+
const effort = (selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0) ?? await readClaudeConfiguredEffort(context.home) ?? entry?.reasoning?.default;
|
|
3390
|
+
if (effort !== void 0 && !entry?.reasoning?.options.some((option) => option.value === effort)) throw new Error("Claude has not advertised this reasoning effort for the selected model");
|
|
3391
|
+
return {
|
|
3392
|
+
...model === void 0 ? {} : { model },
|
|
3393
|
+
...effort === void 0 ? {} : { effort }
|
|
3394
|
+
};
|
|
3395
|
+
};
|
|
3396
|
+
const retire = async () => {
|
|
3397
|
+
await this.deps.liveSwitch.hostingDriver(binding.childSessionId)?.retireRuntime(binding.childSessionId);
|
|
3398
|
+
};
|
|
3399
|
+
return {
|
|
3400
|
+
validate: async (selection) => {
|
|
3401
|
+
await resolve(selection);
|
|
3402
|
+
},
|
|
3403
|
+
prepare: resolve,
|
|
3404
|
+
apply: async (selection) => {
|
|
3405
|
+
const resolved = await resolve(selection);
|
|
3406
|
+
await retire();
|
|
3407
|
+
return resolved;
|
|
3408
|
+
},
|
|
3409
|
+
reconcile: async (state) => {
|
|
3410
|
+
if (this.deps.localAgent.isDelegationActive(binding.childSessionId)) return {
|
|
3411
|
+
active: true,
|
|
3412
|
+
matches: "unknown",
|
|
3413
|
+
resolved: {}
|
|
3414
|
+
};
|
|
3415
|
+
await retire();
|
|
3416
|
+
return {
|
|
3417
|
+
active: false,
|
|
3418
|
+
matches: "current",
|
|
3419
|
+
resolved: await resolve(state.current.selection)
|
|
3420
|
+
};
|
|
3421
|
+
}
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
async modelDirectory(childSessionId, refresh = false) {
|
|
3425
|
+
if (refresh) await this.deps.directory?.(childSessionId, true);
|
|
3426
|
+
return (await this.modelInfo(childSessionId, childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId)?.model)).directory ?? {
|
|
3427
|
+
entries: [],
|
|
3428
|
+
complete: false,
|
|
3429
|
+
customInput: true,
|
|
3430
|
+
status: "unsupported",
|
|
3431
|
+
refreshing: false,
|
|
3432
|
+
revision: 0
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
async *followModelDirectory(childSessionId, signal) {
|
|
3436
|
+
if (this.deps.followDirectory === void 0) {
|
|
3437
|
+
yield await this.modelDirectory(childSessionId);
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
for await (const _snapshot of this.deps.followDirectory(childSessionId, signal)) yield await this.modelDirectory(childSessionId);
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* The `lastObserved` cache: childSessionId (empty key = the memberless
|
|
3444
|
+
* harness read) → the answer and when it was read. A miss is cached too —
|
|
3445
|
+
* a member with no transcript yet should not pay a rescan per card open.
|
|
3446
|
+
*/
|
|
3447
|
+
observedCache = /* @__PURE__ */ new Map();
|
|
3448
|
+
/**
|
|
3449
|
+
* The last model the member (or the harness) was observed running. The
|
|
3450
|
+
* live-settle channel is primary — a record whose latest round reported
|
|
3451
|
+
* `observedModel` answers without touching disk; the CLI's own transcripts
|
|
3452
|
+
* are the history backstop for every round that predates it.
|
|
3453
|
+
*/
|
|
3454
|
+
async lastObserved(childSessionId) {
|
|
3455
|
+
const record = childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId);
|
|
3456
|
+
if (childSessionId !== void 0 && record === void 0) return void 0;
|
|
3457
|
+
if (record?.observedModel !== void 0 && record.observedModel.trim() !== "") return record.observedModel;
|
|
3458
|
+
const key = childSessionId ?? "";
|
|
3459
|
+
const hit = this.observedCache.get(key);
|
|
3460
|
+
if (hit !== void 0 && Date.now() - hit.at < 6e4) return hit.model;
|
|
3461
|
+
const model = await this.deps.transcriptModel(record?.cliSessionId, childSessionId).catch(() => void 0);
|
|
3462
|
+
this.observedCache.set(key, {
|
|
3463
|
+
at: Date.now(),
|
|
3464
|
+
model
|
|
3465
|
+
});
|
|
3466
|
+
return model;
|
|
3467
|
+
}
|
|
3468
|
+
/**
|
|
3469
|
+
* Read the model surface in the family's fixed order. Without a member the
|
|
3470
|
+
* override and delegation layers stay absent — the settings card's "what
|
|
3471
|
+
* would a round run with" read.
|
|
3472
|
+
*/
|
|
3473
|
+
async modelInfo(childSessionId, delegationModel) {
|
|
3474
|
+
const override = childSessionId === void 0 ? void 0 : this.deps.overrides.get(childSessionId);
|
|
3475
|
+
const delegation = delegationModel?.trim() === "" ? void 0 : delegationModel?.trim();
|
|
3476
|
+
const settings = this.deps.settingsModel();
|
|
3477
|
+
const cliDefault = await this.deps.cliDefault(childSessionId);
|
|
3478
|
+
const native = await this.deps.directory?.(childSessionId);
|
|
3479
|
+
const [effective, source] = override !== void 0 ? [override, "override"] : delegation !== void 0 ? [delegation, "delegation"] : settings !== void 0 ? [settings, "settings"] : cliDefault !== void 0 ? [cliDefault, "cli-config"] : [native?.defaultModel, "cli-builtin"];
|
|
3480
|
+
const choices = [...new Set([
|
|
3481
|
+
settings,
|
|
3482
|
+
cliDefault,
|
|
3483
|
+
...native?.entries.filter((entry) => !entry.hidden).map((entry) => entry.value) ?? [],
|
|
3484
|
+
...this.deps.recentModels()
|
|
3485
|
+
].filter((value) => value !== void 0))];
|
|
3486
|
+
const lastObserved = await this.lastObserved(childSessionId);
|
|
3487
|
+
const active = childSessionId !== void 0 && this.deps.localAgent.isDelegationActive(childSessionId);
|
|
3488
|
+
return {
|
|
3489
|
+
...effective === void 0 ? {} : { effective },
|
|
3490
|
+
source,
|
|
3491
|
+
...override === void 0 ? {} : { override },
|
|
3492
|
+
...delegation === void 0 ? {} : { delegation },
|
|
3493
|
+
...settings === void 0 ? {} : { settings },
|
|
3494
|
+
...cliDefault === void 0 ? {} : { cliDefault },
|
|
3495
|
+
...lastObserved === void 0 ? {} : { lastObserved },
|
|
3496
|
+
choices,
|
|
3497
|
+
...native === void 0 ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], [...this.deps.recentModels(), lastObserved]) },
|
|
3498
|
+
live: this.deps.live(),
|
|
3499
|
+
switchable: !active,
|
|
3500
|
+
...active ? { reason: "该成员有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight)" } : {}
|
|
3501
|
+
};
|
|
3502
|
+
}
|
|
3503
|
+
/**
|
|
3504
|
+
* Set or clear a member's session-level override. Throws while a round is
|
|
3505
|
+
* in flight for the member; a same-model set is a no-op; a real change
|
|
3506
|
+
* retires the member's resident runtime when its bound model is no longer
|
|
3507
|
+
* what the next round would spawn with (the CLI session resumes on the
|
|
3508
|
+
* respawn, so the conversation carries over).
|
|
3509
|
+
*/
|
|
3510
|
+
async setMemberModel(childSessionId, model) {
|
|
3511
|
+
if (this.deps.localAgent.setMemberModel !== void 0) return this.deps.localAgent.setMemberModel(childSessionId, model);
|
|
3512
|
+
if (this.deps.localAgent.isDelegationActive(childSessionId)) throw new Error(`subagent-claude: 成员 ${childSessionId} 有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight for this member)`);
|
|
3513
|
+
const trimmed = model?.trim();
|
|
3514
|
+
const next = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
|
|
3515
|
+
if (next === this.deps.overrides.get(childSessionId)) return;
|
|
3516
|
+
if (next === void 0) this.deps.overrides.delete(childSessionId);
|
|
3517
|
+
else this.deps.overrides.set(childSessionId, next);
|
|
3518
|
+
const delegation = this.deps.localAgent.getDelegation(childSessionId)?.model?.trim();
|
|
3519
|
+
const spawnModel = next ?? (delegation === void 0 || delegation === "" ? void 0 : delegation) ?? this.deps.settingsModel();
|
|
3520
|
+
const host = this.deps.liveSwitch.hostingDriver(childSessionId);
|
|
3521
|
+
if (host !== void 0 && host.runtimeModel(childSessionId) !== spawnModel) await host.retireRuntime(childSessionId);
|
|
3522
|
+
}
|
|
3523
|
+
};
|
|
3524
|
+
//#endregion
|
|
3525
|
+
//#region lib/types/index.js
|
|
3526
|
+
/**
|
|
3527
|
+
* Claude Code harness instantiation of the local-agent family. Registers the
|
|
3528
|
+
* `claude-code` harness into the core registry: scoped home under the shared
|
|
3529
|
+
* homes root (`CLAUDE_CONFIG_DIR`), login as a manual handoff (claude ≥2.1
|
|
3530
|
+
* prints no OAuth URL off a TTY, so `/login` replies with the exact terminal
|
|
3531
|
+
* command and the registry watches for the credential), and project-file
|
|
3532
|
+
* session records. Delegation mounts through the `claude-local` provider
|
|
3533
|
+
* (one-shot `claude -p --output-format json` under the scoped home); the
|
|
3534
|
+
* bundle patch's `tool-subagent-claude-code-local` row puts the tool on the
|
|
3535
|
+
* profile root, so every agent preset can delegate.
|
|
3536
|
+
*
|
|
3537
|
+
* @module @khorsheed/dsh-local-agent-claude-code
|
|
3538
|
+
*/
|
|
3539
|
+
/** Stable Cordis plugin name; the bundle patch row id. */
|
|
3540
|
+
const name = "local-agent-claude-code";
|
|
3541
|
+
/** Services required before the harness can register. */
|
|
3542
|
+
const inject = [
|
|
3543
|
+
"localAgent",
|
|
3544
|
+
"subagents",
|
|
3545
|
+
"subprocess",
|
|
3546
|
+
"settings"
|
|
3547
|
+
];
|
|
3548
|
+
/**
|
|
3549
|
+
* The settings fields (`live`, `liveMirrorGranularity`, `model`,
|
|
3550
|
+
* `recentModels`) ride the plugin Config, marked `.volatile()` where the
|
|
3551
|
+
* host's schemastery has it (3.18.4+, the rc.1 line — probed, never sniffed):
|
|
3552
|
+
* there the SettingsForms writes hot-track the running fiber's Volatile
|
|
3553
|
+
* references without a remount, and the official one-shot import moves an old
|
|
3554
|
+
* settings.yaml section into this row's config. On 0.1.5 the call is absent,
|
|
3555
|
+
* the fields stay plain config keys (a semantic superset — the profile row
|
|
3556
|
+
* can now preset them too), and the legacy settings namespace below carries
|
|
3557
|
+
* the user layer exactly as before. `model` deliberately carries NO default:
|
|
3558
|
+
* an unset key must resolve to undefined, which is what keeps the pre-key
|
|
3559
|
+
* behavior byte-identical.
|
|
3560
|
+
*/
|
|
3561
|
+
const SETTINGS_FIELDS = {
|
|
3562
|
+
live: z.boolean().default(false),
|
|
3563
|
+
liveMirrorGranularity: z.union([z.const("event"), z.const("token")]).default("token"),
|
|
3564
|
+
model: z.string(),
|
|
3565
|
+
recentModels: z.array(z.string()).default([])
|
|
3566
|
+
};
|
|
3567
|
+
/** Mark one schema field volatile when the host's schemastery has the method (rc.1), pass it through plain when not (0.1.5). */
|
|
3568
|
+
function volatilize(field) {
|
|
3569
|
+
return typeof field.volatile === "function" ? field.volatile() : field;
|
|
3570
|
+
}
|
|
3571
|
+
const Config = z.object({
|
|
3572
|
+
permissionMode: z.union([z.const("skip"), z.const("normal")]),
|
|
3573
|
+
model: volatilize(SETTINGS_FIELDS.model),
|
|
3574
|
+
baseUrl: z.string(),
|
|
3575
|
+
proxyUrl: z.string(),
|
|
3576
|
+
live: volatilize(SETTINGS_FIELDS.live),
|
|
3577
|
+
liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
|
|
3578
|
+
liveMirrorGranularity: volatilize(SETTINGS_FIELDS.liveMirrorGranularity),
|
|
3579
|
+
recentModels: volatilize(SETTINGS_FIELDS.recentModels)
|
|
3580
|
+
});
|
|
3581
|
+
/** The permission mode a fresh delegation defaults to. */
|
|
3582
|
+
const DEFAULT_PERMISSION_MODE = "skip";
|
|
3583
|
+
/**
|
|
3584
|
+
* Settings namespace for the settings-page card. The Cordis config feeds the
|
|
3585
|
+
* composition `base` layer, so a field absent from the user layer inherits the
|
|
3586
|
+
* YAML value — the card only ever stores deliberate overrides.
|
|
3587
|
+
*/
|
|
3588
|
+
const CLAUDE_SETTINGS_NAMESPACE = "local-agent-claude-code";
|
|
3589
|
+
/**
|
|
3590
|
+
* The card's 0.1.5 schema; field defaults are the innermost layer below
|
|
3591
|
+
* `base`. Shared with the Config schema's settings fields above.
|
|
3592
|
+
*/
|
|
3593
|
+
const CLAUDE_SETTINGS_SCHEMA = z.object(SETTINGS_FIELDS);
|
|
3594
|
+
/**
|
|
3595
|
+
* Register the Claude Code harness into the local-agent registry.
|
|
3596
|
+
* @param ctx - plugin context carrying the registry.
|
|
3597
|
+
* @param config - plugin config; `permissionMode` selects the `claude -p` policy.
|
|
3598
|
+
*/
|
|
3599
|
+
function apply(ctx, config) {
|
|
3600
|
+
ctx.effect(() => {
|
|
3601
|
+
const permissionMode = config.permissionMode ?? "skip";
|
|
3602
|
+
const baseUrl = config.baseUrl;
|
|
3603
|
+
const homeDir = ctx.localAgent.homeDir("claude-code");
|
|
3604
|
+
provisionClaudeHome(homeDir, config.proxyUrl).catch((error) => {
|
|
3605
|
+
ctx.logger.warn(`local-agent-claude-code: scoped home provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3606
|
+
});
|
|
3607
|
+
const readSettings = () => {
|
|
3608
|
+
const model = vol(config.model);
|
|
3609
|
+
const recentModels = vol(config.recentModels);
|
|
3610
|
+
return {
|
|
3611
|
+
live: vol(config.live) ?? false,
|
|
3612
|
+
liveMirrorGranularity: vol(config.liveMirrorGranularity) ?? "token",
|
|
3613
|
+
...model === void 0 ? {} : { model },
|
|
3614
|
+
...recentModels === void 0 ? {} : { recentModels }
|
|
3615
|
+
};
|
|
3616
|
+
};
|
|
3617
|
+
const legacyBase = {};
|
|
3618
|
+
for (const field of [
|
|
3619
|
+
"live",
|
|
3620
|
+
"liveMirrorGranularity",
|
|
3621
|
+
"model"
|
|
3622
|
+
]) {
|
|
3623
|
+
const value = vol(config[field]);
|
|
3624
|
+
if (value !== void 0) legacyBase[field] = value;
|
|
3625
|
+
}
|
|
3626
|
+
const face = settingsFace(ctx, {
|
|
3627
|
+
namespace: CLAUDE_SETTINGS_NAMESPACE,
|
|
3628
|
+
legacySchema: CLAUDE_SETTINGS_SCHEMA,
|
|
3629
|
+
legacyBase,
|
|
3630
|
+
read: readSettings
|
|
3631
|
+
});
|
|
3632
|
+
const resolveModel = () => {
|
|
3633
|
+
const model = face.get().model?.trim();
|
|
3634
|
+
return model === void 0 || model === "" ? void 0 : model;
|
|
3635
|
+
};
|
|
3636
|
+
const overrides = /* @__PURE__ */ new Map();
|
|
3637
|
+
const scopedModelMemory = new ClaudeScopedModelMemory();
|
|
3638
|
+
const effectiveModel = (childSessionId, delegationModel) => {
|
|
3639
|
+
const own = overrides.get(childSessionId)?.trim();
|
|
3640
|
+
if (own !== void 0 && own !== "") return own;
|
|
3641
|
+
const named = delegationModel?.trim();
|
|
3642
|
+
if (named !== void 0 && named !== "") return named;
|
|
3643
|
+
return resolveModel();
|
|
3644
|
+
};
|
|
3645
|
+
const liveSwitch = new LiveDriverSwitch(ctx, face, {
|
|
3646
|
+
...config.permissionMode === void 0 ? {} : { permissionMode: config.permissionMode },
|
|
3647
|
+
...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
|
|
3648
|
+
...config.liveIdleMs === void 0 ? {} : { liveIdleMs: config.liveIdleMs },
|
|
3649
|
+
model: effectiveModel,
|
|
3650
|
+
provisionModel: (home, model) => scopedModelMemory.provision(home, model)
|
|
3651
|
+
});
|
|
3652
|
+
const modelCatalog = new ClaudeModelCatalog({
|
|
3653
|
+
spawn: (spec) => ctx.subprocess.spawn(spec),
|
|
3654
|
+
...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
|
|
3655
|
+
warn: (message) => ctx.logger.warn(message)
|
|
3656
|
+
});
|
|
3657
|
+
const modelContext = (childSessionId) => {
|
|
3658
|
+
const record = childSessionId === void 0 ? void 0 : ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId);
|
|
3659
|
+
return {
|
|
3660
|
+
home: ctx.localAgent.homeDir("claude-code", record?.scope),
|
|
3661
|
+
cwd: record?.cwd
|
|
3662
|
+
};
|
|
3663
|
+
};
|
|
3664
|
+
const modelBroker = new ClaudeModelBroker({
|
|
3665
|
+
localAgent: ctx.localAgent,
|
|
3666
|
+
configurationContext: (binding) => {
|
|
3667
|
+
const home = ctx.localAgent.homeDir("claude-code", binding.scope);
|
|
3668
|
+
return {
|
|
3669
|
+
home,
|
|
3670
|
+
directory: (refresh) => refresh ? modelCatalog.refresh(home, binding.cwd) : modelCatalog.read(home, binding.cwd)
|
|
3671
|
+
};
|
|
3672
|
+
},
|
|
3673
|
+
settingsModel: resolveModel,
|
|
3674
|
+
cliDefault: (childSessionId) => {
|
|
3675
|
+
const context = modelContext(childSessionId);
|
|
3676
|
+
return context.home === homeDir ? scopedModelMemory.cliDefault(homeDir) : readClaudeConfiguredModel(context.home);
|
|
3677
|
+
},
|
|
3678
|
+
directory: (childSessionId, refresh) => {
|
|
3679
|
+
const context = modelContext(childSessionId);
|
|
3680
|
+
return refresh ? modelCatalog.refresh(context.home, context.cwd) : modelCatalog.read(context.home, context.cwd);
|
|
3681
|
+
},
|
|
3682
|
+
followDirectory: (childSessionId, signal) => {
|
|
3683
|
+
const context = modelContext(childSessionId);
|
|
3684
|
+
return modelCatalog.follow(context.home, context.cwd, signal);
|
|
3685
|
+
},
|
|
3686
|
+
recentModels: () => face.get().recentModels ?? [],
|
|
3687
|
+
live: () => face.get().live,
|
|
3688
|
+
overrides,
|
|
3689
|
+
liveSwitch,
|
|
3690
|
+
transcriptModel: (cliSessionId, childSessionId) => readClaudeTranscriptModel(modelContext(childSessionId).home, cliSessionId)
|
|
3691
|
+
});
|
|
3692
|
+
const disposeProvider = ctx.subagents.registerProvider(new ClaudeCliProvider(ctx, permissionMode, baseUrl, liveSwitch.resolve, effectiveModel));
|
|
3693
|
+
const disposeHarness = ctx.localAgent.register({
|
|
3694
|
+
prepareMember: async ({ binding, childSession, configuration, signal }) => {
|
|
3695
|
+
const driver = liveSwitch.resolve(binding.childSessionId);
|
|
3696
|
+
if (driver === void 0 || driver.disabled) throw new Error("Enable the native live harness before preparing a coordinator");
|
|
3697
|
+
await driver.prepare({
|
|
3698
|
+
cwd: binding.cwd,
|
|
3699
|
+
homeDir: ctx.localAgent.homeDir("claude-code", binding.scope),
|
|
3700
|
+
childSession,
|
|
3701
|
+
parentSessionId: binding.parentSessionId,
|
|
3702
|
+
configuration: configuration.resolved
|
|
3703
|
+
}, signal);
|
|
3704
|
+
},
|
|
3705
|
+
name: "claude-code",
|
|
3706
|
+
displayName: "Claude Code",
|
|
3707
|
+
homeEnvVar: "CLAUDE_CONFIG_DIR",
|
|
3708
|
+
delegationProvider: "claude-local",
|
|
3709
|
+
login: {
|
|
3710
|
+
pty: {
|
|
3711
|
+
command: "env",
|
|
3712
|
+
args: (scopedHome) => [
|
|
3713
|
+
"-u",
|
|
3714
|
+
"ANTHROPIC_API_KEY",
|
|
3715
|
+
"-u",
|
|
3716
|
+
"ANTHROPIC_BASE_URL",
|
|
3717
|
+
`CLAUDE_CONFIG_DIR=${scopedHome}`,
|
|
3718
|
+
"claude",
|
|
3719
|
+
"auth",
|
|
3720
|
+
"login"
|
|
3721
|
+
]
|
|
3722
|
+
},
|
|
3723
|
+
watch: async (home) => {
|
|
3724
|
+
await syncClaudeCredentialFile(home, (message) => {
|
|
3725
|
+
ctx.logger.warn(message);
|
|
3726
|
+
});
|
|
3727
|
+
return claudeAuthenticated(home, (message) => {
|
|
3728
|
+
ctx.logger.warn(message);
|
|
3729
|
+
});
|
|
3730
|
+
}
|
|
3731
|
+
},
|
|
3732
|
+
records: { listSessions: (homeDir) => listClaudeSessions(homeDir) },
|
|
3733
|
+
provision: (scopedHome) => provisionClaudeHome(scopedHome, config.proxyUrl).then(() => {}),
|
|
3734
|
+
isAuthenticated: (home) => claudeAuthenticated(home, (message) => {
|
|
3735
|
+
ctx.logger.warn(message);
|
|
3736
|
+
}),
|
|
3737
|
+
credentialStamp: claudeCredentialStamp,
|
|
3738
|
+
logout: claudeLogout,
|
|
3739
|
+
modelBroker,
|
|
3740
|
+
effectiveSettings: async (scopedHome) => {
|
|
3741
|
+
const effectiveBaseUrl = baseUrl ?? process.env.ANTHROPIC_BASE_URL;
|
|
3742
|
+
const baseUrlHost = effectiveBaseUrl !== void 0 ? endpointHost(effectiveBaseUrl) : void 0;
|
|
3743
|
+
const [scopedModel, cliVersion] = await Promise.all([readClaudeConfiguredModel(scopedHome).catch(() => void 0), claudeCliVersion(ctx, scopedHome).catch(() => void 0)]);
|
|
3744
|
+
const model = resolveModel() ?? scopedModel;
|
|
3745
|
+
return {
|
|
3746
|
+
drive: face.get().live ? "live" : "exec",
|
|
3747
|
+
permissionMode,
|
|
3748
|
+
baseUrlSet: effectiveBaseUrl !== void 0,
|
|
3749
|
+
...baseUrlHost !== void 0 ? { baseUrlHost } : {},
|
|
3750
|
+
...model !== void 0 ? { model } : {},
|
|
3751
|
+
...cliVersion !== void 0 ? { cliVersion } : {}
|
|
3752
|
+
};
|
|
3753
|
+
},
|
|
3754
|
+
subcommand: (input, invocation) => {
|
|
3755
|
+
const [verb] = input.split(/\s+/);
|
|
3756
|
+
if (verb === "sessions") return listClaudeSessions(invocation.agent.ctx.localAgent.homeDir("claude-code")).then((records) => ({
|
|
3757
|
+
kind: "success",
|
|
3758
|
+
text: records.length === 0 ? "No claude sessions in the scoped home yet." : records.map((r) => `${r.id} ${r.workDir}`).join("\n")
|
|
3759
|
+
}));
|
|
3760
|
+
}
|
|
3761
|
+
});
|
|
3762
|
+
return () => {
|
|
3763
|
+
modelCatalog.dispose();
|
|
3764
|
+
disposeProvider();
|
|
3765
|
+
disposeHarness();
|
|
3766
|
+
liveSwitch.dispose();
|
|
3767
|
+
};
|
|
3768
|
+
}, "local-agent-claude-code: harness");
|
|
3769
|
+
}
|
|
3770
|
+
//#endregion
|
|
3771
|
+
export { CLAUDE_SETTINGS_NAMESPACE, Config, DEFAULT_PERMISSION_MODE, apply, inject, name };
|