@khorsheed/dsh-local-agent-claude-code 0.2.0-rc.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.en.md +24 -106
- package/README.i18n.yaml +2 -2
- package/README.md +24 -106
- package/lib/client.js +842 -315
- package/lib/index.js +1707 -1392
- package/lib/types/claude-cli-provider.d.ts +86 -6
- package/lib/types/claude-cli-provider.js +391 -146
- package/lib/types/client/SettingsCard.d.ts +75 -24
- package/lib/types/client/SettingsCard.js +106 -128
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/index.d.ts +12 -25
- package/lib/types/client/index.js +135 -50
- package/lib/types/client/locales.d.ts +36 -24
- package/lib/types/client/locales.js +40 -28
- package/lib/types/endpoint-auth.d.ts +181 -0
- package/lib/types/endpoint-auth.js +297 -0
- package/lib/types/index.d.ts +32 -16
- package/lib/types/index.js +128 -62
- package/lib/types/live-driver.d.ts +9 -6
- package/lib/types/live-driver.js +277 -119
- package/lib/types/live-switch.d.ts +4 -1
- package/lib/types/model-broker.d.ts +4 -2
- package/lib/types/model-broker.js +63 -10
- package/lib/types/model-catalog.d.ts +35 -6
- package/lib/types/model-catalog.js +58 -68
- package/lib/types/provision.d.ts +34 -27
- package/lib/types/provision.js +49 -31
- package/lib/types/records.d.ts +4 -62
- package/lib/types/records.js +5 -382
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +20 -21
- package/lib/invariant.js +0 -31
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -33
package/lib/index.js
CHANGED
|
@@ -1,87 +1,311 @@
|
|
|
1
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";
|
|
2
|
+
import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, firstModelOf, modelDirectoryContextKey, persistChildSession, probeCliVersion, readModelList, readModelListSync, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol, writeModelList } from "@khorsheed/dsh-local-agent";
|
|
3
3
|
import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV, endpointHost } from "@khorsheed/dsh-local-agent/types";
|
|
4
|
-
import {
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
6
6
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
7
|
-
import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
|
|
8
|
-
import {
|
|
9
|
-
import { StringDecoder } from "node:string_decoder";
|
|
10
|
-
import { execFile } from "node:child_process";
|
|
7
|
+
import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
|
|
8
|
+
import { join } from "node:path";
|
|
11
9
|
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
12
|
-
import {
|
|
13
|
-
//#region lib/types/
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
10
|
+
import { StringDecoder } from "node:string_decoder";
|
|
11
|
+
//#region lib/types/endpoint-auth.js
|
|
12
|
+
/**
|
|
13
|
+
* Endpoint-key authentication — the claude-code harness's ONLY auth mode.
|
|
14
|
+
* There is no OAuth anywhere in this package: a delegation round reaches its
|
|
15
|
+
* endpoint with `ANTHROPIC_BASE_URL` plus `ANTHROPIC_API_KEY` (x-api-key) or
|
|
16
|
+
* `ANTHROPIC_AUTH_TOKEN` (Authorization: Bearer), exactly the environment a
|
|
17
|
+
* shell would export for a proxy relay, and `claude -p` needs nothing else
|
|
18
|
+
* (verified against claude 2.1.296: a fresh scoped home with only these
|
|
19
|
+
* variables runs a round and scaffolds `.claude.json` itself — no onboarding
|
|
20
|
+
* or key-approval gate in headless mode).
|
|
21
|
+
*
|
|
22
|
+
* Two layers, merged per field, file over config:
|
|
23
|
+
*
|
|
24
|
+
* - `<scopedHome>/endpoint-auth.json` (mode 0600) — the per-scope credential.
|
|
25
|
+
* Its name deliberately matches the eval contract hash's credential denylist
|
|
26
|
+
* (`auth`), so the key material never enters an evaluation's condition
|
|
27
|
+
* fingerprint; the file's mtime serves as the credential stamp, which makes
|
|
28
|
+
* "edit the file" the recovery gesture for a `rejected` grade.
|
|
29
|
+
* - The plugin config's `baseUrl` / `apiKey` / `authToken` — the deployment
|
|
30
|
+
* default every scope without a file shares. Read PER ROUND from the
|
|
31
|
+
* apply-time snapshot, so a profile edit applies to the next round (and a
|
|
32
|
+
* host restart clears the in-memory `rejected` mark).
|
|
33
|
+
*
|
|
34
|
+
* The env layer this module emits rides the family's existing plumbing:
|
|
35
|
+
* `delegationEnv` on the host, and `containerExecSpawn`'s name-only `-e`
|
|
36
|
+
* forwarding into a container (the value travels in the docker client's
|
|
37
|
+
* environment, never on an argv). A containerized round therefore
|
|
38
|
+
* authenticates with the same credential as the host — nothing is authorized
|
|
39
|
+
* inside the unit, which is the point.
|
|
40
|
+
* @module @khorsheed/dsh-local-agent-claude-code/endpoint-auth
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The scoped-home credential file's name. Chosen to match the eval contract
|
|
44
|
+
* hash's credential-shaped denylist (`/token|key|credential|secret|password|auth|\.env/i`),
|
|
45
|
+
* so the file is excluded from condition fingerprints by name.
|
|
46
|
+
*/
|
|
47
|
+
const ENDPOINT_AUTH_FILE = "endpoint-auth.json";
|
|
48
|
+
/** Trim and normalize one credential field; blank reads as absent. */
|
|
49
|
+
function field(value) {
|
|
50
|
+
if (typeof value !== "string") return void 0;
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
53
|
+
}
|
|
54
|
+
/** Whether a resolved endpoint carries a usable credential. */
|
|
55
|
+
function hasEndpointCredential(auth) {
|
|
56
|
+
return auth !== void 0 && (field(auth.apiKey) !== void 0 || field(auth.authToken) !== void 0);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read the scope's endpoint-auth.json. Absent or unreadable yields undefined
|
|
60
|
+
* (the caller falls back to the plugin config); malformed JSON or a non-object
|
|
61
|
+
* payload also yields undefined — a torn file must not fail a status read.
|
|
62
|
+
* Non-string fields are dropped, never carried through.
|
|
63
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
64
|
+
* @returns the file's endpoint credential, or undefined.
|
|
65
|
+
*/
|
|
66
|
+
async function readEndpointAuthFile(homeDir) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = await readFile(join(homeDir, ENDPOINT_AUTH_FILE), "utf8");
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
24
72
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(text);
|
|
75
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
76
|
+
const baseUrl = field(parsed["baseUrl"]);
|
|
77
|
+
const apiKey = field(parsed["apiKey"]);
|
|
78
|
+
const authToken = field(parsed["authToken"]);
|
|
79
|
+
return {
|
|
80
|
+
...baseUrl === void 0 ? {} : { baseUrl },
|
|
81
|
+
...apiKey === void 0 ? {} : { apiKey },
|
|
82
|
+
...authToken === void 0 ? {} : { authToken }
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
38
86
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve one scope's effective endpoint credential: the scope's
|
|
90
|
+
* endpoint-auth.json merged FIELD BY FIELD over the plugin config's
|
|
91
|
+
* `baseUrl` / `apiKey` / `authToken` (a file that names only a key rotates
|
|
92
|
+
* the key while keeping the deployment's relay URL). The result may carry no
|
|
93
|
+
* credential at all — {@link hasEndpointCredential} is the judgment, kept
|
|
94
|
+
* separate so the caller can still route/log the resolved baseUrl.
|
|
95
|
+
* @param homeDir - the scope's scoped home.
|
|
96
|
+
* @param config - the plugin config's deployment-level endpoint triple.
|
|
97
|
+
* @returns the merged endpoint auth.
|
|
98
|
+
*/
|
|
99
|
+
async function resolveEndpointAuth(homeDir, config) {
|
|
100
|
+
const file = await readEndpointAuthFile(homeDir);
|
|
101
|
+
const configBaseUrl = field(config.baseUrl);
|
|
102
|
+
const configApiKey = field(config.apiKey);
|
|
103
|
+
const configAuthToken = field(config.authToken);
|
|
104
|
+
return {
|
|
105
|
+
...configBaseUrl === void 0 ? {} : { baseUrl: configBaseUrl },
|
|
106
|
+
...configApiKey === void 0 ? {} : { apiKey: configApiKey },
|
|
107
|
+
...configAuthToken === void 0 ? {} : { authToken: configAuthToken },
|
|
108
|
+
...file
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The spawn-env layer for a resolved endpoint: one entry per defined field,
|
|
113
|
+
* nothing else. Undefined yields an empty layer — the spread then adds no
|
|
114
|
+
* keys at all, byte-identical to the pre-endpoint env.
|
|
115
|
+
* @param auth - the resolved endpoint auth, or undefined.
|
|
116
|
+
* @returns the explicit env entries for the delegation spawn.
|
|
117
|
+
*/
|
|
118
|
+
function endpointAuthEnv(auth) {
|
|
119
|
+
if (auth === void 0) return {};
|
|
120
|
+
const baseUrl = field(auth.baseUrl);
|
|
121
|
+
const apiKey = field(auth.apiKey);
|
|
122
|
+
const authToken = field(auth.authToken);
|
|
123
|
+
return {
|
|
124
|
+
...baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: baseUrl },
|
|
125
|
+
...apiKey === void 0 ? {} : { ANTHROPIC_API_KEY: apiKey },
|
|
126
|
+
...authToken === void 0 ? {} : { ANTHROPIC_AUTH_TOKEN: authToken }
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The credential marker's modification stamp (epoch ms): the endpoint file's
|
|
131
|
+
* mtime, or undefined when the scope has no file (a config-sourced credential
|
|
132
|
+
* has no marker — rotating it pairs with the profile edit and instance
|
|
133
|
+
* restart that clear the in-memory grade marks). A fresh file write is a
|
|
134
|
+
* fresh stamp, which is how an edited-in key recovers a `rejected` grade.
|
|
135
|
+
* @param homeDir - the scope's scoped home.
|
|
136
|
+
* @returns the stamp, or undefined.
|
|
137
|
+
*/
|
|
138
|
+
async function endpointAuthStamp(homeDir) {
|
|
139
|
+
try {
|
|
140
|
+
return (await stat(join(homeDir, ENDPOINT_AUTH_FILE))).mtimeMs;
|
|
141
|
+
} catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Whether the scope carries its own endpoint file at all. */
|
|
146
|
+
async function endpointAuthFileExists(homeDir) {
|
|
147
|
+
return await endpointAuthStamp(homeDir) !== void 0;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Write the scope's endpoint-auth.json (mode 0600, mkdir -p first), the
|
|
151
|
+
* per-scope provisioning path an evaluation's `conditions provision` (or a
|
|
152
|
+
* person) uses to give one scope its own credential. Whole-file write: the
|
|
153
|
+
* payload is exactly what {@link readEndpointAuthFile} parses back.
|
|
154
|
+
* @param homeDir - the scope's scoped home.
|
|
155
|
+
* @param auth - the endpoint credential to persist.
|
|
156
|
+
*/
|
|
157
|
+
async function writeEndpointAuth(homeDir, auth) {
|
|
158
|
+
await mkdir(homeDir, { recursive: true });
|
|
159
|
+
const baseUrl = field(auth.baseUrl);
|
|
160
|
+
const apiKey = field(auth.apiKey);
|
|
161
|
+
const authToken = field(auth.authToken);
|
|
162
|
+
const payload = {
|
|
163
|
+
...baseUrl === void 0 ? {} : { baseUrl },
|
|
164
|
+
...apiKey === void 0 ? {} : { apiKey },
|
|
165
|
+
...authToken === void 0 ? {} : { authToken }
|
|
166
|
+
};
|
|
167
|
+
await writeFile(join(homeDir, ENDPOINT_AUTH_FILE), JSON.stringify(payload, void 0, 2) + "\n", { mode: 384 });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Clear the scope's OWN endpoint credential (the endpoint-auth.json file).
|
|
171
|
+
* A deployment-level credential from the plugin config is untouched — the
|
|
172
|
+
* core's logout reply then names the profile row as the place to edit.
|
|
173
|
+
* @param homeDir - the scope's scoped home.
|
|
174
|
+
*/
|
|
175
|
+
async function clearEndpointAuth(homeDir) {
|
|
176
|
+
await rm(join(homeDir, ENDPOINT_AUTH_FILE), { force: true });
|
|
177
|
+
}
|
|
178
|
+
/** How many served model ids the check reply lists before truncating. */
|
|
179
|
+
const CHECK_MODEL_LIST_LIMIT = 8;
|
|
180
|
+
/** Bound on the error-body excerpt a failed check reports. */
|
|
181
|
+
const CHECK_ERROR_BODY_LIMIT = 200;
|
|
182
|
+
/** The effort levels one served-model entry advertises, oldest-first. */
|
|
183
|
+
function servedEfforts(effort) {
|
|
184
|
+
if (typeof effort !== "object" || effort === null) return void 0;
|
|
185
|
+
const levels = [
|
|
186
|
+
"low",
|
|
187
|
+
"medium",
|
|
188
|
+
"high",
|
|
189
|
+
"xhigh",
|
|
190
|
+
"max"
|
|
191
|
+
].filter((level) => effort[level]?.supported === true);
|
|
192
|
+
return levels.length === 0 ? void 0 : levels;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The effort block's home in a served-model entry. Anthropic's current
|
|
196
|
+
* /v1/models shape nests it under `capabilities.effort` (verified against the
|
|
197
|
+
* openproxy relay 2026-10-22: `capabilities.effort.<level>.supported`); the
|
|
198
|
+
* flat top-level `effort` the parser was written against still wins when
|
|
199
|
+
* present, so either shape answers.
|
|
200
|
+
*/
|
|
201
|
+
function servedEffortsOf(record) {
|
|
202
|
+
const direct = servedEfforts(record["effort"]);
|
|
203
|
+
if (direct !== void 0) return direct;
|
|
204
|
+
const capabilities = record["capabilities"];
|
|
205
|
+
if (typeof capabilities !== "object" || capabilities === null) return void 0;
|
|
206
|
+
return servedEfforts(capabilities["effort"]);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* `GET <baseUrl>/v1/models` with the resolved credential's auth header(s) —
|
|
210
|
+
* the token-free endpoint probe shared by the `/claude-code check` verdict
|
|
211
|
+
* and the model catalog. Anthropic's own API serves this route with
|
|
212
|
+
* `x-api-key`; relays (openproxy verified) proxy it and answer the same
|
|
213
|
+
* shape. Never throws: every failure mode folds into the result.
|
|
214
|
+
* @param endpoint - the resolved endpoint auth (caller checks the credential).
|
|
215
|
+
* @param fetchImpl - the HTTP fetch; default the host's global fetch.
|
|
216
|
+
* @param signal - abort for the whole probe (timeout bundled by the caller).
|
|
217
|
+
* @returns the folded result.
|
|
218
|
+
*/
|
|
219
|
+
async function fetchEndpointModels(endpoint, fetchImpl, signal) {
|
|
220
|
+
const headers = { "anthropic-version": "2023-06-01" };
|
|
221
|
+
if (endpoint.apiKey !== void 0) headers["x-api-key"] = endpoint.apiKey;
|
|
222
|
+
if (endpoint.authToken !== void 0) headers["authorization"] = `Bearer ${endpoint.authToken}`;
|
|
223
|
+
const fetcher = fetchImpl ?? globalThis.fetch;
|
|
224
|
+
const url = `${endpoint.baseUrl.replace(/\/+$/, "")}/v1/models`;
|
|
225
|
+
let response;
|
|
226
|
+
try {
|
|
227
|
+
response = await fetcher(url, {
|
|
228
|
+
headers,
|
|
229
|
+
signal: signal ?? AbortSignal.timeout(8e3)
|
|
74
230
|
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return {
|
|
233
|
+
kind: "unreachable",
|
|
234
|
+
message: error instanceof Error ? error.message : String(error)
|
|
235
|
+
};
|
|
75
236
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
237
|
+
if (!response.ok) return {
|
|
238
|
+
kind: "http",
|
|
239
|
+
status: response.status,
|
|
240
|
+
body: (await response.text().catch(() => "")).slice(0, CHECK_ERROR_BODY_LIMIT)
|
|
241
|
+
};
|
|
242
|
+
let data;
|
|
243
|
+
try {
|
|
244
|
+
data = JSON.parse(await response.text()).data;
|
|
245
|
+
} catch {
|
|
246
|
+
return { kind: "malformed" };
|
|
247
|
+
}
|
|
248
|
+
if (!Array.isArray(data)) return { kind: "malformed" };
|
|
249
|
+
const models = [];
|
|
250
|
+
for (const entry of data) {
|
|
251
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
252
|
+
const record = entry;
|
|
253
|
+
if (typeof record.id !== "string" || record.id === "") continue;
|
|
254
|
+
const efforts = servedEffortsOf(record);
|
|
255
|
+
models.push({
|
|
256
|
+
id: record.id,
|
|
257
|
+
...typeof record.display_name === "string" && record.display_name !== "" ? { displayName: record.display_name } : {},
|
|
258
|
+
...efforts === void 0 ? {} : { efforts }
|
|
259
|
+
});
|
|
80
260
|
}
|
|
81
|
-
|
|
261
|
+
return {
|
|
262
|
+
kind: "models",
|
|
263
|
+
models
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Probe the resolved endpoint with a cheap, token-free round-trip: `GET
|
|
268
|
+
* <baseUrl>/v1/models` with the resolved credential's auth header(s). A 200
|
|
269
|
+
* proves both reachability and credential validity — the exact evidence a
|
|
270
|
+
* "verify" button needs — and the served model list rides the reply so the
|
|
271
|
+
* user sees what the endpoint offers (any id can be typed into the model
|
|
272
|
+
* field by hand). One transport-level retry rides along: a local proxy
|
|
273
|
+
* restart (a Surge/clash reload) stalls the first attempt into the timeout
|
|
274
|
+
* and answers the retry immediately. Never throws: every failure mode folds
|
|
275
|
+
* into the reply text, since the reply IS the diagnostic.
|
|
276
|
+
* @param endpoint - the resolved endpoint auth (scope file over settings).
|
|
277
|
+
* @param fetchImpl - the HTTP fetch; default the host's global fetch.
|
|
278
|
+
* @param timeoutMs - one attempt's deadline (two attempts at most).
|
|
279
|
+
* @returns the human-readable verdict for the `/claude-code check` reply.
|
|
280
|
+
*/
|
|
281
|
+
async function checkClaudeEndpoint(endpoint, fetchImpl, timeoutMs = 2e4) {
|
|
282
|
+
if (!hasEndpointCredential(endpoint)) return "No endpoint credential configured — fill API Key or Auth Token on the settings card (or the profile row) first.";
|
|
283
|
+
const baseUrl = (endpoint.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
|
|
284
|
+
let result = await fetchEndpointModels({
|
|
285
|
+
...endpoint,
|
|
286
|
+
baseUrl
|
|
287
|
+
}, fetchImpl, AbortSignal.timeout(timeoutMs));
|
|
288
|
+
if (result.kind === "unreachable") result = await fetchEndpointModels({
|
|
289
|
+
...endpoint,
|
|
290
|
+
baseUrl
|
|
291
|
+
}, fetchImpl, AbortSignal.timeout(timeoutMs));
|
|
292
|
+
if (result.kind === "unreachable") return `Endpoint unreachable (${baseUrl}): ${result.message}`;
|
|
293
|
+
if (result.kind === "http") return `Endpoint answered HTTP ${String(result.status)} (${baseUrl})${result.body === "" ? "" : `: ${result.body}`}`;
|
|
294
|
+
if (result.kind === "malformed") return `Endpoint reachable (${baseUrl}) but the model list did not parse — the credential worked, the reply was not /v1/models-shaped.`;
|
|
295
|
+
if (result.models.length === 0) return `Endpoint reachable (${baseUrl}); it serves no models.`;
|
|
296
|
+
const ids = result.models.map((model) => model.id);
|
|
297
|
+
const listed = ids.slice(0, CHECK_MODEL_LIST_LIMIT).join(", ");
|
|
298
|
+
const more = ids.length > CHECK_MODEL_LIST_LIMIT ? `, … +${String(ids.length - CHECK_MODEL_LIST_LIMIT)} more` : "";
|
|
299
|
+
return `Endpoint reachable (${baseUrl}): ${String(ids.length)} model(s) served — ${listed}${more}`;
|
|
300
|
+
}
|
|
82
301
|
//#endregion
|
|
83
302
|
//#region lib/types/model-catalog.js
|
|
84
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* Fold the CLI's own `initialize` model vocabulary. Consumed by the LIVE
|
|
305
|
+
* DRIVER's runtime init handling; the catalog no longer reads this — a
|
|
306
|
+
* local list cannot prove anything about the configured endpoint, and
|
|
307
|
+
* showing it made an invalid credential look valid.
|
|
308
|
+
*/
|
|
85
309
|
function claudeDirectory(result) {
|
|
86
310
|
const models = result["models"];
|
|
87
311
|
if (models === void 0) return {
|
|
@@ -119,7 +343,33 @@ function claudeDirectory(result) {
|
|
|
119
343
|
...entries.has("default") ? { defaultModel: "default" } : {}
|
|
120
344
|
};
|
|
121
345
|
}
|
|
122
|
-
/**
|
|
346
|
+
/** One directory answer that names why it is empty. */
|
|
347
|
+
function unsupported(reason) {
|
|
348
|
+
return {
|
|
349
|
+
entries: [],
|
|
350
|
+
complete: false,
|
|
351
|
+
customInput: true,
|
|
352
|
+
unsupported: true,
|
|
353
|
+
reason
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The model directory, sourced from the configured ENDPOINT (`GET
|
|
358
|
+
* <baseUrl>/v1/models` with the resolved credential) — never from the local
|
|
359
|
+
* CLI: what the picker lists is exactly what the endpoint serves, so a
|
|
360
|
+
* credential that does not work can never hide behind a locally-known list.
|
|
361
|
+
* The answers are deliberate:
|
|
362
|
+
*
|
|
363
|
+
* - no credential resolved → empty with a "save a key first" reason;
|
|
364
|
+
* - 401/403 → empty with the rejection (definitive — cached as data);
|
|
365
|
+
* - another HTTP status → empty with it (the relay serves no model route);
|
|
366
|
+
* - a transport failure THROWS, so the cache keeps the last good list and
|
|
367
|
+
* retries on schedule instead of blanking a momentarily-down endpoint.
|
|
368
|
+
*
|
|
369
|
+
* Effort levels ride the served entries' `effort` capability when the
|
|
370
|
+
* endpoint advertises them. Entries carry `source: 'native'` — they are the
|
|
371
|
+
* harness's native vocabulary, just read from the endpoint in force.
|
|
372
|
+
*/
|
|
123
373
|
var ClaudeModelCatalog = class {
|
|
124
374
|
deps;
|
|
125
375
|
cache;
|
|
@@ -135,22 +385,15 @@ var ClaudeModelCatalog = class {
|
|
|
135
385
|
});
|
|
136
386
|
}
|
|
137
387
|
key(homeDir, cwd = homeDir) {
|
|
138
|
-
const files = [
|
|
139
|
-
|
|
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
|
-
}
|
|
388
|
+
const files = [join(homeDir, "settings.json"), join(homeDir, "endpoint-auth.json")];
|
|
389
|
+
const endpointLabel = this.deps.endpointLabel?.();
|
|
147
390
|
return modelDirectoryContextKey({
|
|
148
391
|
provider: "claude-code",
|
|
149
392
|
homeDir,
|
|
150
393
|
cwd,
|
|
151
|
-
cli: [
|
|
394
|
+
cli: [],
|
|
152
395
|
files,
|
|
153
|
-
...
|
|
396
|
+
...endpointLabel === void 0 ? {} : { endpoint: endpointLabel }
|
|
154
397
|
});
|
|
155
398
|
}
|
|
156
399
|
read(homeDir, cwd) {
|
|
@@ -168,678 +411,106 @@ var ClaudeModelCatalog = class {
|
|
|
168
411
|
dispose() {
|
|
169
412
|
this.cache.dispose();
|
|
170
413
|
}
|
|
171
|
-
async probe(homeDir,
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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(() => {});
|
|
414
|
+
async probe(homeDir, _cwd, signal) {
|
|
415
|
+
const endpoint = await this.deps.endpoint?.(homeDir);
|
|
416
|
+
if (!hasEndpointCredential(endpoint)) return unsupported("No endpoint credential configured — the model list loads from the endpoint once an API Key or Auth Token is saved");
|
|
417
|
+
const result = await fetchEndpointModels({
|
|
418
|
+
...endpoint,
|
|
419
|
+
baseUrl: endpoint.baseUrl ?? "https://api.anthropic.com"
|
|
420
|
+
}, this.deps.fetchImpl, AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? 2e4)]));
|
|
421
|
+
if (result.kind === "unreachable") throw new Error(`endpoint unreachable: ${result.message}`);
|
|
422
|
+
if (result.kind === "malformed") return unsupported("The endpoint answered but the model list did not parse (not /v1/models-shaped)");
|
|
423
|
+
if (result.kind === "http") {
|
|
424
|
+
if (result.status === 401 || result.status === 403) return unsupported(`The endpoint rejected the credential (HTTP ${String(result.status)}) — the list loads once a valid key is saved`);
|
|
425
|
+
return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
|
|
253
426
|
}
|
|
427
|
+
const entries = result.models.map((model) => ({
|
|
428
|
+
value: model.id,
|
|
429
|
+
label: model.displayName ?? model.id,
|
|
430
|
+
source: "native",
|
|
431
|
+
...model.efforts === void 0 ? {} : { reasoning: { options: model.efforts.map((effort) => ({
|
|
432
|
+
value: effort,
|
|
433
|
+
label: effort
|
|
434
|
+
})) } }
|
|
435
|
+
}));
|
|
436
|
+
if (entries.length === 0) return unsupported("The endpoint serves no models");
|
|
437
|
+
return {
|
|
438
|
+
entries,
|
|
439
|
+
complete: true,
|
|
440
|
+
customInput: true
|
|
441
|
+
};
|
|
254
442
|
}
|
|
255
443
|
};
|
|
256
444
|
//#endregion
|
|
257
|
-
//#region lib/types/
|
|
258
|
-
/**
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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;
|
|
445
|
+
//#region lib/types/control-requests.js
|
|
446
|
+
/** Bounded, correlated Claude stream-json controls, shared by discovery and live sessions. */
|
|
447
|
+
var ClaudeControlRequests = class {
|
|
448
|
+
send;
|
|
449
|
+
timeoutMs;
|
|
450
|
+
sequence = 0;
|
|
451
|
+
closed = false;
|
|
452
|
+
pending = /* @__PURE__ */ new Map();
|
|
453
|
+
constructor(send, timeoutMs = 5e3) {
|
|
454
|
+
this.send = send;
|
|
455
|
+
this.timeoutMs = timeoutMs;
|
|
305
456
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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;
|
|
457
|
+
accept(event) {
|
|
458
|
+
if (event["type"] !== "control_response") return false;
|
|
459
|
+
const response = event["response"];
|
|
460
|
+
const id = response?.["request_id"];
|
|
461
|
+
if (typeof id !== "string") return true;
|
|
462
|
+
const pending = this.pending.get(id);
|
|
463
|
+
if (pending === void 0) return true;
|
|
464
|
+
if (response?.["subtype"] !== "success") pending.reject(new Error(typeof response?.["error"] === "string" ? response["error"] : "Claude rejected the control request"));
|
|
465
|
+
else {
|
|
466
|
+
const result = response["response"];
|
|
467
|
+
pending.resolve(typeof result === "object" && result !== null ? result : {});
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
334
470
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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;
|
|
471
|
+
request(request, signal) {
|
|
472
|
+
if (this.closed || signal?.aborted) return Promise.reject(/* @__PURE__ */ new Error("Claude control channel is closed"));
|
|
473
|
+
const id = `dsh-control-${++this.sequence}`;
|
|
474
|
+
return new Promise((resolve, reject) => {
|
|
475
|
+
const finish = () => {
|
|
476
|
+
this.pending.delete(id);
|
|
477
|
+
clearTimeout(timer);
|
|
478
|
+
signal?.removeEventListener("abort", abort);
|
|
479
|
+
};
|
|
480
|
+
const fail = (error) => {
|
|
481
|
+
finish();
|
|
482
|
+
reject(error);
|
|
483
|
+
};
|
|
484
|
+
const abort = () => {
|
|
485
|
+
fail(/* @__PURE__ */ new Error("Claude control request cancelled"));
|
|
486
|
+
};
|
|
487
|
+
const timer = setTimeout(() => fail(/* @__PURE__ */ new Error(`Claude ${String(request["subtype"])} control timed out`)), this.timeoutMs);
|
|
488
|
+
timer.unref();
|
|
489
|
+
this.pending.set(id, {
|
|
490
|
+
resolve: (value) => {
|
|
491
|
+
finish();
|
|
492
|
+
resolve(value);
|
|
493
|
+
},
|
|
494
|
+
reject: fail
|
|
495
|
+
});
|
|
496
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
672
497
|
try {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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;
|
|
498
|
+
this.send({
|
|
499
|
+
type: "control_request",
|
|
500
|
+
request_id: id,
|
|
501
|
+
request
|
|
502
|
+
});
|
|
503
|
+
} catch (error) {
|
|
504
|
+
fail(error instanceof Error ? error : /* @__PURE__ */ new Error("Claude control write failed"));
|
|
809
505
|
}
|
|
810
|
-
|
|
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
|
-
}
|
|
506
|
+
});
|
|
836
507
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
508
|
+
close() {
|
|
509
|
+
if (this.closed) return;
|
|
510
|
+
this.closed = true;
|
|
511
|
+
for (const request of [...this.pending.values()]) request.reject(/* @__PURE__ */ new Error("Claude control channel closed before acknowledgement"));
|
|
841
512
|
}
|
|
842
|
-
}
|
|
513
|
+
};
|
|
843
514
|
//#endregion
|
|
844
515
|
//#region lib/types/live-driver.js
|
|
845
516
|
/**
|
|
@@ -855,12 +526,10 @@ async function readClaudeTranscriptModel(homeDir, cliSessionId) {
|
|
|
855
526
|
* continuable) and the same-shape push stream folded live per turn.
|
|
856
527
|
*
|
|
857
528
|
* Auth discipline (a hard requirement): the resident process runs under the
|
|
858
|
-
* family's scoped `CLAUDE_CONFIG_DIR`
|
|
859
|
-
* the
|
|
860
|
-
*
|
|
861
|
-
* never touches the user's global `~/.claude
|
|
862
|
-
* and only adds `ANTHROPIC_BASE_URL` when the plugin config supplies it (the
|
|
863
|
-
* exec path's exact env rule).
|
|
529
|
+
* family's scoped `CLAUDE_CONFIG_DIR` plus the resolved endpoint credential's
|
|
530
|
+
* `ANTHROPIC_*` variables (the scope's endpoint-auth.json over the plugin
|
|
531
|
+
* config — the exec path's exact env rule, resolved per spawn). The driver
|
|
532
|
+
* never touches the user's global `~/.claude` and never runs any `auth` verb.
|
|
864
533
|
*
|
|
865
534
|
* Protocol facts (all probed): the first stdin `user` message triggers
|
|
866
535
|
* `system/init` carrying the server-assigned session id (EVERY turn emits a
|
|
@@ -873,8 +542,9 @@ async function readClaudeTranscriptModel(homeDir, cliSessionId) {
|
|
|
873
542
|
* Fold: the resident stream speaks the exec path's exact event vocabulary
|
|
874
543
|
* (`system`/`assistant`/`user`/`result`), so each turn folds through the
|
|
875
544
|
* 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
|
-
*
|
|
545
|
+
* volatile last line waits for `result`, which carries the round's usage, and
|
|
546
|
+
* an unresulted tool line holds the fold until its result merges in place —
|
|
547
|
+
* the native lifecycle folds as one closed step). Every line folds 1:1. The always-enabled
|
|
878
548
|
* `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
|
|
879
549
|
* stream (native message id + block index, with ordered legacy fallback),
|
|
880
550
|
* which reserves a (turn, step) at its first delta and
|
|
@@ -915,11 +585,6 @@ const DEFAULT_LIVE_CHANNEL_RETRY_MS = 3e5;
|
|
|
915
585
|
const RECLAIM_EOF_GRACE_MS = 1e3;
|
|
916
586
|
/** Default minimum interval between one streaming kind's snapshot messages. */
|
|
917
587
|
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
588
|
var LiveChannelUnavailableError = class extends Error {
|
|
924
589
|
constructor(message) {
|
|
925
590
|
super(message);
|
|
@@ -1238,9 +903,7 @@ var ClaudeLiveDriver = class {
|
|
|
1238
903
|
async spawnRuntime(spec, signal) {
|
|
1239
904
|
const key = String(spec.childSession.id);
|
|
1240
905
|
if (this.disposed) throw new LiveChannelUnavailableError("the live driver is disposed");
|
|
1241
|
-
await
|
|
1242
|
-
this.ctx.logger.warn(message);
|
|
1243
|
-
}).catch(() => false);
|
|
906
|
+
const endpoint = await this.config.endpoint?.(spec.homeDir);
|
|
1244
907
|
const member = registerClaudeMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
|
|
1245
908
|
const model = this.spawnModel(key, spec);
|
|
1246
909
|
if (spec.configuration === void 0 && this.config.provisionModel !== void 0) await this.config.provisionModel(spec.homeDir, model).catch(() => void 0);
|
|
@@ -1274,8 +937,9 @@ var ClaudeLiveDriver = class {
|
|
|
1274
937
|
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
1275
938
|
env: delegationEnv({
|
|
1276
939
|
CLAUDE_CONFIG_DIR: spec.homeDir,
|
|
940
|
+
...member === void 0 ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
|
|
1277
941
|
...spec.configuration === void 0 ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? "auto" },
|
|
1278
|
-
...
|
|
942
|
+
...endpointAuthEnv(endpoint)
|
|
1279
943
|
})
|
|
1280
944
|
};
|
|
1281
945
|
let child;
|
|
@@ -1369,27 +1033,22 @@ var ClaudeLiveDriver = class {
|
|
|
1369
1033
|
*/
|
|
1370
1034
|
let roundModel;
|
|
1371
1035
|
let roundCliVersion;
|
|
1372
|
-
/** Whether a folded line already
|
|
1036
|
+
/** Whether a folded line already carried usage (per-call inline, or the round's total as the fallback — no backfill event exists). */
|
|
1373
1037
|
let usageCarried = false;
|
|
1374
1038
|
/**
|
|
1375
|
-
* The
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
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.
|
|
1039
|
+
* The streaming blocks seen this round by stream key: deltas accumulate
|
|
1040
|
+
* into throttled snapshots at the block's step once it may open in the
|
|
1041
|
+
* log (previewed live before that) — the host folds repeated settles at
|
|
1042
|
+
* one coordinate into one live-updating chat node. The block's completed
|
|
1043
|
+
* line folds at the same step and finalizes it; an entry whose
|
|
1044
|
+
* completion never arrives is force-finalized at settle. A tool that
|
|
1045
|
+
* completes mid-stream folds after the stream (chronological order).
|
|
1391
1046
|
*/
|
|
1392
1047
|
const streams = /* @__PURE__ */ new Map();
|
|
1048
|
+
/** Provisional stream keys (live flush and preview identity), never log steps. */
|
|
1049
|
+
let streamKeys = 0;
|
|
1050
|
+
/** Line indexes already written (a line may fold ahead of one it chronologically precedes). */
|
|
1051
|
+
const folded = /* @__PURE__ */ new Set();
|
|
1393
1052
|
/** The stream deltas currently accumulate into (kinds stream sequentially). */
|
|
1394
1053
|
let activeStream;
|
|
1395
1054
|
let persistQueue = Promise.resolve();
|
|
@@ -1419,6 +1078,34 @@ var ClaudeLiveDriver = class {
|
|
|
1419
1078
|
};
|
|
1420
1079
|
let messageKey;
|
|
1421
1080
|
let legacyMessage = 0;
|
|
1081
|
+
const callUsage = /* @__PURE__ */ new Map();
|
|
1082
|
+
const openCalls = /* @__PURE__ */ new Set();
|
|
1083
|
+
let currentCall;
|
|
1084
|
+
const endCall = (usage) => {
|
|
1085
|
+
if (currentCall === void 0) return;
|
|
1086
|
+
if (typeof usage === "object" && usage !== null) {
|
|
1087
|
+
const merged = { ...callUsage.get(currentCall) };
|
|
1088
|
+
for (const [key, value] of Object.entries(usage)) if (value !== null && value !== void 0) merged[key] = value;
|
|
1089
|
+
callUsage.set(currentCall, merged);
|
|
1090
|
+
}
|
|
1091
|
+
openCalls.delete(currentCall);
|
|
1092
|
+
};
|
|
1093
|
+
/**
|
|
1094
|
+
* A line's usage when its call is known: `skip` on every line but the
|
|
1095
|
+
* call's last, the call's final usage (or, without stream events, the
|
|
1096
|
+
* line's own report) on the last; `null` for an id-less line.
|
|
1097
|
+
*/
|
|
1098
|
+
const callLineUsage = (index) => {
|
|
1099
|
+
const id = parser.lines[index]?.messageId;
|
|
1100
|
+
if (id === void 0) return null;
|
|
1101
|
+
let last = -1;
|
|
1102
|
+
parser.lines.forEach((line, position) => {
|
|
1103
|
+
if (line.messageId === id) last = position;
|
|
1104
|
+
});
|
|
1105
|
+
if (index !== last) return { skip: true };
|
|
1106
|
+
const raw = callUsage.get(id);
|
|
1107
|
+
return { usage: raw !== void 0 ? usageFromClaude(raw) : parser.lines[index].usage };
|
|
1108
|
+
};
|
|
1422
1109
|
const streamKey = (kind, index) => `${messageKey ?? `legacy-${turn}-${legacyMessage}`}:${typeof index === "number" ? index : kind}`;
|
|
1423
1110
|
/**
|
|
1424
1111
|
* Append one snapshot of a streaming kind at its reserved (turn, step).
|
|
@@ -1429,6 +1116,27 @@ var ClaudeLiveDriver = class {
|
|
|
1429
1116
|
*/
|
|
1430
1117
|
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
1118
|
const liveFlush = new LiveFlush((error) => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
|
|
1119
|
+
let lastStep = 0;
|
|
1120
|
+
let openStep;
|
|
1121
|
+
const openStreamStep = (stream) => {
|
|
1122
|
+
lastStep += 1;
|
|
1123
|
+
childSession.append("step/start", {
|
|
1124
|
+
turn,
|
|
1125
|
+
step: lastStep
|
|
1126
|
+
});
|
|
1127
|
+
stream.durable = lastStep;
|
|
1128
|
+
stream.opened = true;
|
|
1129
|
+
openStep = lastStep;
|
|
1130
|
+
};
|
|
1131
|
+
const closeOpenStep = () => {
|
|
1132
|
+
if (openStep === void 0) return;
|
|
1133
|
+
childSession.append("step/end", {
|
|
1134
|
+
turn,
|
|
1135
|
+
step: openStep
|
|
1136
|
+
});
|
|
1137
|
+
openStep = void 0;
|
|
1138
|
+
};
|
|
1139
|
+
const canOpenStream = (stream) => openStep === void 0 && mirrored >= stream.after && streams.keys().next().value === stream.itemId;
|
|
1432
1140
|
const pendingStreamArrival = /* @__PURE__ */ new Map();
|
|
1433
1141
|
const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
|
|
1434
1142
|
if (stream.text.trim() === "") return;
|
|
@@ -1441,30 +1149,30 @@ var ClaudeLiveDriver = class {
|
|
|
1441
1149
|
liveFlush.cancel(stream.step);
|
|
1442
1150
|
const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
|
|
1443
1151
|
pendingStreamArrival.delete(stream.step);
|
|
1444
|
-
if (
|
|
1445
|
-
if (
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1152
|
+
if (!stream.opened) {
|
|
1153
|
+
if (canOpenStream(stream)) openStreamStep(stream);
|
|
1154
|
+
else if (!final) {
|
|
1155
|
+
streamPublisher?.preview(stream.step, stream.kind, stream.text, {
|
|
1156
|
+
itemId: stream.itemId,
|
|
1157
|
+
receivedAt
|
|
1449
1158
|
});
|
|
1450
|
-
|
|
1159
|
+
return;
|
|
1160
|
+
} else {
|
|
1161
|
+
closeOpenStep();
|
|
1162
|
+
openStreamStep(stream);
|
|
1451
1163
|
}
|
|
1452
|
-
|
|
1164
|
+
}
|
|
1165
|
+
if (streamPublisher !== void 0 && !final) {
|
|
1166
|
+
streamPublisher.update(stream.durable, stream.kind, stream.text, { receivedAt });
|
|
1453
1167
|
stream.lastSnapshotAt = now;
|
|
1454
1168
|
stream.lastSnapshotLen = stream.text.length;
|
|
1455
1169
|
return;
|
|
1456
1170
|
}
|
|
1457
|
-
if (!stream.opened) {
|
|
1458
|
-
childSession.append("step/start", {
|
|
1459
|
-
turn,
|
|
1460
|
-
step: stream.step
|
|
1461
|
-
});
|
|
1462
|
-
stream.opened = true;
|
|
1463
|
-
}
|
|
1464
1171
|
const usage = parser.usage;
|
|
1172
|
+
const totalFallback = withUsage && !usageCarried && usage !== void 0 && !parser.lines.some((line) => line.usage !== void 0);
|
|
1465
1173
|
childSession.append("assistant/message", {
|
|
1466
1174
|
turn,
|
|
1467
|
-
step: stream.
|
|
1175
|
+
step: stream.durable,
|
|
1468
1176
|
message: assistantEvent([stream.kind === "think" ? {
|
|
1469
1177
|
type: "reasoning",
|
|
1470
1178
|
text: stream.text
|
|
@@ -1473,19 +1181,26 @@ var ClaudeLiveDriver = class {
|
|
|
1473
1181
|
text: stream.text
|
|
1474
1182
|
}]),
|
|
1475
1183
|
stream: [],
|
|
1476
|
-
...
|
|
1184
|
+
...totalFallback ? { usage } : {},
|
|
1477
1185
|
...interrupted ? { interrupted: true } : {}
|
|
1478
1186
|
}, { surfaceOp: "append" });
|
|
1479
|
-
if (
|
|
1187
|
+
if (totalFallback) usageCarried = true;
|
|
1480
1188
|
stream.lastSnapshotAt = now;
|
|
1481
1189
|
stream.lastSnapshotLen = stream.text.length;
|
|
1482
1190
|
persist();
|
|
1483
1191
|
};
|
|
1192
|
+
/** Close a stream's live presentation once its final text is in the log. */
|
|
1193
|
+
const finishStream = (stream) => {
|
|
1194
|
+
liveFlush.cancel(stream.step);
|
|
1195
|
+
streams.delete(stream.itemId);
|
|
1196
|
+
if (activeStream === stream.itemId) activeStream = void 0;
|
|
1197
|
+
streamPublisher?.finish(stream.step);
|
|
1198
|
+
if (stream.durable !== void 0) streamPublisher?.finish(stream.durable);
|
|
1199
|
+
};
|
|
1484
1200
|
/**
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
* completion still folds at its own reserved step when it lands).
|
|
1201
|
+
* Track one new streaming block: its provisional key and how many lines
|
|
1202
|
+
* had completed when it began. A different block's deltas force one
|
|
1203
|
+
* freshness snapshot of the current stream.
|
|
1489
1204
|
*/
|
|
1490
1205
|
const reserveStream = (itemId, kind) => {
|
|
1491
1206
|
if (activeStream !== void 0 && activeStream !== itemId) {
|
|
@@ -1495,12 +1210,12 @@ var ClaudeLiveDriver = class {
|
|
|
1495
1210
|
}
|
|
1496
1211
|
let stream = streams.get(itemId);
|
|
1497
1212
|
if (stream === void 0) {
|
|
1498
|
-
|
|
1499
|
-
reservedSteps.push(step);
|
|
1213
|
+
streamKeys += 1;
|
|
1500
1214
|
stream = {
|
|
1501
1215
|
itemId,
|
|
1502
1216
|
kind,
|
|
1503
|
-
step,
|
|
1217
|
+
step: streamKeys,
|
|
1218
|
+
after: parser.lines.length,
|
|
1504
1219
|
text: "",
|
|
1505
1220
|
lastSnapshotAt: 0,
|
|
1506
1221
|
lastSnapshotLen: 0,
|
|
@@ -1510,60 +1225,90 @@ var ClaudeLiveDriver = class {
|
|
|
1510
1225
|
}
|
|
1511
1226
|
activeStream = itemId;
|
|
1512
1227
|
};
|
|
1513
|
-
/** The
|
|
1514
|
-
const
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1228
|
+
/** The stream a completed line finalizes, if it was streamed. */
|
|
1229
|
+
const streamOfLine = (line) => line === void 0 || 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-")));
|
|
1230
|
+
/** Write parser.lines[index] at the next step(s). */
|
|
1231
|
+
const foldLine = (index, lineUsage) => {
|
|
1232
|
+
const line = parser.lines[index];
|
|
1233
|
+
const perCall = callLineUsage(index);
|
|
1234
|
+
const effectiveUsage = perCall === null ? line.usage ?? lineUsage : "skip" in perCall ? void 0 : perCall.usage ?? lineUsage;
|
|
1235
|
+
if (effectiveUsage !== void 0) usageCarried = true;
|
|
1236
|
+
const stream = streamOfLine(line);
|
|
1237
|
+
if (stream !== void 0) {
|
|
1238
|
+
if (!stream.opened) {
|
|
1239
|
+
closeOpenStep();
|
|
1240
|
+
openStreamStep(stream);
|
|
1241
|
+
}
|
|
1242
|
+
const blocks = stream.kind === "think" ? [{
|
|
1243
|
+
type: "reasoning",
|
|
1244
|
+
text: line.kind === "tool" ? "" : line.text
|
|
1245
|
+
}] : [{
|
|
1246
|
+
type: "text",
|
|
1247
|
+
text: line.kind === "tool" ? "" : line.text
|
|
1248
|
+
}];
|
|
1249
|
+
childSession.append("assistant/message", {
|
|
1250
|
+
turn,
|
|
1251
|
+
step: stream.durable,
|
|
1252
|
+
message: assistantEvent(blocks),
|
|
1253
|
+
stream: [],
|
|
1254
|
+
...effectiveUsage === void 0 ? {} : { usage: effectiveUsage }
|
|
1255
|
+
}, { surfaceOp: "append" });
|
|
1256
|
+
finishStream(stream);
|
|
1257
|
+
closeOpenStep();
|
|
1258
|
+
} else {
|
|
1259
|
+
closeOpenStep();
|
|
1260
|
+
lastStep += 1;
|
|
1261
|
+
const { usage: _snapshot, ...bare } = line;
|
|
1262
|
+
appendClaudeTranscriptLine(childSession, turn, lastStep, bare, effectiveUsage);
|
|
1263
|
+
}
|
|
1264
|
+
folded.add(index);
|
|
1265
|
+
localAgent?.reportRunProgress(childSession.id, {
|
|
1266
|
+
kind: "delta",
|
|
1267
|
+
text: claudeLineText(line)
|
|
1268
|
+
});
|
|
1519
1269
|
};
|
|
1520
|
-
/**
|
|
1521
|
-
|
|
1270
|
+
/**
|
|
1271
|
+
* Mirror the completed lines before `upto` in chronological order; the
|
|
1272
|
+
* last line is held back until `result`. A line waits behind every stream
|
|
1273
|
+
* that began before it completed (that stream's line folds first, or the
|
|
1274
|
+
* mirror stops while it still streams). A tool line whose result the
|
|
1275
|
+
* stream has not recorded yet HOLDS the fold mid-round: the fold writes
|
|
1276
|
+
* the native lifecycle as one closed step (the released-log reader
|
|
1277
|
+
* resolves every advertised call before step/end), and the result merges
|
|
1278
|
+
* into the call's line in place, so a later pass retries from here. The
|
|
1279
|
+
* settle flushes (`settle: true` — the turn's result event, or the final
|
|
1280
|
+
* flush after a cancel/failure) never hold: a result that will never
|
|
1281
|
+
* arrive folds as the interrupted placeholder.
|
|
1282
|
+
*/
|
|
1283
|
+
const mirrorUpTo = (upto, withUsage, settle = false) => {
|
|
1522
1284
|
let usageIndex = -1;
|
|
1523
|
-
if (withUsage) {
|
|
1285
|
+
if (withUsage && !parser.lines.some((line) => line.usage !== void 0)) {
|
|
1524
1286
|
for (let index = 0; index < parser.lines.length; index += 1) if (parser.lines[index]?.kind !== "tool") usageIndex = index;
|
|
1525
1287
|
}
|
|
1526
|
-
|
|
1527
|
-
const line = parser.lines[index];
|
|
1528
|
-
if (line === void 0) continue;
|
|
1288
|
+
const usageFor = (index) => {
|
|
1529
1289
|
const lineUsage = withUsage && index === usageIndex ? parser.usage : void 0;
|
|
1530
1290
|
if (lineUsage !== void 0) usageCarried = true;
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
-
});
|
|
1291
|
+
return lineUsage;
|
|
1292
|
+
};
|
|
1293
|
+
outer: for (let index = mirrored; index < upto; index += 1) {
|
|
1294
|
+
if (folded.has(index)) continue;
|
|
1295
|
+
const line = parser.lines[index];
|
|
1296
|
+
if (line === void 0) continue;
|
|
1297
|
+
if (!settle && line.kind === "tool" && line.result === void 0) break;
|
|
1298
|
+
if (!settle && line.messageId !== void 0 && openCalls.has(line.messageId)) break;
|
|
1299
|
+
const own = streamOfLine(line);
|
|
1300
|
+
const all = [...streams.values()];
|
|
1301
|
+
const before = own === void 0 ? all.filter((stream) => stream.after <= index) : all.slice(0, all.indexOf(own));
|
|
1302
|
+
for (const earlier of before) {
|
|
1303
|
+
const at = parser.lines.findIndex((candidate, position) => position > index && position < upto && !folded.has(position) && streamOfLine(candidate) === earlier);
|
|
1304
|
+
if (at < 0) break outer;
|
|
1305
|
+
const atCall = parser.lines[at]?.messageId;
|
|
1306
|
+
if (!settle && atCall !== void 0 && openCalls.has(atCall)) break outer;
|
|
1307
|
+
foldLine(at, usageFor(at));
|
|
1308
|
+
}
|
|
1309
|
+
foldLine(index, usageFor(index));
|
|
1566
1310
|
}
|
|
1311
|
+
while (folded.has(mirrored)) mirrored += 1;
|
|
1567
1312
|
if (upto > 0) persist();
|
|
1568
1313
|
};
|
|
1569
1314
|
let resolveResult;
|
|
@@ -1579,6 +1324,13 @@ var ClaudeLiveDriver = class {
|
|
|
1579
1324
|
/** The round's event sink (installed on the runtime while it runs). */
|
|
1580
1325
|
const onEvent = (event) => {
|
|
1581
1326
|
const type = event["type"];
|
|
1327
|
+
if (roundSettled) {
|
|
1328
|
+
if (type === "result") {
|
|
1329
|
+
awaitingResult = false;
|
|
1330
|
+
resolveResult(event);
|
|
1331
|
+
}
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1582
1334
|
if (type === "system" && typeof event["session_id"] === "string") {
|
|
1583
1335
|
if (runtime !== void 0 && runtime.sessionId === void 0) {
|
|
1584
1336
|
runtime.sessionId = event["session_id"];
|
|
@@ -1591,7 +1343,7 @@ var ClaudeLiveDriver = class {
|
|
|
1591
1343
|
if (type === "result") {
|
|
1592
1344
|
awaitingResult = false;
|
|
1593
1345
|
parser.push(JSON.stringify(event) + "\n");
|
|
1594
|
-
mirrorUpTo(parser.lines.length, true);
|
|
1346
|
+
mirrorUpTo(parser.lines.length, true, true);
|
|
1595
1347
|
resolveResult(event);
|
|
1596
1348
|
return;
|
|
1597
1349
|
}
|
|
@@ -1600,6 +1352,17 @@ var ClaudeLiveDriver = class {
|
|
|
1600
1352
|
if (inner?.["type"] === "message_start") {
|
|
1601
1353
|
const message = inner["message"];
|
|
1602
1354
|
messageKey = typeof message?.["id"] === "string" ? message["id"] : `legacy-${turn}-${++legacyMessage}`;
|
|
1355
|
+
currentCall = typeof message?.["id"] === "string" ? message["id"] : void 0;
|
|
1356
|
+
if (currentCall !== void 0) {
|
|
1357
|
+
openCalls.add(currentCall);
|
|
1358
|
+
const usage = message?.["usage"];
|
|
1359
|
+
if (typeof usage === "object" && usage !== null) callUsage.set(currentCall, { ...usage });
|
|
1360
|
+
}
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
if (inner?.["type"] === "message_delta" || inner?.["type"] === "message_stop") {
|
|
1364
|
+
endCall(inner["usage"]);
|
|
1365
|
+
mirrorUpTo(parser.lines.length - 1, false);
|
|
1603
1366
|
return;
|
|
1604
1367
|
}
|
|
1605
1368
|
const delta = inner?.["delta"];
|
|
@@ -1675,6 +1438,11 @@ var ClaudeLiveDriver = class {
|
|
|
1675
1438
|
throw new Error("subagent-claude: run cancelled locally");
|
|
1676
1439
|
}
|
|
1677
1440
|
childSession.append("turn/start", { turn });
|
|
1441
|
+
if (spec.configuration?.model !== void 0) appendClaudeRequestHeader(childSession, {
|
|
1442
|
+
provider: "claude-local",
|
|
1443
|
+
model: spec.configuration.model,
|
|
1444
|
+
reasoningEffort: spec.configuration.effort ?? "auto"
|
|
1445
|
+
});
|
|
1678
1446
|
childSession.append("user/message", createUserMessage({
|
|
1679
1447
|
content: [{
|
|
1680
1448
|
type: "text",
|
|
@@ -1726,16 +1494,20 @@ var ClaudeLiveDriver = class {
|
|
|
1726
1494
|
liveFlush.dispose();
|
|
1727
1495
|
turnInFlight = false;
|
|
1728
1496
|
if (turnOpened) {
|
|
1729
|
-
if (mirrored < parser.lines.length) mirrorUpTo(parser.lines.length, true);
|
|
1497
|
+
if (mirrored < parser.lines.length) mirrorUpTo(parser.lines.length, true, true);
|
|
1498
|
+
while (mirrored < parser.lines.length && streams.size > 0) {
|
|
1499
|
+
const oldest = streams.values().next().value;
|
|
1500
|
+
appendStreamSnapshot(oldest, true, settled.stopReason !== "completed", false, true);
|
|
1501
|
+
finishStream(oldest);
|
|
1502
|
+
closeOpenStep();
|
|
1503
|
+
mirrorUpTo(parser.lines.length, true, true);
|
|
1504
|
+
}
|
|
1730
1505
|
if (streams.size > 0) {
|
|
1731
1506
|
const remaining = [...streams.values()];
|
|
1732
1507
|
for (const [position, stream] of remaining.entries()) {
|
|
1733
1508
|
appendStreamSnapshot(stream, true, settled.stopReason !== "completed", position === remaining.length - 1, true);
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
turn,
|
|
1737
|
-
step: stream.step
|
|
1738
|
-
});
|
|
1509
|
+
finishStream(stream);
|
|
1510
|
+
closeOpenStep();
|
|
1739
1511
|
}
|
|
1740
1512
|
streams.clear();
|
|
1741
1513
|
activeStream = void 0;
|
|
@@ -1795,7 +1567,7 @@ var ClaudeLiveDriver = class {
|
|
|
1795
1567
|
});
|
|
1796
1568
|
await Promise.race([accepted, abortBranch.catch(() => void 0)]);
|
|
1797
1569
|
return subprocessRunHandle({
|
|
1798
|
-
id:
|
|
1570
|
+
id: SessionId(randomUUID()),
|
|
1799
1571
|
result,
|
|
1800
1572
|
signal: request.signal,
|
|
1801
1573
|
onAbort,
|
|
@@ -1807,32 +1579,22 @@ var ClaudeLiveDriver = class {
|
|
|
1807
1579
|
});
|
|
1808
1580
|
}
|
|
1809
1581
|
};
|
|
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
1582
|
/** Default POSIX grace between subprocess termination tiers. */
|
|
1829
1583
|
const DEFAULT_DISPOSE_GRACE_MS = 3e3;
|
|
1830
1584
|
/**
|
|
1831
1585
|
* Auth-shaped failure signatures in claude's result error or stderr: the
|
|
1832
|
-
* endpoint rejected the credential (401/403, revoked
|
|
1833
|
-
*
|
|
1586
|
+
* endpoint rejected the credential (401/403, revoked key). Matches the CLI's
|
|
1587
|
+
* own phrasing ("Failed to authenticate. API Error: 401 …", the
|
|
1588
|
+
* `authentication_failed` error tag) plus the relay phrasings seen in the
|
|
1589
|
+
* wild ("invalid x-api-key" and friends). Narrow on purpose — matched against
|
|
1590
|
+
* provider-controlled error strings only.
|
|
1591
|
+
*/
|
|
1592
|
+
const CLAUDE_AUTH_FAILURE = /failed to authenticate|authentication_failed|oauth access token|invalid (x-api-key|api key|authentication token)|api error: 40[13]/i;
|
|
1593
|
+
/**
|
|
1594
|
+
* The MCP tool-call bound for a member's claude process (`MCP_TOOL_TIMEOUT`,
|
|
1595
|
+
* ms): a bridge call may wait up to 30 minutes for the human's answer.
|
|
1834
1596
|
*/
|
|
1835
|
-
const
|
|
1597
|
+
const MEMBER_TOOL_TIMEOUT_MS = String(18e5);
|
|
1836
1598
|
/** Register one member run with the channel; see {@link ClaudeMemberRunHandle}. */
|
|
1837
1599
|
function registerClaudeMemberRun(ctx, childSessionId, parentSessionId) {
|
|
1838
1600
|
const registry = ctx.localAgent;
|
|
@@ -1907,7 +1669,7 @@ function claudeCliVersion(ctx, homeDir) {
|
|
|
1907
1669
|
var ClaudeCliProvider = class {
|
|
1908
1670
|
ctx;
|
|
1909
1671
|
permissionMode;
|
|
1910
|
-
|
|
1672
|
+
endpoint;
|
|
1911
1673
|
live;
|
|
1912
1674
|
model;
|
|
1913
1675
|
name = "claude-local";
|
|
@@ -1918,6 +1680,11 @@ var ClaudeCliProvider = class {
|
|
|
1918
1680
|
* generation's driver per member (the settings toggle swaps generations;
|
|
1919
1681
|
* a resolver may return undefined to steer one member's round to exec
|
|
1920
1682
|
* while a retiring generation still hosts it).
|
|
1683
|
+
* @param endpoint - resolver for the round's endpoint credential (endpoint-auth.ts:
|
|
1684
|
+
* the scope's endpoint-auth.json over the plugin config triple), read PER
|
|
1685
|
+
* ROUND against the round's scoped home, so a key rotation or a profile
|
|
1686
|
+
* edit takes effect on the next delegation without a reload. Absent, no
|
|
1687
|
+
* `ANTHROPIC_*` variables reach the child at all.
|
|
1921
1688
|
* @param model - resolver for the member's effective model, read PER ROUND
|
|
1922
1689
|
* so a settings-card write or a composer switch takes effect on the next
|
|
1923
1690
|
* delegation without a reload. Receives the member (the child session id)
|
|
@@ -1926,10 +1693,10 @@ var ClaudeCliProvider = class {
|
|
|
1926
1693
|
* resolver not passed), the delegation's own model still rides, exactly
|
|
1927
1694
|
* as before the key existed.
|
|
1928
1695
|
*/
|
|
1929
|
-
constructor(ctx, permissionMode = "skip",
|
|
1696
|
+
constructor(ctx, permissionMode = "skip", endpoint, live, model) {
|
|
1930
1697
|
this.ctx = ctx;
|
|
1931
1698
|
this.permissionMode = permissionMode;
|
|
1932
|
-
this.
|
|
1699
|
+
this.endpoint = endpoint;
|
|
1933
1700
|
this.live = live;
|
|
1934
1701
|
this.model = model;
|
|
1935
1702
|
}
|
|
@@ -1971,7 +1738,7 @@ var ClaudeCliProvider = class {
|
|
|
1971
1738
|
...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
|
|
1972
1739
|
}, (configuration) => this.startClaudeResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
|
|
1973
1740
|
}
|
|
1974
|
-
const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
|
|
1741
|
+
const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
|
|
1975
1742
|
if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
|
|
1976
1743
|
if (intent?.effort !== void 0) throw new Error("Claude effort requires the configuration admission core");
|
|
1977
1744
|
return this.startClaudeFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
|
|
@@ -2000,18 +1767,22 @@ var ClaudeCliProvider = class {
|
|
|
2000
1767
|
delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
|
|
2001
1768
|
} });
|
|
2002
1769
|
const harness = this.ctx.localAgent.get("claude-code");
|
|
2003
|
-
const label = subagentDelegationLabel(harness?.displayName ?? "Claude Code", request.
|
|
1770
|
+
const label = subagentDelegationLabel(harness?.displayName ?? "Claude Code", request.label || void 0);
|
|
2004
1771
|
childSession.append("subagent/descriptor", {
|
|
2005
|
-
|
|
1772
|
+
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
1773
|
+
mode: "one-shot",
|
|
1774
|
+
provider: this.name,
|
|
2006
1775
|
label
|
|
2007
1776
|
});
|
|
2008
1777
|
establishSubagentCatalogChild(request.parent.session, childSession.header, label);
|
|
2009
1778
|
} catch (error) {
|
|
2010
1779
|
this.ctx.logger.warn(`subagent-claude: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2011
1780
|
}
|
|
2012
|
-
const
|
|
1781
|
+
const endpoint = await this.endpoint?.(homeDir);
|
|
1782
|
+
const effectiveBaseUrl = endpoint?.baseUrl;
|
|
2013
1783
|
this.ctx.logger.info(`subagent-claude: delegating via ${effectiveBaseUrl ?? "claude default endpoint"}`);
|
|
2014
1784
|
const live = exec === void 0 ? this.liveDriver(runId) : void 0;
|
|
1785
|
+
const round = configuration?.resolved ?? this.roundModel(runId, requestedModel);
|
|
2015
1786
|
if (live !== void 0 && childSession !== void 0 && !live.disabled) try {
|
|
2016
1787
|
return await live.startRound(request, {
|
|
2017
1788
|
cwd,
|
|
@@ -2028,7 +1799,7 @@ var ClaudeCliProvider = class {
|
|
|
2028
1799
|
cliSessionId: sessionId,
|
|
2029
1800
|
cwd,
|
|
2030
1801
|
...scope === void 0 ? {} : { scope },
|
|
2031
|
-
...
|
|
1802
|
+
...round.model === void 0 ? {} : { model: round.model }
|
|
2032
1803
|
});
|
|
2033
1804
|
}
|
|
2034
1805
|
});
|
|
@@ -2042,12 +1813,13 @@ var ClaudeCliProvider = class {
|
|
|
2042
1813
|
cwd,
|
|
2043
1814
|
env: delegationEnv({
|
|
2044
1815
|
CLAUDE_CONFIG_DIR: homeDir,
|
|
2045
|
-
...
|
|
1816
|
+
...member === void 0 ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
|
|
1817
|
+
...endpointAuthEnv(endpoint)
|
|
2046
1818
|
}),
|
|
2047
1819
|
...exec === void 0 ? {} : { exec },
|
|
2048
1820
|
endpointLabel: effectiveBaseUrl,
|
|
2049
1821
|
permissionMode: this.permissionMode,
|
|
2050
|
-
...
|
|
1822
|
+
...round,
|
|
2051
1823
|
...configuration === void 0 ? {} : { controlled: true },
|
|
2052
1824
|
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
2053
1825
|
spawn: (spec) => this.ctx.subprocess.spawn(spec),
|
|
@@ -2076,7 +1848,7 @@ var ClaudeCliProvider = class {
|
|
|
2076
1848
|
cliSessionId: sessionId,
|
|
2077
1849
|
cwd,
|
|
2078
1850
|
...scope === void 0 ? {} : { scope },
|
|
2079
|
-
...
|
|
1851
|
+
...round.model === void 0 ? {} : { model: round.model }
|
|
2080
1852
|
});
|
|
2081
1853
|
},
|
|
2082
1854
|
onRoundSettled: (round) => {
|
|
@@ -2121,7 +1893,8 @@ var ClaudeCliProvider = class {
|
|
|
2121
1893
|
if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
|
|
2122
1894
|
this.ctx.logger.warn(`subagent-claude: live driver unavailable, using the exec one-shot: ${error.message}`);
|
|
2123
1895
|
}
|
|
2124
|
-
const
|
|
1896
|
+
const endpoint = await this.endpoint?.(homeDir);
|
|
1897
|
+
const effectiveBaseUrl = endpoint?.baseUrl;
|
|
2125
1898
|
this.ctx.logger.info(`subagent-claude: resuming via ${effectiveBaseUrl ?? "claude default endpoint"}`);
|
|
2126
1899
|
const member = exec === void 0 ? this.memberRun(intent.childSessionId, request.parent.session.id) : void 0;
|
|
2127
1900
|
let run;
|
|
@@ -2130,7 +1903,8 @@ var ClaudeCliProvider = class {
|
|
|
2130
1903
|
cwd,
|
|
2131
1904
|
env: delegationEnv({
|
|
2132
1905
|
CLAUDE_CONFIG_DIR: homeDir,
|
|
2133
|
-
...
|
|
1906
|
+
...member === void 0 ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
|
|
1907
|
+
...endpointAuthEnv(endpoint)
|
|
2134
1908
|
}),
|
|
2135
1909
|
...exec === void 0 ? {} : { exec },
|
|
2136
1910
|
endpointLabel: effectiveBaseUrl,
|
|
@@ -2200,6 +1974,17 @@ function textTask(prompt) {
|
|
|
2200
1974
|
if (texts.every((text) => text.trim().length === 0)) throw new Error("subagent-claude: the one-shot task must not be empty");
|
|
2201
1975
|
return texts.join("\n");
|
|
2202
1976
|
}
|
|
1977
|
+
/** Group the lines of one API call (consecutive lines sharing a message id).
|
|
1978
|
+
* Lines without an id stay singleton units (the pre-grouping shape). */
|
|
1979
|
+
function groupClaudeTranscriptLines(lines) {
|
|
1980
|
+
const groups = [];
|
|
1981
|
+
for (const line of lines) {
|
|
1982
|
+
const last = groups[groups.length - 1];
|
|
1983
|
+
if (line.messageId !== void 0 && last !== void 0 && last[0]?.messageId === line.messageId) last.push(line);
|
|
1984
|
+
else groups.push([line]);
|
|
1985
|
+
}
|
|
1986
|
+
return groups;
|
|
1987
|
+
}
|
|
2203
1988
|
/**
|
|
2204
1989
|
* Fold a tool-call counter into the settled shape: `{count, byName}` with the
|
|
2205
1990
|
* per-name values summing to `count`. An empty map yields undefined — a
|
|
@@ -2312,11 +2097,17 @@ function foldClaudeStreamLine(state, raw) {
|
|
|
2312
2097
|
}
|
|
2313
2098
|
if (event.type !== "assistant" && event.type !== "user") return;
|
|
2314
2099
|
const blocks = event.message?.content ?? [];
|
|
2100
|
+
const linesBefore = state.lines.length;
|
|
2101
|
+
const messageId = event.type === "assistant" && typeof event.message?.id === "string" ? event.message.id : void 0;
|
|
2315
2102
|
for (const [blockIndex, block] of blocks.entries()) {
|
|
2316
2103
|
if (typeof block !== "object" || block === null) continue;
|
|
2317
2104
|
const record = block;
|
|
2318
|
-
const identity =
|
|
2105
|
+
const identity = messageId !== void 0 ? {
|
|
2106
|
+
streamId: `${messageId}:${blockIndex}`,
|
|
2107
|
+
messageId
|
|
2108
|
+
} : {};
|
|
2319
2109
|
const kind = record["type"];
|
|
2110
|
+
if (kind === "text" && event.type === "user") continue;
|
|
2320
2111
|
if (kind === "text" && typeof record["text"] === "string" && record["text"].trim() !== "") {
|
|
2321
2112
|
state.lines.push({
|
|
2322
2113
|
kind: "text",
|
|
@@ -2353,6 +2144,7 @@ function foldClaudeStreamLine(state, raw) {
|
|
|
2353
2144
|
id: id ?? `claude-tool-${state.lines.length}`,
|
|
2354
2145
|
name: "ApplyPatch",
|
|
2355
2146
|
args: edit.args,
|
|
2147
|
+
...messageId === void 0 ? {} : { messageId },
|
|
2356
2148
|
...edit.patch === void 0 ? {} : { result: edit.patch }
|
|
2357
2149
|
});
|
|
2358
2150
|
else {
|
|
@@ -2361,6 +2153,7 @@ function foldClaudeStreamLine(state, raw) {
|
|
|
2361
2153
|
kind: "tool",
|
|
2362
2154
|
id: id ?? `claude-tool-${state.lines.length}`,
|
|
2363
2155
|
name: kind === "server_tool_use" ? SERVER_TOOL_CARD_NAMES[name] ?? name : name,
|
|
2156
|
+
...messageId === void 0 ? {} : { messageId },
|
|
2364
2157
|
...detail === void 0 ? {} : { args: detail }
|
|
2365
2158
|
});
|
|
2366
2159
|
}
|
|
@@ -2372,6 +2165,22 @@ function foldClaudeStreamLine(state, raw) {
|
|
|
2372
2165
|
if (resultText !== void 0 && resultText.trim() !== "") attachToolResult(state, record["tool_use_id"], resultText, false);
|
|
2373
2166
|
}
|
|
2374
2167
|
}
|
|
2168
|
+
if (event.type === "assistant" && event.message?.usage !== void 0 && state.lines.length > linesBefore) {
|
|
2169
|
+
const lastIndex = state.lines.length - 1;
|
|
2170
|
+
const last = state.lines[lastIndex];
|
|
2171
|
+
if (messageId !== void 0) {
|
|
2172
|
+
const previous = state.usageStamped.get(messageId);
|
|
2173
|
+
if (previous !== void 0 && previous !== lastIndex) {
|
|
2174
|
+
const { usage: _cleared, ...rest } = state.lines[previous];
|
|
2175
|
+
state.lines[previous] = rest;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
state.lines[lastIndex] = {
|
|
2179
|
+
...last,
|
|
2180
|
+
usage: usageFromClaude(event.message.usage)
|
|
2181
|
+
};
|
|
2182
|
+
if (messageId !== void 0) state.usageStamped.set(messageId, lastIndex);
|
|
2183
|
+
}
|
|
2375
2184
|
}
|
|
2376
2185
|
/**
|
|
2377
2186
|
* Merge a tool result into its call line: pair by tool_use_id when the stream
|
|
@@ -2411,6 +2220,7 @@ var ClaudeStreamParser = class {
|
|
|
2411
2220
|
buffer = "";
|
|
2412
2221
|
lines = [];
|
|
2413
2222
|
callsById = /* @__PURE__ */ new Map();
|
|
2223
|
+
usageStamped = /* @__PURE__ */ new Map();
|
|
2414
2224
|
text;
|
|
2415
2225
|
usage;
|
|
2416
2226
|
sessionId;
|
|
@@ -2447,6 +2257,7 @@ function parseClaudeStreamJson(output) {
|
|
|
2447
2257
|
const state = {
|
|
2448
2258
|
lines: [],
|
|
2449
2259
|
callsById: /* @__PURE__ */ new Map(),
|
|
2260
|
+
usageStamped: /* @__PURE__ */ new Map(),
|
|
2450
2261
|
toolCalls: /* @__PURE__ */ new Map(),
|
|
2451
2262
|
text: void 0,
|
|
2452
2263
|
usage: void 0,
|
|
@@ -2593,16 +2404,30 @@ function serverToolResultText(record) {
|
|
|
2593
2404
|
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
2405
|
}
|
|
2595
2406
|
}
|
|
2596
|
-
/**
|
|
2407
|
+
/**
|
|
2408
|
+
* Flatten a tool_result content payload (string or block array) to text.
|
|
2409
|
+
* Beyond `text` blocks, ToolSearch answers with `tool_reference` blocks — one
|
|
2410
|
+
* per discovered tool — which list as the found tool names; dropping them
|
|
2411
|
+
* stranded the call (no result recorded) and it settled as a phantom
|
|
2412
|
+
* interrupted failure.
|
|
2413
|
+
*/
|
|
2597
2414
|
function toolResultText(content) {
|
|
2598
2415
|
if (typeof content === "string") return content;
|
|
2599
2416
|
if (!Array.isArray(content)) return void 0;
|
|
2600
|
-
return content.
|
|
2417
|
+
return content.map((block) => {
|
|
2418
|
+
if (typeof block !== "object" || block === null) return void 0;
|
|
2419
|
+
const record = block;
|
|
2420
|
+
if (record.type === "text" && typeof record.text === "string") return record.text;
|
|
2421
|
+
if (record.type === "tool_reference" && typeof record.tool_name === "string") return record.tool_name;
|
|
2422
|
+
}).filter((text) => text !== void 0).join("\n");
|
|
2601
2423
|
}
|
|
2602
2424
|
/**
|
|
2603
2425
|
* Map claude's usage payload onto the shared usage contract. Claude's
|
|
2604
2426
|
* `input_tokens` is the uncached input; cache reads and cache creation are
|
|
2605
2427
|
* reported separately, so each maps to its own bucket with no subtraction.
|
|
2428
|
+
* When the payload carries ANY cache field, `cacheReadTokens` is always
|
|
2429
|
+
* written (0 when the read counter is absent or zero) — a reader can then
|
|
2430
|
+
* tell "no hits" from "not reported".
|
|
2606
2431
|
* @param usage - the raw claude usage object.
|
|
2607
2432
|
* @returns the shared usage record.
|
|
2608
2433
|
*/
|
|
@@ -2624,7 +2449,7 @@ function usageFromClaude(usage) {
|
|
|
2624
2449
|
inputTokens: input ?? 0,
|
|
2625
2450
|
outputTokens: output ?? 0
|
|
2626
2451
|
};
|
|
2627
|
-
if (cacheRead !== void 0
|
|
2452
|
+
if (cacheRead !== void 0 || cacheWrite !== void 0) usageRecord.cacheReadTokens = cacheRead ?? 0;
|
|
2628
2453
|
if (cacheWrite !== void 0 && cacheWrite > 0) usageRecord.cacheWriteTokens = cacheWrite;
|
|
2629
2454
|
return usageRecord;
|
|
2630
2455
|
}
|
|
@@ -2644,10 +2469,6 @@ function usageFromClaude(usage) {
|
|
|
2644
2469
|
async function startClaudeCliRun(request, spec) {
|
|
2645
2470
|
const task = textTask(request.prompt);
|
|
2646
2471
|
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
2472
|
const turn = spec.resume?.turn ?? 1;
|
|
2652
2473
|
const memberArgv = spec.member === void 0 ? [] : [
|
|
2653
2474
|
"--mcp-config",
|
|
@@ -2787,44 +2608,50 @@ async function startClaudeCliRun(request, spec) {
|
|
|
2787
2608
|
onAbort
|
|
2788
2609
|
}).then((settled) => {
|
|
2789
2610
|
if (settled.stopReason === "completed") spec.onAuthSuccess?.();
|
|
2790
|
-
|
|
2791
|
-
|
|
2611
|
+
return settled;
|
|
2612
|
+
});
|
|
2613
|
+
result.then(() => child.done).then(async () => {
|
|
2614
|
+
const drainedStdout = child.collected.stdout?.readFrom(0).text || output;
|
|
2615
|
+
if (spec.onAuthFailure !== void 0) {
|
|
2616
|
+
const drainedStderr = child.collected.stderr?.readFrom(0).text || stderr;
|
|
2617
|
+
const authDetail = `${parseClaudeStreamJson(drainedStdout).error ?? ""}\n${drainedStderr}`;
|
|
2618
|
+
if (CLAUDE_AUTH_FAILURE.test(authDetail)) spec.onAuthFailure(authDetail.split("\n").find((line) => line.trim() !== "") ?? "auth failure");
|
|
2619
|
+
}
|
|
2620
|
+
await mirrorClaudeAfterExit(spec, task, turn, drainedStdout, liveMirror);
|
|
2621
|
+
const closeTurn = async () => {
|
|
2622
|
+
if (spec.childSession === void 0) return;
|
|
2623
|
+
const childSession = spec.childSession;
|
|
2624
|
+
const settled = await result;
|
|
2625
|
+
if (settled.stopReason === "completed") childSession.append("turn/end", {
|
|
2792
2626
|
turn,
|
|
2793
2627
|
reason: { kind: "completed" }
|
|
2794
2628
|
});
|
|
2795
|
-
else if (settled.stopReason === "aborted")
|
|
2629
|
+
else if (settled.stopReason === "aborted") childSession.append("turn/end", {
|
|
2796
2630
|
turn,
|
|
2797
2631
|
reason: {
|
|
2798
2632
|
kind: "aborted",
|
|
2799
2633
|
reason: { kind: "parent" }
|
|
2800
2634
|
}
|
|
2801
2635
|
});
|
|
2802
|
-
else {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
message: parsed.error ?? `claude -p exited with code ${String(exitCode)}`,
|
|
2810
|
-
code: "UNKNOWN"
|
|
2811
|
-
}
|
|
2636
|
+
else childSession.append("turn/end", {
|
|
2637
|
+
turn,
|
|
2638
|
+
reason: {
|
|
2639
|
+
kind: "error",
|
|
2640
|
+
error: {
|
|
2641
|
+
message: parseClaudeStreamJson(drainedStdout).error ?? `claude -p exited with code ${String(exitCode)}`,
|
|
2642
|
+
code: "UNKNOWN"
|
|
2812
2643
|
}
|
|
2813
|
-
}
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
if (
|
|
2820
|
-
|
|
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);
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
if (spec.ctx !== void 0) await persistChildSession(spec.ctx, childSession).catch((error) => {
|
|
2647
|
+
spec.ctx?.logger.warn(`subagent-claude: turn/end persistence failed: ${thrown(error).message}`);
|
|
2648
|
+
});
|
|
2649
|
+
};
|
|
2650
|
+
if (liveMirror === void 0) await closeTurn();
|
|
2651
|
+
else await liveMirror.enqueue(closeTurn);
|
|
2825
2652
|
}, () => {});
|
|
2826
2653
|
return Promise.resolve(subprocessRunHandle({
|
|
2827
|
-
id:
|
|
2654
|
+
id: SessionId(randomUUID()),
|
|
2828
2655
|
result,
|
|
2829
2656
|
signal: request.signal,
|
|
2830
2657
|
onAbort,
|
|
@@ -2833,6 +2660,22 @@ async function startClaudeCliRun(request, spec) {
|
|
|
2833
2660
|
}));
|
|
2834
2661
|
}
|
|
2835
2662
|
/** One assistant-role message event, attributed to the claude route. */
|
|
2663
|
+
/**
|
|
2664
|
+
* Write the round's request/header when the config differs from the child
|
|
2665
|
+
* log's last one (the host logs changed snapshots, not per-call duplicates):
|
|
2666
|
+
* the first write is `initial`, a differing one `change`, an identical one
|
|
2667
|
+
* skipped. Context Lens reads the child log's config from these headers.
|
|
2668
|
+
*/
|
|
2669
|
+
function appendClaudeRequestHeader(childSession, config) {
|
|
2670
|
+
const serialized = JSON.stringify(config);
|
|
2671
|
+
let last;
|
|
2672
|
+
for (const event of childSession.snapshotEvents()) if (event.type === "request/header") last = JSON.stringify(event.data.header?.config);
|
|
2673
|
+
if (last === serialized) return;
|
|
2674
|
+
childSession.append("request/header", {
|
|
2675
|
+
header: { config },
|
|
2676
|
+
reason: last === void 0 ? "initial" : "change"
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2836
2679
|
function assistantEvent(blocks) {
|
|
2837
2680
|
return createAssistantMessage({
|
|
2838
2681
|
content: blocks,
|
|
@@ -2850,30 +2693,53 @@ function assistantEvent(blocks) {
|
|
|
2850
2693
|
* the real-time view renders nothing (only a full rebuild, which mints step
|
|
2851
2694
|
* drafts from the explicit coordinates, recovers it). Tool cards escape that
|
|
2852
2695
|
* fate only because their definition does not consult the step location.
|
|
2696
|
+
*
|
|
2697
|
+
* A tool line folds the native lifecycle inside its one step: an assistant
|
|
2698
|
+
* message advertising the tool-call block, the `tool/call`, and its
|
|
2699
|
+
* `tool/result` — the released-log reader pairs call and result against the
|
|
2700
|
+
* advertisement and resolves every advertised call before the step closes, so
|
|
2701
|
+
* a line whose result the stream has not recorded yet must NOT reach this
|
|
2702
|
+
* fold mid-round (the callers hold there; the result merges into the call's
|
|
2703
|
+
* line in place, so a later pass sees it). A line still unresulted at settle
|
|
2704
|
+
* folds with the interrupted placeholder ({@link CLAUDE_INTERRUPTED_TOOL_RESULT}).
|
|
2853
2705
|
*/
|
|
2854
2706
|
function appendClaudeTranscriptLine(childSession, turn, step, line, usage) {
|
|
2707
|
+
const lineUsage = line.usage ?? usage;
|
|
2855
2708
|
childSession.append("step/start", {
|
|
2856
2709
|
turn,
|
|
2857
2710
|
step
|
|
2858
2711
|
});
|
|
2859
2712
|
if (line.kind === "tool") {
|
|
2713
|
+
const args = line.args ?? "";
|
|
2714
|
+
childSession.append("assistant/message", {
|
|
2715
|
+
turn,
|
|
2716
|
+
step,
|
|
2717
|
+
message: assistantEvent([{
|
|
2718
|
+
type: "tool-call",
|
|
2719
|
+
id: line.id,
|
|
2720
|
+
name: line.name,
|
|
2721
|
+
arguments: args
|
|
2722
|
+
}]),
|
|
2723
|
+
stream: [],
|
|
2724
|
+
...lineUsage === void 0 ? {} : { usage: lineUsage }
|
|
2725
|
+
}, { surfaceOp: "append" });
|
|
2860
2726
|
const call = childSession.append("tool/call", {
|
|
2861
2727
|
turn,
|
|
2862
2728
|
step,
|
|
2863
2729
|
callId: line.id,
|
|
2864
2730
|
name: line.name,
|
|
2865
|
-
arguments:
|
|
2731
|
+
arguments: args
|
|
2866
2732
|
});
|
|
2867
|
-
|
|
2733
|
+
childSession.append("tool/result", {
|
|
2868
2734
|
turn,
|
|
2869
2735
|
step,
|
|
2870
2736
|
message: createToolResultMessage({
|
|
2871
2737
|
callId: line.id,
|
|
2872
2738
|
content: [{
|
|
2873
2739
|
type: "text",
|
|
2874
|
-
text: line.result
|
|
2740
|
+
text: line.result ?? "(工具调用被中断,未能记录返回结果)"
|
|
2875
2741
|
}],
|
|
2876
|
-
isError:
|
|
2742
|
+
isError: line.result === void 0
|
|
2877
2743
|
})
|
|
2878
2744
|
}, {
|
|
2879
2745
|
surfaceOp: "append",
|
|
@@ -2897,24 +2763,82 @@ function appendClaudeTranscriptLine(childSession, turn, step, line, usage) {
|
|
|
2897
2763
|
step,
|
|
2898
2764
|
message: assistantEvent(blocks),
|
|
2899
2765
|
stream: [],
|
|
2900
|
-
...
|
|
2766
|
+
...lineUsage === void 0 ? {} : { usage: lineUsage }
|
|
2767
|
+
}, { surfaceOp: "append" });
|
|
2768
|
+
childSession.append("step/end", {
|
|
2769
|
+
turn,
|
|
2770
|
+
step
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
/** The delta-progress text for one transcript line. */
|
|
2774
|
+
function claudeLineText(line) {
|
|
2775
|
+
return line.kind === "tool" ? `[工具 ${line.name}]${line.args !== void 0 ? ` ${line.args}` : ""}${line.result !== void 0 ? ` → ${line.result}` : ""}` : line.text;
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Fold one API call (a message-id group) into the child session as ONE
|
|
2779
|
+
* assistant step: a single assistant/message carrying the call's reasoning,
|
|
2780
|
+
* text and tool-call blocks (the host's own mixed-content shape), then each
|
|
2781
|
+
* call's native lifecycle, then step/end. The call's usage rides the message
|
|
2782
|
+
* once — the group's own per-call stamp wins over the round-total fallback.
|
|
2783
|
+
*/
|
|
2784
|
+
function appendClaudeTranscriptGroup(childSession, turn, step, group, usage) {
|
|
2785
|
+
const lineUsage = group.map((line) => line.usage).find((value) => value !== void 0) ?? usage;
|
|
2786
|
+
const blocks = [];
|
|
2787
|
+
for (const line of group) if (line.kind === "think") blocks.push({
|
|
2788
|
+
type: "reasoning",
|
|
2789
|
+
text: line.text
|
|
2790
|
+
});
|
|
2791
|
+
else if (line.kind === "text") blocks.push({
|
|
2792
|
+
type: "text",
|
|
2793
|
+
text: line.text
|
|
2794
|
+
});
|
|
2795
|
+
else blocks.push({
|
|
2796
|
+
type: "tool-call",
|
|
2797
|
+
id: line.id,
|
|
2798
|
+
name: line.name,
|
|
2799
|
+
arguments: line.args ?? ""
|
|
2800
|
+
});
|
|
2801
|
+
childSession.append("step/start", {
|
|
2802
|
+
turn,
|
|
2803
|
+
step
|
|
2804
|
+
});
|
|
2805
|
+
childSession.append("assistant/message", {
|
|
2806
|
+
turn,
|
|
2807
|
+
step,
|
|
2808
|
+
message: assistantEvent(blocks),
|
|
2809
|
+
stream: [],
|
|
2810
|
+
...lineUsage === void 0 ? {} : { usage: lineUsage }
|
|
2901
2811
|
}, { surfaceOp: "append" });
|
|
2812
|
+
for (const line of group) {
|
|
2813
|
+
if (line.kind !== "tool") continue;
|
|
2814
|
+
const call = childSession.append("tool/call", {
|
|
2815
|
+
turn,
|
|
2816
|
+
step,
|
|
2817
|
+
callId: line.id,
|
|
2818
|
+
name: line.name,
|
|
2819
|
+
arguments: line.args ?? ""
|
|
2820
|
+
});
|
|
2821
|
+
childSession.append("tool/result", {
|
|
2822
|
+
turn,
|
|
2823
|
+
step,
|
|
2824
|
+
message: createToolResultMessage({
|
|
2825
|
+
callId: line.id,
|
|
2826
|
+
content: [{
|
|
2827
|
+
type: "text",
|
|
2828
|
+
text: line.result ?? "(工具调用被中断,未能记录返回结果)"
|
|
2829
|
+
}],
|
|
2830
|
+
isError: line.result === void 0
|
|
2831
|
+
})
|
|
2832
|
+
}, {
|
|
2833
|
+
surfaceOp: "append",
|
|
2834
|
+
sourceEventSeqs: [call.seq]
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2902
2837
|
childSession.append("step/end", {
|
|
2903
2838
|
turn,
|
|
2904
2839
|
step
|
|
2905
2840
|
});
|
|
2906
2841
|
}
|
|
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
2842
|
/** Create the per-run live mirror over the incremental parser. */
|
|
2919
2843
|
function createClaudeLiveMirror(spec, task, turn) {
|
|
2920
2844
|
const parser = new ClaudeStreamParser();
|
|
@@ -2922,6 +2846,7 @@ function createClaudeLiveMirror(spec, task, turn) {
|
|
|
2922
2846
|
const ctx = spec.ctx;
|
|
2923
2847
|
let mirrored = 0;
|
|
2924
2848
|
let userMirrored = false;
|
|
2849
|
+
let headerWritten = false;
|
|
2925
2850
|
let lastTodos;
|
|
2926
2851
|
let todoSkewWarned = false;
|
|
2927
2852
|
let queue = Promise.resolve();
|
|
@@ -2933,7 +2858,7 @@ function createClaudeLiveMirror(spec, task, turn) {
|
|
|
2933
2858
|
return userMirrored;
|
|
2934
2859
|
},
|
|
2935
2860
|
enqueue(task) {
|
|
2936
|
-
queue = queue.then(task);
|
|
2861
|
+
queue = queue.catch(() => {}).then(task);
|
|
2937
2862
|
return queue;
|
|
2938
2863
|
},
|
|
2939
2864
|
push(chunk) {
|
|
@@ -2946,8 +2871,22 @@ function createClaudeLiveMirror(spec, task, turn) {
|
|
|
2946
2871
|
todoSkewWarned = true;
|
|
2947
2872
|
ctx.logger.warn("subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line");
|
|
2948
2873
|
}
|
|
2949
|
-
const
|
|
2950
|
-
|
|
2874
|
+
const groups = groupClaudeTranscriptLines(parser.lines);
|
|
2875
|
+
const groupStarts = [];
|
|
2876
|
+
let totalLines = 0;
|
|
2877
|
+
for (const group of groups) {
|
|
2878
|
+
groupStarts.push(totalLines);
|
|
2879
|
+
totalLines += group.length;
|
|
2880
|
+
}
|
|
2881
|
+
let uptoGroups = parser.completed ? groups.length : groups.length - 1;
|
|
2882
|
+
if (!parser.completed) for (let scan = 0; scan < uptoGroups; scan += 1) {
|
|
2883
|
+
if (groupStarts[scan] < mirrored) continue;
|
|
2884
|
+
if (groups[scan].some((line) => line.kind === "tool" && line.result === void 0)) {
|
|
2885
|
+
uptoGroups = scan;
|
|
2886
|
+
break;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
if ((groupStarts[uptoGroups] ?? totalLines) <= mirrored && userMirrored) return;
|
|
2951
2890
|
mirror.enqueue(async () => {
|
|
2952
2891
|
try {
|
|
2953
2892
|
const localAgent = ctx.get("localAgent");
|
|
@@ -2965,17 +2904,27 @@ function createClaudeLiveMirror(spec, task, turn) {
|
|
|
2965
2904
|
text: task
|
|
2966
2905
|
});
|
|
2967
2906
|
}
|
|
2907
|
+
if (!headerWritten) {
|
|
2908
|
+
const observedModel = parser.model ?? spec.model;
|
|
2909
|
+
if (observedModel !== void 0) {
|
|
2910
|
+
headerWritten = true;
|
|
2911
|
+
appendClaudeRequestHeader(childSession, {
|
|
2912
|
+
provider: "claude-local",
|
|
2913
|
+
model: observedModel,
|
|
2914
|
+
...spec.controlled ? { reasoningEffort: spec.effort ?? "auto" } : {}
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2968
2918
|
let carrier = -1;
|
|
2969
|
-
if (parser.completed && parser.usage !== void 0) {
|
|
2970
|
-
for (let scan = 0; scan <
|
|
2919
|
+
if (parser.completed && parser.usage !== void 0 && !parser.lines.some((line) => line.usage !== void 0)) {
|
|
2920
|
+
for (let scan = 0; scan < groups.length; scan += 1) if (groups[scan].some((line) => line.kind !== "tool")) carrier = scan;
|
|
2971
2921
|
}
|
|
2972
|
-
for (let
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
localAgent?.reportRunProgress(childSession.id, {
|
|
2922
|
+
for (let scan = 0; scan < uptoGroups; scan += 1) {
|
|
2923
|
+
if (groupStarts[scan] < mirrored) continue;
|
|
2924
|
+
const group = groups[scan];
|
|
2925
|
+
appendClaudeTranscriptGroup(childSession, turn, scan + 1, group, scan === carrier ? parser.usage : void 0);
|
|
2926
|
+
mirrored = groupStarts[scan] + group.length;
|
|
2927
|
+
for (const line of group) localAgent?.reportRunProgress(childSession.id, {
|
|
2979
2928
|
kind: "delta",
|
|
2980
2929
|
text: claudeLineText(line)
|
|
2981
2930
|
});
|
|
@@ -3025,18 +2974,28 @@ async function appendClaudeResponse(spec, task, turn, parsed, fromLines = 0, use
|
|
|
3025
2974
|
text: task
|
|
3026
2975
|
});
|
|
3027
2976
|
}
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
let
|
|
3031
|
-
for (const
|
|
3032
|
-
|
|
3033
|
-
|
|
2977
|
+
const groups = groupClaudeTranscriptLines(parsed.lines);
|
|
2978
|
+
const groupStarts = [];
|
|
2979
|
+
let totalLines = 0;
|
|
2980
|
+
for (const group of groups) {
|
|
2981
|
+
groupStarts.push(totalLines);
|
|
2982
|
+
totalLines += group.length;
|
|
2983
|
+
}
|
|
2984
|
+
let carrier = -1;
|
|
2985
|
+
if (!parsed.lines.some((line) => line.usage !== void 0)) {
|
|
2986
|
+
for (let scan = 0; scan < groups.length; scan += 1) if (groups[scan].some((line) => line.kind !== "tool")) carrier = scan;
|
|
2987
|
+
}
|
|
2988
|
+
for (let scan = 0; scan < groups.length; scan += 1) {
|
|
2989
|
+
if (groupStarts[scan] < fromLines) continue;
|
|
2990
|
+
const group = groups[scan];
|
|
2991
|
+
appendClaudeTranscriptGroup(childSession, turn, scan + 1, group, scan === carrier ? parsed.usage : void 0);
|
|
2992
|
+
for (const line of group) localAgent?.reportRunProgress(childSession.id, {
|
|
3034
2993
|
kind: "delta",
|
|
3035
2994
|
text: claudeLineText(line)
|
|
3036
2995
|
});
|
|
3037
|
-
step += 1;
|
|
3038
2996
|
}
|
|
3039
|
-
|
|
2997
|
+
const carrierLine = carrier === -1 ? -1 : groupStarts[carrier] ?? -1;
|
|
2998
|
+
if (parsed.usage !== void 0 && carrierLine !== -1 && carrierLine < fromLines) spec.ctx.logger.warn(`subagent-claude: round ${turn} usage arrived after its carrier line was mirrored; the accounting is dropped`);
|
|
3040
2999
|
await persistChildSession(spec.ctx, childSession);
|
|
3041
3000
|
localAgent?.reportRunProgress(childSession.id, {
|
|
3042
3001
|
kind: "mirror",
|
|
@@ -3090,449 +3049,780 @@ async function mirrorClaudeAfterExit(spec, task, turn, output, live) {
|
|
|
3090
3049
|
} catch (error) {
|
|
3091
3050
|
spec.onError?.(thrown(error), "error");
|
|
3092
3051
|
}
|
|
3093
|
-
}
|
|
3094
|
-
//#endregion
|
|
3095
|
-
//#region lib/types/live-switch.js
|
|
3052
|
+
}
|
|
3053
|
+
//#endregion
|
|
3054
|
+
//#region lib/types/live-switch.js
|
|
3055
|
+
/**
|
|
3056
|
+
* The settings-driven live-driver switch: owns the active driver generation
|
|
3057
|
+
* and retires old ones on a settings change, without a plugin reload.
|
|
3058
|
+
*
|
|
3059
|
+
* Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
|
|
3060
|
+
* new rounds are refused (the provider's catch falls back to exec), in-flight
|
|
3061
|
+
* rounds finish on their runtime, idle runtimes are reclaimed at once.
|
|
3062
|
+
* Toggling ON builds the next generation lazily (no process until the first
|
|
3063
|
+
* round). Legacy granularity settings are ignored.
|
|
3064
|
+
*
|
|
3065
|
+
* @module @khorsheed/dsh-local-agent-claude-code — internal, unit-tested directly.
|
|
3066
|
+
*/
|
|
3067
|
+
var LiveDriverSwitch = class {
|
|
3068
|
+
ctx;
|
|
3069
|
+
driverBase;
|
|
3070
|
+
liveOn = false;
|
|
3071
|
+
active;
|
|
3072
|
+
/** Retiring generations still draining their in-flight rounds. */
|
|
3073
|
+
draining = /* @__PURE__ */ new Set();
|
|
3074
|
+
unwatch;
|
|
3075
|
+
constructor(ctx, face, driverBase) {
|
|
3076
|
+
this.ctx = ctx;
|
|
3077
|
+
this.driverBase = driverBase;
|
|
3078
|
+
this.apply(face.get());
|
|
3079
|
+
this.unwatch = face.watch((next) => {
|
|
3080
|
+
this.apply(next);
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Resolve the live driver for one round's member (the provider's call
|
|
3085
|
+
* site). A retiring generation still hosting the member gates the new one —
|
|
3086
|
+
* the round falls back to exec rather than double-loading the same claude
|
|
3087
|
+
* session into two processes.
|
|
3088
|
+
*/
|
|
3089
|
+
resolve = (childSessionId) => {
|
|
3090
|
+
if (this.active === void 0) return void 0;
|
|
3091
|
+
for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return void 0;
|
|
3092
|
+
return this.active;
|
|
3093
|
+
};
|
|
3094
|
+
/**
|
|
3095
|
+
* The generation currently hosting the member's runtime — the active one,
|
|
3096
|
+
* or a retiring generation still draining it (the model broker retires the
|
|
3097
|
+
* member's runtime through whichever generation owns it on a model switch).
|
|
3098
|
+
*/
|
|
3099
|
+
hostingDriver(childSessionId) {
|
|
3100
|
+
if (this.active?.hasRuntime(childSessionId) === true) return this.active;
|
|
3101
|
+
for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return retiring;
|
|
3102
|
+
}
|
|
3103
|
+
/** Mirror the resolved settings into the driver generation. */
|
|
3104
|
+
apply(next) {
|
|
3105
|
+
if (next.live === this.liveOn) return;
|
|
3106
|
+
this.liveOn = next.live;
|
|
3107
|
+
const retiring = this.active;
|
|
3108
|
+
this.active = next.live ? new ClaudeLiveDriver(this.ctx, { ...this.driverBase }) : void 0;
|
|
3109
|
+
if (retiring !== void 0) {
|
|
3110
|
+
this.draining.add(retiring);
|
|
3111
|
+
retiring.drain().then(() => {
|
|
3112
|
+
this.draining.delete(retiring);
|
|
3113
|
+
}, (error) => {
|
|
3114
|
+
this.draining.delete(retiring);
|
|
3115
|
+
this.ctx.logger.warn(`local-agent-claude-code: live driver drain failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
/** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
|
|
3120
|
+
dispose() {
|
|
3121
|
+
this.unwatch();
|
|
3122
|
+
this.active?.disposeAll();
|
|
3123
|
+
for (const retiring of this.draining) retiring.disposeAll();
|
|
3124
|
+
this.draining.clear();
|
|
3125
|
+
}
|
|
3126
|
+
};
|
|
3127
|
+
//#endregion
|
|
3128
|
+
//#region lib/types/provision.js
|
|
3129
|
+
/**
|
|
3130
|
+
* Claude Code scoped-home provisioning. The harness authenticates by
|
|
3131
|
+
* endpoint key (see endpoint-auth.ts), so a scoped home needs nothing beyond
|
|
3132
|
+
* the directory itself: `claude -p` scaffolds `.claude.json` on its first
|
|
3133
|
+
* run, and the endpoint credential arrives per round through the spawn env
|
|
3134
|
+
* (resolved from the scope's endpoint-auth.json over the plugin config).
|
|
3135
|
+
* When `proxyUrl` is configured, the scoped `settings.json` gains an env
|
|
3136
|
+
* block pointing the CLI's own traffic at it — claude reads proxy settings
|
|
3137
|
+
* from this file, so the child works even when the host process environment
|
|
3138
|
+
* carries no proxy (a supervisor-spawned instance's env is not the user's
|
|
3139
|
+
* shell env).
|
|
3140
|
+
* @module @khorsheed/dsh-local-agent-claude-code/provision
|
|
3141
|
+
*/
|
|
3142
|
+
/** The scoped settings file carrying the CLI's env block. */
|
|
3143
|
+
const SCOPED_SETTINGS = "settings.json";
|
|
3144
|
+
/**
|
|
3145
|
+
* Provision the scoped home directory. Claude creates it lazily on first
|
|
3146
|
+
* use, but creating it eagerly keeps the harness's homeDir contract uniform
|
|
3147
|
+
* with the other harnesses. Two maintenance duties run here:
|
|
3148
|
+
*
|
|
3149
|
+
* - Legacy OAuth sweep: the endpoint-key era never reads `.credentials.json`
|
|
3150
|
+
* or the `.claude.json` `oauthAccount` marker — both are deleted so a
|
|
3151
|
+
* pre-migration scoped home carries no dead grant (measured: claude
|
|
3152
|
+
* 2.1.296 prefers the env credential over a stored one anyway; the sweep
|
|
3153
|
+
* is hygiene, not priority repair). Other `.claude.json` keys survive.
|
|
3154
|
+
* - When `proxyUrl` is configured, the scoped `settings.json` gains an env
|
|
3155
|
+
* block pointing the CLI's own traffic at it — claude reads proxy settings
|
|
3156
|
+
* from this file, so the child works even when the host process environment
|
|
3157
|
+
* carries no proxy (a supervisor-spawned instance's env is not the user's
|
|
3158
|
+
* shell env). An existing settings file is merged, not overwritten; only
|
|
3159
|
+
* the two proxy keys are managed.
|
|
3160
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3161
|
+
* @param proxyUrl - optional HTTP proxy URL for the child CLI's traffic.
|
|
3162
|
+
*/
|
|
3163
|
+
async function provisionClaudeHome(homeDir, proxyUrl) {
|
|
3164
|
+
await mkdir(homeDir, { recursive: true });
|
|
3165
|
+
await sweepLegacyOauth(homeDir);
|
|
3166
|
+
if (proxyUrl === void 0) return;
|
|
3167
|
+
const path = join(homeDir, SCOPED_SETTINGS);
|
|
3168
|
+
let settings = {};
|
|
3169
|
+
try {
|
|
3170
|
+
settings = JSON.parse(await readFile(path, "utf8"));
|
|
3171
|
+
} catch {}
|
|
3172
|
+
const env = settings.env ?? {};
|
|
3173
|
+
if (env.https_proxy === proxyUrl && env.http_proxy === proxyUrl) return;
|
|
3174
|
+
env.https_proxy = proxyUrl;
|
|
3175
|
+
env.http_proxy = proxyUrl;
|
|
3176
|
+
settings.env = env;
|
|
3177
|
+
await writeFile(path, JSON.stringify(settings, void 0, 2) + "\n");
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Remove the OAuth era's artifacts from the scoped home: the
|
|
3181
|
+
* `.credentials.json` grant file outright, and the `oauthAccount` marker key
|
|
3182
|
+
* from `.claude.json` (the file itself carries live CLI state and stays).
|
|
3183
|
+
* Idempotent and best-effort per file — a missing or malformed file is
|
|
3184
|
+
* simply not a sweep target. The macOS keychain entry is deliberately left
|
|
3185
|
+
* alone: the CLI never consults it once an env credential rides the spawn,
|
|
3186
|
+
* and keychain writes are outside the harness's file-based contract.
|
|
3187
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3188
|
+
*/
|
|
3189
|
+
async function sweepLegacyOauth(homeDir) {
|
|
3190
|
+
await rm(join(homeDir, ".credentials.json"), { force: true });
|
|
3191
|
+
const configPath = join(homeDir, ".claude.json");
|
|
3192
|
+
let config;
|
|
3193
|
+
try {
|
|
3194
|
+
config = JSON.parse(await readFile(configPath, "utf8"));
|
|
3195
|
+
} catch {
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
if (typeof config !== "object" || config === null || !("oauthAccount" in config)) return;
|
|
3199
|
+
delete config["oauthAccount"];
|
|
3200
|
+
await writeFile(configPath, JSON.stringify(config, void 0, 2) + "\n");
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* Read the scoped settings' configured model: the `model` key of
|
|
3204
|
+
* `<homeDir>/settings.json` — the model a delegation round pins when the
|
|
3205
|
+
* scoped settings name one at all. Claude's own default model is decided by
|
|
3206
|
+
* the CLI and is deliberately NOT guessed here. Only the scoped file is
|
|
3207
|
+
* consulted (never the host's `~/.claude`); absent or malformed content
|
|
3208
|
+
* yields undefined.
|
|
3209
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3210
|
+
* @returns the configured model identifier, or undefined when none is set.
|
|
3211
|
+
*/
|
|
3212
|
+
async function readClaudeConfiguredModel(homeDir) {
|
|
3213
|
+
let text;
|
|
3214
|
+
try {
|
|
3215
|
+
text = await readFile(join(homeDir, SCOPED_SETTINGS), "utf8");
|
|
3216
|
+
} catch {
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
try {
|
|
3220
|
+
const settings = JSON.parse(text);
|
|
3221
|
+
return typeof settings.model === "string" && settings.model !== "" ? settings.model : void 0;
|
|
3222
|
+
} catch {
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
/** Read the scoped native effort setting without changing the settings file. */
|
|
3227
|
+
async function readClaudeConfiguredEffort(homeDir) {
|
|
3228
|
+
try {
|
|
3229
|
+
const settings = JSON.parse(await readFile(join(homeDir, SCOPED_SETTINGS), "utf8"));
|
|
3230
|
+
return typeof settings.effortLevel === "string" && settings.effortLevel.trim() !== "" ? settings.effortLevel.trim() : void 0;
|
|
3231
|
+
} catch {
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
/**
|
|
3236
|
+
* Write (or, with undefined, remove) the scoped settings.json `model` key,
|
|
3237
|
+
* merging the rest of the file exactly as {@link provisionClaudeHome} merges
|
|
3238
|
+
* its env block. Only the live driver calls this: a resident runtime needs
|
|
3239
|
+
* its member's effective model IN THE FILE, because a `--resume` respawn
|
|
3240
|
+
* restores the session's own stored model over the `--model` flag. The exec
|
|
3241
|
+
* path never calls it — its `--model` flag always applies, and the file
|
|
3242
|
+
* stays exactly as the person edited it.
|
|
3243
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3244
|
+
* @param model - the model to scratch in, or undefined to remove the key.
|
|
3245
|
+
*/
|
|
3246
|
+
async function writeClaudeScopedModel(homeDir, model) {
|
|
3247
|
+
const path = join(homeDir, SCOPED_SETTINGS);
|
|
3248
|
+
let settings = {};
|
|
3249
|
+
try {
|
|
3250
|
+
settings = JSON.parse(await readFile(path, "utf8"));
|
|
3251
|
+
} catch {}
|
|
3252
|
+
if (model === void 0) {
|
|
3253
|
+
if (!("model" in settings)) return;
|
|
3254
|
+
delete settings["model"];
|
|
3255
|
+
} else {
|
|
3256
|
+
if (settings["model"] === model) return;
|
|
3257
|
+
settings["model"] = model;
|
|
3258
|
+
}
|
|
3259
|
+
await writeFile(path, JSON.stringify(settings, void 0, 2) + "\n");
|
|
3260
|
+
}
|
|
3261
|
+
//#endregion
|
|
3262
|
+
//#region lib/types/model-broker.js
|
|
3263
|
+
/**
|
|
3264
|
+
* The claude-code harness's model broker: the family gateway routes
|
|
3265
|
+
* `harnessModel` / `memberModel` / `setMemberModel` here, and the broker
|
|
3266
|
+
* answers the family's fixed resolution order — the session-level override
|
|
3267
|
+
* (the composer's picker), the delegation's recorded model, the plugin-config
|
|
3268
|
+
* `model` key (the settings card), the scoped settings.json's own `model`,
|
|
3269
|
+
* and last the CLI's built-in default, which names nothing.
|
|
3270
|
+
*
|
|
3271
|
+
* The override map is IN-MEMORY by design (it deliberately does not survive a
|
|
3272
|
+
* host restart) and SHARED: index.ts hands the same map to the provider's and
|
|
3273
|
+
* the live driver's model resolvers, so a composer switch reaches the next
|
|
3274
|
+
* round through every drive. With the live driver on, switching to a
|
|
3275
|
+
* DIFFERENT model retires the member's resident runtime so the next round
|
|
3276
|
+
* respawns onto the new model — the CLI session itself resumes on disk, so
|
|
3277
|
+
* the conversation carries over. A switch while a round is in flight is
|
|
3278
|
+
* refused: retiring a runtime mid-round would kill the run.
|
|
3279
|
+
*
|
|
3280
|
+
* `lastObserved` (the cli-builtin layer's display hint): the delegation
|
|
3281
|
+
* record's `observedModel` answers when the live-settle channel reported
|
|
3282
|
+
* one; the member's own CLI transcript (`transcriptModel`, a bounded read of
|
|
3283
|
+
* the projects tree) is the history backstop for rounds that predate that
|
|
3284
|
+
* channel — read-only, TTL-cached, never a write into `delegations.jsonl`.
|
|
3285
|
+
*
|
|
3286
|
+
* @module @khorsheed/dsh-local-agent-claude-code/model-broker — internal, unit-tested directly.
|
|
3287
|
+
*/
|
|
3288
|
+
/**
|
|
3289
|
+
* The scoped settings.json `model` key across live-spawn scratch writes. The
|
|
3290
|
+
* live driver writes a member's effective model into the file before a
|
|
3291
|
+
* resident spawn (a `--resume` respawn restores the session's stored model
|
|
3292
|
+
* over the `--model` flag), and this memory keeps the person's own configured
|
|
3293
|
+
* value straight across those writes: what the file held before our first
|
|
3294
|
+
* write is the `cli-config` layer, and a spawn with NO effective model
|
|
3295
|
+
* restores that value instead of leaking the previous member's model into
|
|
3296
|
+
* the next runtime. In-memory on purpose: a restart re-reads the file as it
|
|
3297
|
+
* stands. Writes serialize on an internal queue so concurrent member spawns
|
|
3298
|
+
* cannot lose each other's read-modify-write.
|
|
3299
|
+
*/
|
|
3300
|
+
var ClaudeScopedModelMemory = class {
|
|
3301
|
+
/** The value we last scratched in and believe the file still holds. */
|
|
3302
|
+
written;
|
|
3303
|
+
/** What the file held before our first write; null = it named no model. */
|
|
3304
|
+
previous = null;
|
|
3305
|
+
queue = Promise.resolve();
|
|
3306
|
+
/**
|
|
3307
|
+
* Bring the scoped settings.json's `model` key to the effective model a
|
|
3308
|
+
* spawn is about to bind — writing it, or restoring the person's value when
|
|
3309
|
+
* the spawn binds none and the file still holds our earlier scratch.
|
|
3310
|
+
* @param homeDir - the scoped home the spawn runs against.
|
|
3311
|
+
* @param model - the spawn's effective model, or undefined for none.
|
|
3312
|
+
*/
|
|
3313
|
+
provision(homeDir, model) {
|
|
3314
|
+
const run = this.queue.then(() => this.provisionLocked(homeDir, model));
|
|
3315
|
+
this.queue = run.catch(() => void 0);
|
|
3316
|
+
return run;
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* The person's configured model — the `cli-config` layer. A file value we
|
|
3320
|
+
* did not write reports as read; our own scratch value reports what the
|
|
3321
|
+
* file held before we wrote it.
|
|
3322
|
+
* @param homeDir - the scoped home to read.
|
|
3323
|
+
* @returns the person's configured model, or undefined when none.
|
|
3324
|
+
*/
|
|
3325
|
+
async cliDefault(homeDir) {
|
|
3326
|
+
const file = await readClaudeConfiguredModel(homeDir);
|
|
3327
|
+
if (file !== this.written) {
|
|
3328
|
+
this.previous = file ?? null;
|
|
3329
|
+
this.written = void 0;
|
|
3330
|
+
return file;
|
|
3331
|
+
}
|
|
3332
|
+
return this.previous ?? void 0;
|
|
3333
|
+
}
|
|
3334
|
+
async provisionLocked(homeDir, model) {
|
|
3335
|
+
const file = await readClaudeConfiguredModel(homeDir);
|
|
3336
|
+
if (file !== this.written) {
|
|
3337
|
+
this.previous = file ?? null;
|
|
3338
|
+
this.written = void 0;
|
|
3339
|
+
}
|
|
3340
|
+
if (model === void 0) {
|
|
3341
|
+
if (this.written === void 0) return;
|
|
3342
|
+
await writeClaudeScopedModel(homeDir, this.previous ?? void 0);
|
|
3343
|
+
this.written = void 0;
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
if (file === model) return;
|
|
3347
|
+
await writeClaudeScopedModel(homeDir, model);
|
|
3348
|
+
this.written = model;
|
|
3349
|
+
}
|
|
3350
|
+
};
|
|
3096
3351
|
/**
|
|
3097
|
-
*
|
|
3098
|
-
*
|
|
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.
|
|
3352
|
+
* Scope the served directory to the surface asking for it (the same helper
|
|
3353
|
+
* lives in each provider's broker — fold into the family core when the
|
|
3354
|
+
* directory faces next evolve):
|
|
3105
3355
|
*
|
|
3106
|
-
*
|
|
3356
|
+
* - memberless (the card's add-model dialog, the invite picker): the
|
|
3357
|
+
* endpoint's served catalog only — the configuration/history extras the
|
|
3358
|
+
* broker's info extension adds for STATUS display must never become add
|
|
3359
|
+
* candidates;
|
|
3360
|
+
* - a member whose harness has a curated list: the list IS the vocabulary,
|
|
3361
|
+
* in the list's own order (the same rule `choices` already follows) — a
|
|
3362
|
+
* curated value the endpoint no longer serves stays as a bare row (the
|
|
3363
|
+
* card shows it the same way), and the member's current effective model is
|
|
3364
|
+
* kept visible as a bare entry when it sits outside the list, so a pre-list
|
|
3365
|
+
* binding never renders blank;
|
|
3366
|
+
* - a member without a curated list: the legacy extended vocabulary.
|
|
3107
3367
|
*/
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3368
|
+
function scopeServedDirectory(directory, opts) {
|
|
3369
|
+
if (opts.member && opts.curated.length === 0) return directory;
|
|
3370
|
+
const served = directory.entries.filter((entry) => entry.source === "native");
|
|
3371
|
+
if (!opts.member) return {
|
|
3372
|
+
...directory,
|
|
3373
|
+
entries: served
|
|
3374
|
+
};
|
|
3375
|
+
const byValue = new Map(served.flatMap((entry) => entry.resolvedModel === void 0 ? [[entry.value, entry]] : [[entry.value, entry], [entry.resolvedModel, entry]]));
|
|
3376
|
+
const entries = opts.curated.map((value) => byValue.get(value) ?? {
|
|
3377
|
+
value,
|
|
3378
|
+
label: value,
|
|
3379
|
+
source: "configuration"
|
|
3380
|
+
});
|
|
3381
|
+
const current = opts.current?.trim();
|
|
3382
|
+
if (current !== void 0 && current !== "" && !entries.some((entry) => entry.value === current || entry.resolvedModel === current)) entries.push({
|
|
3383
|
+
value: current,
|
|
3384
|
+
label: current,
|
|
3385
|
+
source: "configuration"
|
|
3386
|
+
});
|
|
3387
|
+
return {
|
|
3388
|
+
...directory,
|
|
3389
|
+
entries
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
var ClaudeModelBroker = class {
|
|
3393
|
+
deps;
|
|
3394
|
+
constructor(deps) {
|
|
3395
|
+
this.deps = deps;
|
|
3396
|
+
}
|
|
3397
|
+
configurationAdapter(binding) {
|
|
3398
|
+
const context = this.deps.configurationContext?.(binding);
|
|
3399
|
+
if (context === void 0) throw new Error("Claude member configuration context is unavailable");
|
|
3400
|
+
const resolve = async (selection) => {
|
|
3401
|
+
let directory = await context.directory();
|
|
3402
|
+
if (directory.status === "loading") directory = await context.directory(true);
|
|
3403
|
+
const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel(binding.scope) ?? await readClaudeConfiguredModel(context.home) ?? directory.defaultModel;
|
|
3404
|
+
const entry = directory.entries.find((entry) => entry.value === model || entry.resolvedModel === model);
|
|
3405
|
+
const effort = (selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0) ?? await readClaudeConfiguredEffort(context.home) ?? entry?.reasoning?.default;
|
|
3406
|
+
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");
|
|
3407
|
+
return {
|
|
3408
|
+
...model === void 0 ? {} : { model },
|
|
3409
|
+
...effort === void 0 ? {} : { effort }
|
|
3410
|
+
};
|
|
3411
|
+
};
|
|
3412
|
+
const retire = async () => {
|
|
3413
|
+
await this.deps.liveSwitch.hostingDriver(binding.childSessionId)?.retireRuntime(binding.childSessionId);
|
|
3414
|
+
};
|
|
3415
|
+
return {
|
|
3416
|
+
validate: async (selection) => {
|
|
3417
|
+
await resolve(selection);
|
|
3418
|
+
},
|
|
3419
|
+
prepare: resolve,
|
|
3420
|
+
apply: async (selection) => {
|
|
3421
|
+
const resolved = await resolve(selection);
|
|
3422
|
+
await retire();
|
|
3423
|
+
return resolved;
|
|
3424
|
+
},
|
|
3425
|
+
reconcile: async (state) => {
|
|
3426
|
+
if (this.deps.localAgent.isDelegationActive(binding.childSessionId)) return {
|
|
3427
|
+
active: true,
|
|
3428
|
+
matches: "unknown",
|
|
3429
|
+
resolved: {}
|
|
3430
|
+
};
|
|
3431
|
+
await retire();
|
|
3432
|
+
return {
|
|
3433
|
+
active: false,
|
|
3434
|
+
matches: "current",
|
|
3435
|
+
resolved: await resolve(state.current.selection)
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
async modelDirectory(childSessionId, refresh = false) {
|
|
3441
|
+
if (refresh) await this.deps.directory?.(childSessionId, true);
|
|
3442
|
+
const record = childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId);
|
|
3443
|
+
const info = await this.modelInfo(childSessionId, record?.model);
|
|
3444
|
+
return scopeServedDirectory(info.directory ?? {
|
|
3445
|
+
entries: [],
|
|
3446
|
+
complete: false,
|
|
3447
|
+
customInput: true,
|
|
3448
|
+
status: "unsupported",
|
|
3449
|
+
refreshing: false,
|
|
3450
|
+
revision: 0
|
|
3451
|
+
}, {
|
|
3452
|
+
member: childSessionId !== void 0,
|
|
3453
|
+
curated: this.deps.modelList?.(record?.scope) ?? [],
|
|
3454
|
+
current: info.effective
|
|
3122
3455
|
});
|
|
3123
3456
|
}
|
|
3457
|
+
async *followModelDirectory(childSessionId, signal) {
|
|
3458
|
+
if (this.deps.followDirectory === void 0) {
|
|
3459
|
+
yield await this.modelDirectory(childSessionId);
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
for await (const _snapshot of this.deps.followDirectory(childSessionId, signal)) yield await this.modelDirectory(childSessionId);
|
|
3463
|
+
}
|
|
3124
3464
|
/**
|
|
3125
|
-
*
|
|
3126
|
-
*
|
|
3127
|
-
*
|
|
3128
|
-
* session into two processes.
|
|
3465
|
+
* The `lastObserved` cache: childSessionId (empty key = the memberless
|
|
3466
|
+
* harness read) → the answer and when it was read. A miss is cached too —
|
|
3467
|
+
* a member with no transcript yet should not pay a rescan per card open.
|
|
3129
3468
|
*/
|
|
3130
|
-
|
|
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
|
-
};
|
|
3469
|
+
observedCache = /* @__PURE__ */ new Map();
|
|
3135
3470
|
/**
|
|
3136
|
-
* The
|
|
3137
|
-
*
|
|
3138
|
-
*
|
|
3471
|
+
* The last model the member (or the harness) was observed running. The
|
|
3472
|
+
* live-settle channel is primary — a record whose latest round reported
|
|
3473
|
+
* `observedModel` answers without touching disk; the CLI's own transcripts
|
|
3474
|
+
* are the history backstop for every round that predates it.
|
|
3139
3475
|
*/
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3476
|
+
async lastObserved(childSessionId) {
|
|
3477
|
+
const record = childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId);
|
|
3478
|
+
if (childSessionId !== void 0 && record === void 0) return void 0;
|
|
3479
|
+
if (record?.observedModel !== void 0 && record.observedModel.trim() !== "") return record.observedModel;
|
|
3480
|
+
const key = childSessionId ?? "";
|
|
3481
|
+
const hit = this.observedCache.get(key);
|
|
3482
|
+
if (hit !== void 0 && Date.now() - hit.at < 6e4) return hit.model;
|
|
3483
|
+
const model = await this.deps.transcriptModel(record?.cliSessionId, childSessionId).catch(() => void 0);
|
|
3484
|
+
this.observedCache.set(key, {
|
|
3485
|
+
at: Date.now(),
|
|
3486
|
+
model
|
|
3487
|
+
});
|
|
3488
|
+
return model;
|
|
3143
3489
|
}
|
|
3144
|
-
/**
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3490
|
+
/**
|
|
3491
|
+
* Read the model surface in the family's fixed order. Without a member the
|
|
3492
|
+
* override and delegation layers stay absent — the settings card's "what
|
|
3493
|
+
* would a round run with" read.
|
|
3494
|
+
*/
|
|
3495
|
+
async modelInfo(childSessionId, delegationModel) {
|
|
3496
|
+
const record = childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId);
|
|
3497
|
+
const override = childSessionId === void 0 ? void 0 : this.deps.overrides.get(childSessionId);
|
|
3498
|
+
const delegation = delegationModel?.trim() === "" ? void 0 : delegationModel?.trim();
|
|
3499
|
+
const settings = this.deps.settingsModel(record?.scope);
|
|
3500
|
+
const cliDefault = await this.deps.cliDefault(childSessionId);
|
|
3501
|
+
const native = await this.deps.directory?.(childSessionId);
|
|
3502
|
+
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"];
|
|
3503
|
+
const curated = this.deps.modelList?.(record?.scope) ?? [];
|
|
3504
|
+
const choices = curated.length > 0 ? [...curated] : [...new Set([
|
|
3505
|
+
settings,
|
|
3506
|
+
cliDefault,
|
|
3507
|
+
...native?.entries.filter((entry) => !entry.hidden).map((entry) => entry.value) ?? [],
|
|
3508
|
+
...this.deps.recentModels()
|
|
3509
|
+
].filter((value) => value !== void 0))];
|
|
3510
|
+
const lastObserved = await this.lastObserved(childSessionId);
|
|
3511
|
+
const active = childSessionId !== void 0 && this.deps.localAgent.isDelegationActive(childSessionId);
|
|
3512
|
+
return {
|
|
3513
|
+
...effective === void 0 ? {} : { effective },
|
|
3514
|
+
source,
|
|
3515
|
+
...override === void 0 ? {} : { override },
|
|
3516
|
+
...delegation === void 0 ? {} : { delegation },
|
|
3517
|
+
...settings === void 0 ? {} : { settings },
|
|
3518
|
+
...cliDefault === void 0 ? {} : { cliDefault },
|
|
3519
|
+
...lastObserved === void 0 ? {} : { lastObserved },
|
|
3520
|
+
choices,
|
|
3521
|
+
...native === void 0 ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], curated.length > 0 ? [] : [...this.deps.recentModels(), lastObserved]) },
|
|
3522
|
+
live: this.deps.live(),
|
|
3523
|
+
switchable: !active,
|
|
3524
|
+
...active ? { reason: "该成员有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight)" } : {}
|
|
3525
|
+
};
|
|
3159
3526
|
}
|
|
3160
|
-
/**
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3527
|
+
/**
|
|
3528
|
+
* Set or clear a member's session-level override. Throws while a round is
|
|
3529
|
+
* in flight for the member; a same-model set is a no-op; a real change
|
|
3530
|
+
* retires the member's resident runtime when its bound model is no longer
|
|
3531
|
+
* what the next round would spawn with (the CLI session resumes on the
|
|
3532
|
+
* respawn, so the conversation carries over).
|
|
3533
|
+
*/
|
|
3534
|
+
async setMemberModel(childSessionId, model) {
|
|
3535
|
+
if (this.deps.localAgent.setMemberModel !== void 0) return this.deps.localAgent.setMemberModel(childSessionId, model);
|
|
3536
|
+
if (this.deps.localAgent.isDelegationActive(childSessionId)) throw new Error(`subagent-claude: 成员 ${childSessionId} 有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight for this member)`);
|
|
3537
|
+
const trimmed = model?.trim();
|
|
3538
|
+
const next = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
|
|
3539
|
+
if (next === this.deps.overrides.get(childSessionId)) return;
|
|
3540
|
+
if (next === void 0) this.deps.overrides.delete(childSessionId);
|
|
3541
|
+
else this.deps.overrides.set(childSessionId, next);
|
|
3542
|
+
const record = this.deps.localAgent.getDelegation(childSessionId);
|
|
3543
|
+
const delegation = record?.model?.trim();
|
|
3544
|
+
const spawnModel = next ?? (delegation === void 0 || delegation === "" ? void 0 : delegation) ?? this.deps.settingsModel(record?.scope);
|
|
3545
|
+
const host = this.deps.liveSwitch.hostingDriver(childSessionId);
|
|
3546
|
+
if (host !== void 0 && host.runtimeModel(childSessionId) !== spawnModel) await host.retireRuntime(childSessionId);
|
|
3166
3547
|
}
|
|
3167
3548
|
};
|
|
3168
3549
|
//#endregion
|
|
3169
|
-
//#region lib/types/
|
|
3550
|
+
//#region lib/types/records.js
|
|
3551
|
+
/**
|
|
3552
|
+
* Claude Code session records adapter: scans the scoped home's `projects/`
|
|
3553
|
+
* session files (one JSONL per session under `projects/<cwd-slug>/`, the
|
|
3554
|
+
* slug being the workspace path with separators replaced by dashes), plus
|
|
3555
|
+
* the transcript model read-back ({@link readClaudeTranscriptModel}) the
|
|
3556
|
+
* model broker's `lastObserved` backstop runs on. The
|
|
3557
|
+
* slug is a LOSSY encoding of the cwd — different paths can collide on the
|
|
3558
|
+
* same slug — so the listed cwd always comes from the file CONTENT (the
|
|
3559
|
+
* first `user` event's `cwd` field), never from the directory name. Only the
|
|
3560
|
+
* head of each file is read — the first user event carries the id, cwd, and
|
|
3561
|
+
* timestamp the listing needs; the rest of the file (conversation) is not
|
|
3562
|
+
* parsed. Files are append-only with torn-tail tolerance, and contents are
|
|
3563
|
+
* always untrusted input.
|
|
3564
|
+
*
|
|
3565
|
+
* Credentials are NOT this module's business: the harness authenticates by
|
|
3566
|
+
* endpoint key only (see endpoint-auth.ts), which needs no keychain read,
|
|
3567
|
+
* no `.credentials.json` reconcile, and no OAuth marker probe.
|
|
3568
|
+
* @module @khorsheed/dsh-local-agent-claude-code/records
|
|
3569
|
+
*/
|
|
3570
|
+
/** Session files live under this directory inside the scoped home. */
|
|
3571
|
+
const PROJECTS_ROOT = "projects";
|
|
3572
|
+
/** Bound on the head prefix read from one session file. */
|
|
3573
|
+
const HEAD_BYTES = 65536;
|
|
3574
|
+
/**
|
|
3575
|
+
* Bound on the tail read when extracting a transcript's model. Assistant
|
|
3576
|
+
* lines are frequent, so the last window almost always names one; a bigger
|
|
3577
|
+
* conversation is never read whole.
|
|
3578
|
+
*/
|
|
3579
|
+
const TRANSCRIPT_TAIL_BYTES = 262144;
|
|
3580
|
+
/** Cap on project directories the transcript reads scan. */
|
|
3581
|
+
const TRANSCRIPT_SCAN_DIRS = 200;
|
|
3582
|
+
/** Cap on transcript files the harness-level newest-scan stats. */
|
|
3583
|
+
const TRANSCRIPT_SCAN_FILES = 500;
|
|
3584
|
+
/** Cap on sidechain (`agent-*.jsonl`) candidates inspected for one session id. */
|
|
3585
|
+
const TRANSCRIPT_SIDECHAIN_FILES = 100;
|
|
3586
|
+
/** How many newest transcripts the harness-level read tries before giving up. */
|
|
3587
|
+
const TRANSCRIPT_NEWEST_TRIES = 3;
|
|
3170
3588
|
/**
|
|
3171
|
-
*
|
|
3172
|
-
*
|
|
3173
|
-
*
|
|
3174
|
-
*
|
|
3175
|
-
*
|
|
3176
|
-
* the
|
|
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
|
|
3589
|
+
* Parse the head of a session file for the listing fields. The first `user`
|
|
3590
|
+
* event carries `sessionId`, `cwd`, and `timestamp`; its `cwd` is the only
|
|
3591
|
+
* trustworthy workspace source (the directory slug is lossy). Unknown or
|
|
3592
|
+
* malformed values yield an undefined record rather than failing the list.
|
|
3593
|
+
* @param text - the raw session file head (only the head is read).
|
|
3594
|
+
* @returns the record, or undefined when no user event is found.
|
|
3182
3595
|
*/
|
|
3183
|
-
|
|
3184
|
-
const
|
|
3185
|
-
|
|
3186
|
-
|
|
3596
|
+
function projectRecord(text) {
|
|
3597
|
+
for (const raw of text.split("\n")) {
|
|
3598
|
+
const line = raw.trim();
|
|
3599
|
+
if (line === "") continue;
|
|
3600
|
+
let event;
|
|
3601
|
+
try {
|
|
3602
|
+
event = JSON.parse(line);
|
|
3603
|
+
} catch {
|
|
3604
|
+
continue;
|
|
3605
|
+
}
|
|
3606
|
+
if (event.type !== "user") continue;
|
|
3607
|
+
if (typeof event.sessionId !== "string" || event.sessionId === "") return void 0;
|
|
3608
|
+
if (typeof event.cwd !== "string") return void 0;
|
|
3609
|
+
const record = {
|
|
3610
|
+
id: event.sessionId,
|
|
3611
|
+
workDir: event.cwd
|
|
3612
|
+
};
|
|
3613
|
+
const startedAt = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : void 0;
|
|
3614
|
+
if (startedAt !== void 0 && Number.isFinite(startedAt)) record.startedAt = startedAt;
|
|
3615
|
+
return record;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3187
3618
|
/**
|
|
3188
|
-
*
|
|
3189
|
-
*
|
|
3190
|
-
*
|
|
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.
|
|
3619
|
+
* Read the scoped home's session files. The listing walks the projects
|
|
3620
|
+
* directory and reads each jsonl head; a torn or malformed file is skipped
|
|
3621
|
+
* rather than failing the whole list.
|
|
3196
3622
|
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3197
|
-
* @
|
|
3623
|
+
* @returns the parsed records, empty when no sessions exist yet.
|
|
3198
3624
|
*/
|
|
3199
|
-
async function
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
const path = join(homeDir, SCOPED_SETTINGS);
|
|
3203
|
-
let settings = {};
|
|
3625
|
+
async function listClaudeSessions(homeDir) {
|
|
3626
|
+
const records = [];
|
|
3627
|
+
let projectDirs;
|
|
3204
3628
|
try {
|
|
3205
|
-
|
|
3206
|
-
} catch {
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3629
|
+
projectDirs = await readdir(join(homeDir, PROJECTS_ROOT));
|
|
3630
|
+
} catch (error) {
|
|
3631
|
+
if (error.code === "ENOENT") return [];
|
|
3632
|
+
throw error;
|
|
3633
|
+
}
|
|
3634
|
+
for (const projectDir of projectDirs) {
|
|
3635
|
+
let files;
|
|
3636
|
+
try {
|
|
3637
|
+
files = await readdir(join(homeDir, PROJECTS_ROOT, projectDir));
|
|
3638
|
+
} catch {
|
|
3639
|
+
continue;
|
|
3640
|
+
}
|
|
3641
|
+
for (const name of files) {
|
|
3642
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
3643
|
+
try {
|
|
3644
|
+
const handle = await open(join(homeDir, PROJECTS_ROOT, projectDir, name), "r");
|
|
3645
|
+
let text;
|
|
3646
|
+
try {
|
|
3647
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
3648
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
3649
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3650
|
+
} finally {
|
|
3651
|
+
await handle.close();
|
|
3652
|
+
}
|
|
3653
|
+
const record = projectRecord(text);
|
|
3654
|
+
if (record !== void 0) records.push(record);
|
|
3655
|
+
} catch {
|
|
3656
|
+
continue;
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
return records;
|
|
3213
3661
|
}
|
|
3214
3662
|
/**
|
|
3215
|
-
*
|
|
3216
|
-
*
|
|
3217
|
-
*
|
|
3218
|
-
*
|
|
3219
|
-
*
|
|
3220
|
-
*
|
|
3221
|
-
*
|
|
3222
|
-
* @
|
|
3663
|
+
* Extract the model one transcript names, reading its TAIL (bounded, never
|
|
3664
|
+
* the whole conversation) from the newest line backward: the first line
|
|
3665
|
+
* carrying a model wins — the top-level `model` of the stream-json init
|
|
3666
|
+
* event's shape, or the nested `message.model` of an assistant line. Newest
|
|
3667
|
+
* first because a session's later rounds may run a different model than its
|
|
3668
|
+
* first, and "what ran last" is the question the caller asks. A torn final
|
|
3669
|
+
* line is skipped, never failed on.
|
|
3670
|
+
* @param path - the transcript file.
|
|
3671
|
+
* @returns the model identifier, or undefined when the window names none.
|
|
3223
3672
|
*/
|
|
3224
|
-
async function
|
|
3673
|
+
async function transcriptFileModel(path) {
|
|
3225
3674
|
let text;
|
|
3226
3675
|
try {
|
|
3227
|
-
|
|
3676
|
+
const handle = await open(path, "r");
|
|
3677
|
+
try {
|
|
3678
|
+
const { size } = await handle.stat();
|
|
3679
|
+
const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
3680
|
+
const buffer = Buffer.alloc(length);
|
|
3681
|
+
const { bytesRead } = await handle.read(buffer, 0, length, Math.max(0, size - length));
|
|
3682
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3683
|
+
} finally {
|
|
3684
|
+
await handle.close();
|
|
3685
|
+
}
|
|
3228
3686
|
} catch {
|
|
3229
3687
|
return;
|
|
3230
3688
|
}
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3689
|
+
const lines = text.split("\n");
|
|
3690
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
3691
|
+
const line = lines[index].trim();
|
|
3692
|
+
if (line === "") continue;
|
|
3693
|
+
let event;
|
|
3694
|
+
try {
|
|
3695
|
+
event = JSON.parse(line);
|
|
3696
|
+
} catch {
|
|
3697
|
+
continue;
|
|
3698
|
+
}
|
|
3699
|
+
if (typeof event.model === "string" && event.model !== "") return event.model;
|
|
3700
|
+
if (typeof event.message?.model === "string" && event.message.model !== "") return event.message.model;
|
|
3236
3701
|
}
|
|
3237
3702
|
}
|
|
3238
|
-
/**
|
|
3239
|
-
async function
|
|
3703
|
+
/** The project directories under one scoped home, capped; empty on any failure. */
|
|
3704
|
+
async function projectDirs(homeDir) {
|
|
3240
3705
|
try {
|
|
3241
|
-
|
|
3242
|
-
return typeof settings.effortLevel === "string" && settings.effortLevel.trim() !== "" ? settings.effortLevel.trim() : void 0;
|
|
3706
|
+
return (await readdir(join(homeDir, PROJECTS_ROOT))).slice(0, TRANSCRIPT_SCAN_DIRS);
|
|
3243
3707
|
} catch {
|
|
3244
|
-
return;
|
|
3708
|
+
return [];
|
|
3245
3709
|
}
|
|
3246
3710
|
}
|
|
3247
|
-
/**
|
|
3248
|
-
|
|
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 = {};
|
|
3711
|
+
/** The jsonl file names inside one project directory; empty on any failure. */
|
|
3712
|
+
async function projectFiles(homeDir, projectDir) {
|
|
3261
3713
|
try {
|
|
3262
|
-
|
|
3263
|
-
} catch {
|
|
3264
|
-
|
|
3265
|
-
if (!("model" in settings)) return;
|
|
3266
|
-
delete settings["model"];
|
|
3267
|
-
} else {
|
|
3268
|
-
if (settings["model"] === model) return;
|
|
3269
|
-
settings["model"] = model;
|
|
3714
|
+
return (await readdir(join(homeDir, PROJECTS_ROOT, projectDir))).filter((name) => name.endsWith(".jsonl"));
|
|
3715
|
+
} catch {
|
|
3716
|
+
return [];
|
|
3270
3717
|
}
|
|
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
3718
|
}
|
|
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
3719
|
/**
|
|
3314
|
-
*
|
|
3315
|
-
*
|
|
3316
|
-
*
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
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;
|
|
3720
|
+
* Whether a sidechain (`agent-*.jsonl`) transcript belongs to one session:
|
|
3721
|
+
* its head lines carry the PARENT session's id (`isSidechain: true`). Only
|
|
3722
|
+
* the bounded head is read.
|
|
3723
|
+
*/
|
|
3724
|
+
async function sidechainSessionMatches(path, cliSessionId) {
|
|
3725
|
+
let text;
|
|
3726
|
+
try {
|
|
3727
|
+
const handle = await open(path, "r");
|
|
3728
|
+
try {
|
|
3729
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
3730
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
3731
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3732
|
+
} finally {
|
|
3733
|
+
await handle.close();
|
|
3356
3734
|
}
|
|
3357
|
-
|
|
3735
|
+
} catch {
|
|
3736
|
+
return false;
|
|
3358
3737
|
}
|
|
3359
|
-
|
|
3360
|
-
const
|
|
3361
|
-
if (
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
}
|
|
3365
|
-
|
|
3366
|
-
if (this.written === void 0) return;
|
|
3367
|
-
await writeClaudeScopedModel(homeDir, this.previous ?? void 0);
|
|
3368
|
-
this.written = void 0;
|
|
3369
|
-
return;
|
|
3738
|
+
for (const raw of text.split("\n")) {
|
|
3739
|
+
const line = raw.trim();
|
|
3740
|
+
if (line === "") continue;
|
|
3741
|
+
try {
|
|
3742
|
+
if (JSON.parse(line).sessionId === cliSessionId) return true;
|
|
3743
|
+
} catch {
|
|
3744
|
+
continue;
|
|
3370
3745
|
}
|
|
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
3746
|
}
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
};
|
|
3415
|
-
await retire();
|
|
3416
|
-
return {
|
|
3417
|
-
active: false,
|
|
3418
|
-
matches: "current",
|
|
3419
|
-
resolved: await resolve(state.current.selection)
|
|
3420
|
-
};
|
|
3747
|
+
return false;
|
|
3748
|
+
}
|
|
3749
|
+
/**
|
|
3750
|
+
* The model a member's OWN CLI transcript names — the history backstop for
|
|
3751
|
+
* the broker's `lastObserved` when no delegation record observed one (every
|
|
3752
|
+
* member whose rounds predate the live-settle report). The live-settle
|
|
3753
|
+
* channel remains primary (the record's `observedModel` wins when present);
|
|
3754
|
+
* this reader only answers "what did this session last run" from the CLI's
|
|
3755
|
+
* own files, read-only — nothing is backfilled into `delegations.jsonl`.
|
|
3756
|
+
*
|
|
3757
|
+
* Location rule: with `cliSessionId`, the main transcript
|
|
3758
|
+
* `projects/<cwd-slug>/<cliSessionId>.jsonl` (found by NAME across the
|
|
3759
|
+
* project dirs — the slug is lossy, so it is never computed); when it is
|
|
3760
|
+
* absent, a sidechain `agent-*.jsonl` whose head names the session id
|
|
3761
|
+
* (bounded candidate count) answers instead. Without `cliSessionId` (the
|
|
3762
|
+
* settings card's memberless read), the NEWEST transcript across the
|
|
3763
|
+
* projects tree (mtime, bounded dir/file scan) answers; a newest file that
|
|
3764
|
+
* names no model yields to the next, up to a few tries. Every failure —
|
|
3765
|
+
* missing tree, unreadable file, no model in the read window — is
|
|
3766
|
+
* `undefined`, never a throw.
|
|
3767
|
+
* @param homeDir - the `claude-code` harness's scoped home.
|
|
3768
|
+
* @param cliSessionId - the delegation record's CLI session id, when the
|
|
3769
|
+
* member's own transcript is wanted.
|
|
3770
|
+
* @returns the model identifier, or undefined.
|
|
3771
|
+
*/
|
|
3772
|
+
async function readClaudeTranscriptModel(homeDir, cliSessionId) {
|
|
3773
|
+
if (cliSessionId !== void 0) {
|
|
3774
|
+
let main;
|
|
3775
|
+
const sidechains = [];
|
|
3776
|
+
for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
|
|
3777
|
+
if (name === `${cliSessionId}.jsonl` && main === void 0) {
|
|
3778
|
+
main = join(homeDir, PROJECTS_ROOT, projectDir, name);
|
|
3779
|
+
continue;
|
|
3421
3780
|
}
|
|
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;
|
|
3781
|
+
if (name.startsWith("agent-") && sidechains.length < TRANSCRIPT_SIDECHAIN_FILES) sidechains.push(join(homeDir, PROJECTS_ROOT, projectDir, name));
|
|
3439
3782
|
}
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
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;
|
|
3783
|
+
if (main !== void 0) {
|
|
3784
|
+
const model = await transcriptFileModel(main);
|
|
3785
|
+
if (model !== void 0) return model;
|
|
3786
|
+
}
|
|
3787
|
+
for (const path of sidechains) if (await sidechainSessionMatches(path, cliSessionId)) {
|
|
3788
|
+
const model = await transcriptFileModel(path);
|
|
3789
|
+
if (model !== void 0) return model;
|
|
3790
|
+
}
|
|
3791
|
+
return;
|
|
3467
3792
|
}
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
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
|
-
};
|
|
3793
|
+
const candidates = [];
|
|
3794
|
+
let scanned = 0;
|
|
3795
|
+
for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
|
|
3796
|
+
if (scanned >= TRANSCRIPT_SCAN_FILES) break;
|
|
3797
|
+
scanned += 1;
|
|
3798
|
+
const path = join(homeDir, PROJECTS_ROOT, projectDir, name);
|
|
3799
|
+
try {
|
|
3800
|
+
candidates.push({
|
|
3801
|
+
path,
|
|
3802
|
+
mtimeMs: (await stat(path)).mtimeMs
|
|
3803
|
+
});
|
|
3804
|
+
} catch {
|
|
3805
|
+
continue;
|
|
3806
|
+
}
|
|
3502
3807
|
}
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
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);
|
|
3808
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
3809
|
+
for (const candidate of candidates.slice(0, TRANSCRIPT_NEWEST_TRIES)) {
|
|
3810
|
+
const model = await transcriptFileModel(candidate.path);
|
|
3811
|
+
if (model !== void 0) return model;
|
|
3522
3812
|
}
|
|
3523
|
-
}
|
|
3813
|
+
}
|
|
3524
3814
|
//#endregion
|
|
3525
3815
|
//#region lib/types/index.js
|
|
3526
3816
|
/**
|
|
3527
3817
|
* Claude Code harness instantiation of the local-agent family. Registers the
|
|
3528
3818
|
* `claude-code` harness into the core registry: scoped home under the shared
|
|
3529
|
-
* homes root (`CLAUDE_CONFIG_DIR`),
|
|
3530
|
-
*
|
|
3531
|
-
*
|
|
3532
|
-
* session records. Delegation mounts through
|
|
3533
|
-
* (one-shot `claude -p
|
|
3534
|
-
* bundle patch's `tool-subagent-claude-code-local` row puts the tool on
|
|
3535
|
-
* profile root, so every agent preset can delegate.
|
|
3819
|
+
* homes root (`CLAUDE_CONFIG_DIR`), endpoint-key authentication (the scope's
|
|
3820
|
+
* `endpoint-auth.json` over the plugin config's `baseUrl`/`apiKey`/
|
|
3821
|
+
* `authToken` triple — no OAuth, no browser flow, nothing to authorize inside
|
|
3822
|
+
* a container), and project-file session records. Delegation mounts through
|
|
3823
|
+
* the `claude-local` provider (one-shot `claude -p` under the scoped home);
|
|
3824
|
+
* the bundle patch's `tool-subagent-claude-code-local` row puts the tool on
|
|
3825
|
+
* the profile root, so every agent preset can delegate.
|
|
3536
3826
|
*
|
|
3537
3827
|
* @module @khorsheed/dsh-local-agent-claude-code
|
|
3538
3828
|
*/
|
|
@@ -3547,7 +3837,8 @@ const inject = [
|
|
|
3547
3837
|
];
|
|
3548
3838
|
/**
|
|
3549
3839
|
* The settings fields (`live`, `liveMirrorGranularity`, `model`,
|
|
3550
|
-
* `recentModels
|
|
3840
|
+
* `recentModels`, and the endpoint credential `baseUrl`/`apiKey`/`authToken`)
|
|
3841
|
+
* ride the plugin Config, marked `.volatile()` where the
|
|
3551
3842
|
* host's schemastery has it (3.18.4+, the rc.1 line — probed, never sniffed):
|
|
3552
3843
|
* there the SettingsForms writes hot-track the running fiber's Volatile
|
|
3553
3844
|
* references without a remount, and the official one-shot import moves an old
|
|
@@ -3572,6 +3863,8 @@ const Config = z.object({
|
|
|
3572
3863
|
permissionMode: z.union([z.const("skip"), z.const("normal")]),
|
|
3573
3864
|
model: volatilize(SETTINGS_FIELDS.model),
|
|
3574
3865
|
baseUrl: z.string(),
|
|
3866
|
+
apiKey: z.string(),
|
|
3867
|
+
authToken: z.string(),
|
|
3575
3868
|
proxyUrl: z.string(),
|
|
3576
3869
|
live: volatilize(SETTINGS_FIELDS.live),
|
|
3577
3870
|
liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
|
|
@@ -3599,7 +3892,6 @@ const CLAUDE_SETTINGS_SCHEMA = z.object(SETTINGS_FIELDS);
|
|
|
3599
3892
|
function apply(ctx, config) {
|
|
3600
3893
|
ctx.effect(() => {
|
|
3601
3894
|
const permissionMode = config.permissionMode ?? "skip";
|
|
3602
|
-
const baseUrl = config.baseUrl;
|
|
3603
3895
|
const homeDir = ctx.localAgent.homeDir("claude-code");
|
|
3604
3896
|
provisionClaudeHome(homeDir, config.proxyUrl).catch((error) => {
|
|
3605
3897
|
ctx.logger.warn(`local-agent-claude-code: scoped home provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -3629,10 +3921,17 @@ function apply(ctx, config) {
|
|
|
3629
3921
|
legacyBase,
|
|
3630
3922
|
read: readSettings
|
|
3631
3923
|
});
|
|
3632
|
-
const
|
|
3924
|
+
const endpointConfig = {
|
|
3925
|
+
...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
|
|
3926
|
+
...config.apiKey === void 0 ? {} : { apiKey: config.apiKey },
|
|
3927
|
+
...config.authToken === void 0 ? {} : { authToken: config.authToken }
|
|
3928
|
+
};
|
|
3929
|
+
const resolveEndpoint = (scopeHome) => resolveEndpointAuth(scopeHome, endpointConfig);
|
|
3930
|
+
const settingsModelKey = () => {
|
|
3633
3931
|
const model = face.get().model?.trim();
|
|
3634
3932
|
return model === void 0 || model === "" ? void 0 : model;
|
|
3635
3933
|
};
|
|
3934
|
+
const resolveModel = (scope) => firstModelOf(readModelListSync(ctx.localAgent.homeDir("claude-code", scope))) ?? settingsModelKey();
|
|
3636
3935
|
const overrides = /* @__PURE__ */ new Map();
|
|
3637
3936
|
const scopedModelMemory = new ClaudeScopedModelMemory();
|
|
3638
3937
|
const effectiveModel = (childSessionId, delegationModel) => {
|
|
@@ -3640,18 +3939,19 @@ function apply(ctx, config) {
|
|
|
3640
3939
|
if (own !== void 0 && own !== "") return own;
|
|
3641
3940
|
const named = delegationModel?.trim();
|
|
3642
3941
|
if (named !== void 0 && named !== "") return named;
|
|
3643
|
-
|
|
3942
|
+
const record = ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId);
|
|
3943
|
+
return resolveModel(record?.scope);
|
|
3644
3944
|
};
|
|
3645
3945
|
const liveSwitch = new LiveDriverSwitch(ctx, face, {
|
|
3646
3946
|
...config.permissionMode === void 0 ? {} : { permissionMode: config.permissionMode },
|
|
3647
|
-
|
|
3947
|
+
endpoint: resolveEndpoint,
|
|
3648
3948
|
...config.liveIdleMs === void 0 ? {} : { liveIdleMs: config.liveIdleMs },
|
|
3649
3949
|
model: effectiveModel,
|
|
3650
3950
|
provisionModel: (home, model) => scopedModelMemory.provision(home, model)
|
|
3651
3951
|
});
|
|
3652
3952
|
const modelCatalog = new ClaudeModelCatalog({
|
|
3653
|
-
|
|
3654
|
-
|
|
3953
|
+
endpoint: resolveEndpoint,
|
|
3954
|
+
endpointLabel: () => config.baseUrl,
|
|
3655
3955
|
warn: (message) => ctx.logger.warn(message)
|
|
3656
3956
|
});
|
|
3657
3957
|
const modelContext = (childSessionId) => {
|
|
@@ -3671,6 +3971,7 @@ function apply(ctx, config) {
|
|
|
3671
3971
|
};
|
|
3672
3972
|
},
|
|
3673
3973
|
settingsModel: resolveModel,
|
|
3974
|
+
modelList: (scope) => readModelListSync(ctx.localAgent.homeDir("claude-code", scope)),
|
|
3674
3975
|
cliDefault: (childSessionId) => {
|
|
3675
3976
|
const context = modelContext(childSessionId);
|
|
3676
3977
|
return context.home === homeDir ? scopedModelMemory.cliDefault(homeDir) : readClaudeConfiguredModel(context.home);
|
|
@@ -3689,7 +3990,7 @@ function apply(ctx, config) {
|
|
|
3689
3990
|
liveSwitch,
|
|
3690
3991
|
transcriptModel: (cliSessionId, childSessionId) => readClaudeTranscriptModel(modelContext(childSessionId).home, cliSessionId)
|
|
3691
3992
|
});
|
|
3692
|
-
const disposeProvider = ctx.subagents.registerProvider(new ClaudeCliProvider(ctx, permissionMode,
|
|
3993
|
+
const disposeProvider = ctx.subagents.registerProvider(new ClaudeCliProvider(ctx, permissionMode, resolveEndpoint, liveSwitch.resolve, effectiveModel));
|
|
3693
3994
|
const disposeHarness = ctx.localAgent.register({
|
|
3694
3995
|
prepareMember: async ({ binding, childSession, configuration, signal }) => {
|
|
3695
3996
|
const driver = liveSwitch.resolve(binding.childSessionId);
|
|
@@ -3706,46 +4007,53 @@ function apply(ctx, config) {
|
|
|
3706
4007
|
displayName: "Claude Code",
|
|
3707
4008
|
homeEnvVar: "CLAUDE_CONFIG_DIR",
|
|
3708
4009
|
delegationProvider: "claude-local",
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
]
|
|
4010
|
+
records: { listSessions: (homeDir) => listClaudeSessions(homeDir) },
|
|
4011
|
+
provision: (scopedHome) => provisionClaudeHome(scopedHome, config.proxyUrl).then(() => {}),
|
|
4012
|
+
isAuthenticated: (home) => resolveEndpoint(home).then((endpoint) => hasEndpointCredential(endpoint)),
|
|
4013
|
+
checkEndpoint: (home) => resolveEndpoint(home).then((endpoint) => checkClaudeEndpoint(endpoint)),
|
|
4014
|
+
endpointAuth: {
|
|
4015
|
+
read: async (home) => {
|
|
4016
|
+
const endpoint = await resolveEndpoint(home);
|
|
4017
|
+
return {
|
|
4018
|
+
...endpoint.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
|
|
4019
|
+
apiKeySet: endpoint.apiKey !== void 0,
|
|
4020
|
+
authTokenSet: endpoint.authToken !== void 0
|
|
4021
|
+
};
|
|
3722
4022
|
},
|
|
3723
|
-
|
|
3724
|
-
await
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
4023
|
+
write: async (home, fields) => {
|
|
4024
|
+
const next = { ...await readEndpointAuthFile(home) ?? {} };
|
|
4025
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
4026
|
+
if (value === void 0) continue;
|
|
4027
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
4028
|
+
if (value === null || trimmed === "") delete next[field];
|
|
4029
|
+
else next[field] = trimmed;
|
|
4030
|
+
}
|
|
4031
|
+
if (Object.keys(next).length === 0) await clearEndpointAuth(home);
|
|
4032
|
+
else await writeEndpointAuth(home, next);
|
|
3730
4033
|
}
|
|
3731
4034
|
},
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
4035
|
+
credentialStamp: endpointAuthStamp,
|
|
4036
|
+
logout: async (home) => {
|
|
4037
|
+
if (await endpointAuthFileExists(home)) {
|
|
4038
|
+
await clearEndpointAuth(home);
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
throw new Error("claude-code has no scoped endpoint-auth.json to clear — the credential comes from the settings card or the profile row (baseUrl/apiKey/authToken); clear it there instead");
|
|
4042
|
+
},
|
|
3739
4043
|
modelBroker,
|
|
4044
|
+
modelList: {
|
|
4045
|
+
read: (home) => readModelList(home),
|
|
4046
|
+
write: (home, models) => writeModelList(home, models)
|
|
4047
|
+
},
|
|
3740
4048
|
effectiveSettings: async (scopedHome) => {
|
|
3741
|
-
const
|
|
3742
|
-
const baseUrlHost =
|
|
4049
|
+
const endpoint = await resolveEndpoint(scopedHome);
|
|
4050
|
+
const baseUrlHost = endpoint.baseUrl !== void 0 ? endpointHost(endpoint.baseUrl) : void 0;
|
|
3743
4051
|
const [scopedModel, cliVersion] = await Promise.all([readClaudeConfiguredModel(scopedHome).catch(() => void 0), claudeCliVersion(ctx, scopedHome).catch(() => void 0)]);
|
|
3744
4052
|
const model = resolveModel() ?? scopedModel;
|
|
3745
4053
|
return {
|
|
3746
4054
|
drive: face.get().live ? "live" : "exec",
|
|
3747
4055
|
permissionMode,
|
|
3748
|
-
baseUrlSet:
|
|
4056
|
+
baseUrlSet: endpoint.baseUrl !== void 0,
|
|
3749
4057
|
...baseUrlHost !== void 0 ? { baseUrlHost } : {},
|
|
3750
4058
|
...model !== void 0 ? { model } : {},
|
|
3751
4059
|
...cliVersion !== void 0 ? { cliVersion } : {}
|
|
@@ -3757,6 +4065,13 @@ function apply(ctx, config) {
|
|
|
3757
4065
|
kind: "success",
|
|
3758
4066
|
text: records.length === 0 ? "No claude sessions in the scoped home yet." : records.map((r) => `${r.id} ${r.workDir}`).join("\n")
|
|
3759
4067
|
}));
|
|
4068
|
+
if (verb === "check") {
|
|
4069
|
+
const scopeHome = invocation.agent.ctx.localAgent.homeDir("claude-code");
|
|
4070
|
+
return resolveEndpoint(scopeHome).then((endpoint) => checkClaudeEndpoint(endpoint)).then((text) => ({
|
|
4071
|
+
kind: "success",
|
|
4072
|
+
text
|
|
4073
|
+
}));
|
|
4074
|
+
}
|
|
3760
4075
|
}
|
|
3761
4076
|
});
|
|
3762
4077
|
return () => {
|