@khorsheed/dsh-local-agent-claude-code 0.2.0-rc.1 → 0.2.0-rc.3

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/lib/index.js CHANGED
@@ -1,87 +1,297 @@
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 { createHash, randomUUID } from "node:crypto";
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 { dirname, join } from "node:path";
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 { promisify } from "node:util";
13
- //#region lib/types/control-requests.js
14
- /** Bounded, correlated Claude stream-json controls, shared by discovery and live sessions. */
15
- var ClaudeControlRequests = class {
16
- send;
17
- timeoutMs;
18
- sequence = 0;
19
- closed = false;
20
- pending = /* @__PURE__ */ new Map();
21
- constructor(send, timeoutMs = 5e3) {
22
- this.send = send;
23
- this.timeoutMs = timeoutMs;
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
- accept(event) {
26
- if (event["type"] !== "control_response") return false;
27
- const response = event["response"];
28
- const id = response?.["request_id"];
29
- if (typeof id !== "string") return true;
30
- const pending = this.pending.get(id);
31
- if (pending === void 0) return true;
32
- if (response?.["subtype"] !== "success") pending.reject(new Error(typeof response?.["error"] === "string" ? response["error"] : "Claude rejected the control request"));
33
- else {
34
- const result = response["response"];
35
- pending.resolve(typeof result === "object" && result !== null ? result : {});
36
- }
37
- return true;
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
- request(request, signal) {
40
- if (this.closed || signal?.aborted) return Promise.reject(/* @__PURE__ */ new Error("Claude control channel is closed"));
41
- const id = `dsh-control-${++this.sequence}`;
42
- return new Promise((resolve, reject) => {
43
- const finish = () => {
44
- this.pending.delete(id);
45
- clearTimeout(timer);
46
- signal?.removeEventListener("abort", abort);
47
- };
48
- const fail = (error) => {
49
- finish();
50
- reject(error);
51
- };
52
- const abort = () => {
53
- fail(/* @__PURE__ */ new Error("Claude control request cancelled"));
54
- };
55
- const timer = setTimeout(() => fail(/* @__PURE__ */ new Error(`Claude ${String(request["subtype"])} control timed out`)), this.timeoutMs);
56
- timer.unref();
57
- this.pending.set(id, {
58
- resolve: (value) => {
59
- finish();
60
- resolve(value);
61
- },
62
- reject: fail
63
- });
64
- signal?.addEventListener("abort", abort, { once: true });
65
- try {
66
- this.send({
67
- type: "control_request",
68
- request_id: id,
69
- request
70
- });
71
- } catch (error) {
72
- fail(error instanceof Error ? error : /* @__PURE__ */ new Error("Claude control write failed"));
73
- }
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
+ * `GET <baseUrl>/v1/models` with the resolved credential's auth header(s) —
196
+ * the token-free endpoint probe shared by the `/claude-code check` verdict
197
+ * and the model catalog. Anthropic's own API serves this route with
198
+ * `x-api-key`; relays (openproxy verified) proxy it and answer the same
199
+ * shape. Never throws: every failure mode folds into the result.
200
+ * @param endpoint - the resolved endpoint auth (caller checks the credential).
201
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
202
+ * @param signal - abort for the whole probe (timeout bundled by the caller).
203
+ * @returns the folded result.
204
+ */
205
+ async function fetchEndpointModels(endpoint, fetchImpl, signal) {
206
+ const headers = { "anthropic-version": "2023-06-01" };
207
+ if (endpoint.apiKey !== void 0) headers["x-api-key"] = endpoint.apiKey;
208
+ if (endpoint.authToken !== void 0) headers["authorization"] = `Bearer ${endpoint.authToken}`;
209
+ const fetcher = fetchImpl ?? globalThis.fetch;
210
+ const url = `${endpoint.baseUrl.replace(/\/+$/, "")}/v1/models`;
211
+ let response;
212
+ try {
213
+ response = await fetcher(url, {
214
+ headers,
215
+ signal: signal ?? AbortSignal.timeout(8e3)
74
216
  });
217
+ } catch (error) {
218
+ return {
219
+ kind: "unreachable",
220
+ message: error instanceof Error ? error.message : String(error)
221
+ };
75
222
  }
76
- close() {
77
- if (this.closed) return;
78
- this.closed = true;
79
- for (const request of [...this.pending.values()]) request.reject(/* @__PURE__ */ new Error("Claude control channel closed before acknowledgement"));
223
+ if (!response.ok) return {
224
+ kind: "http",
225
+ status: response.status,
226
+ body: (await response.text().catch(() => "")).slice(0, CHECK_ERROR_BODY_LIMIT)
227
+ };
228
+ let data;
229
+ try {
230
+ data = JSON.parse(await response.text()).data;
231
+ } catch {
232
+ return { kind: "malformed" };
233
+ }
234
+ if (!Array.isArray(data)) return { kind: "malformed" };
235
+ const models = [];
236
+ for (const entry of data) {
237
+ if (typeof entry !== "object" || entry === null) continue;
238
+ const record = entry;
239
+ if (typeof record.id !== "string" || record.id === "") continue;
240
+ const efforts = servedEfforts(record.effort);
241
+ models.push({
242
+ id: record.id,
243
+ ...typeof record.display_name === "string" && record.display_name !== "" ? { displayName: record.display_name } : {},
244
+ ...efforts === void 0 ? {} : { efforts }
245
+ });
80
246
  }
81
- };
247
+ return {
248
+ kind: "models",
249
+ models
250
+ };
251
+ }
252
+ /**
253
+ * Probe the resolved endpoint with a cheap, token-free round-trip: `GET
254
+ * <baseUrl>/v1/models` with the resolved credential's auth header(s). A 200
255
+ * proves both reachability and credential validity — the exact evidence a
256
+ * "verify" button needs — and the served model list rides the reply so the
257
+ * user sees what the endpoint offers (any id can be typed into the model
258
+ * field by hand). One transport-level retry rides along: a local proxy
259
+ * restart (a Surge/clash reload) stalls the first attempt into the timeout
260
+ * and answers the retry immediately. Never throws: every failure mode folds
261
+ * into the reply text, since the reply IS the diagnostic.
262
+ * @param endpoint - the resolved endpoint auth (scope file over settings).
263
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
264
+ * @param timeoutMs - one attempt's deadline (two attempts at most).
265
+ * @returns the human-readable verdict for the `/claude-code check` reply.
266
+ */
267
+ async function checkClaudeEndpoint(endpoint, fetchImpl, timeoutMs = 2e4) {
268
+ if (!hasEndpointCredential(endpoint)) return "No endpoint credential configured — fill API Key or Auth Token on the settings card (or the profile row) first.";
269
+ const baseUrl = (endpoint.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
270
+ let result = await fetchEndpointModels({
271
+ ...endpoint,
272
+ baseUrl
273
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
274
+ if (result.kind === "unreachable") result = await fetchEndpointModels({
275
+ ...endpoint,
276
+ baseUrl
277
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
278
+ if (result.kind === "unreachable") return `Endpoint unreachable (${baseUrl}): ${result.message}`;
279
+ if (result.kind === "http") return `Endpoint answered HTTP ${String(result.status)} (${baseUrl})${result.body === "" ? "" : `: ${result.body}`}`;
280
+ 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.`;
281
+ if (result.models.length === 0) return `Endpoint reachable (${baseUrl}); it serves no models.`;
282
+ const ids = result.models.map((model) => model.id);
283
+ const listed = ids.slice(0, CHECK_MODEL_LIST_LIMIT).join(", ");
284
+ const more = ids.length > CHECK_MODEL_LIST_LIMIT ? `, … +${String(ids.length - CHECK_MODEL_LIST_LIMIT)} more` : "";
285
+ return `Endpoint reachable (${baseUrl}): ${String(ids.length)} model(s) served — ${listed}${more}`;
286
+ }
82
287
  //#endregion
83
288
  //#region lib/types/model-catalog.js
84
- /** Initialize's native picker vocabulary, not an account entitlement claim. */
289
+ /**
290
+ * Fold the CLI's own `initialize` model vocabulary. Consumed by the LIVE
291
+ * DRIVER's runtime init handling; the catalog no longer reads this — a
292
+ * local list cannot prove anything about the configured endpoint, and
293
+ * showing it made an invalid credential look valid.
294
+ */
85
295
  function claudeDirectory(result) {
86
296
  const models = result["models"];
87
297
  if (models === void 0) return {
@@ -119,7 +329,33 @@ function claudeDirectory(result) {
119
329
  ...entries.has("default") ? { defaultModel: "default" } : {}
120
330
  };
121
331
  }
122
- /** Scoped native control probe. Never syncs credentials, writes settings or submits a user turn. */
332
+ /** One directory answer that names why it is empty. */
333
+ function unsupported(reason) {
334
+ return {
335
+ entries: [],
336
+ complete: false,
337
+ customInput: true,
338
+ unsupported: true,
339
+ reason
340
+ };
341
+ }
342
+ /**
343
+ * The model directory, sourced from the configured ENDPOINT (`GET
344
+ * <baseUrl>/v1/models` with the resolved credential) — never from the local
345
+ * CLI: what the picker lists is exactly what the endpoint serves, so a
346
+ * credential that does not work can never hide behind a locally-known list.
347
+ * The answers are deliberate:
348
+ *
349
+ * - no credential resolved → empty with a "save a key first" reason;
350
+ * - 401/403 → empty with the rejection (definitive — cached as data);
351
+ * - another HTTP status → empty with it (the relay serves no model route);
352
+ * - a transport failure THROWS, so the cache keeps the last good list and
353
+ * retries on schedule instead of blanking a momentarily-down endpoint.
354
+ *
355
+ * Effort levels ride the served entries' `effort` capability when the
356
+ * endpoint advertises them. Entries carry `source: 'native'` — they are the
357
+ * harness's native vocabulary, just read from the endpoint in force.
358
+ */
123
359
  var ClaudeModelCatalog = class {
124
360
  deps;
125
361
  cache;
@@ -135,22 +371,15 @@ var ClaudeModelCatalog = class {
135
371
  });
136
372
  }
137
373
  key(homeDir, cwd = homeDir) {
138
- const files = [
139
- join(homeDir, "settings.json"),
140
- join(homeDir, ".credentials.json"),
141
- join(homeDir, ".claude.json")
142
- ];
143
- for (let directory = cwd;; directory = dirname(directory)) {
144
- files.push(join(directory, ".claude", "settings.json"), join(directory, ".claude", "settings.local.json"));
145
- if (dirname(directory) === directory) break;
146
- }
374
+ const files = [join(homeDir, "settings.json"), join(homeDir, "endpoint-auth.json")];
375
+ const endpointLabel = this.deps.endpointLabel?.();
147
376
  return modelDirectoryContextKey({
148
377
  provider: "claude-code",
149
378
  homeDir,
150
379
  cwd,
151
- cli: [this.deps.cli ?? "claude"],
380
+ cli: [],
152
381
  files,
153
- ...this.deps.baseUrl === void 0 ? {} : { endpoint: this.deps.baseUrl }
382
+ ...endpointLabel === void 0 ? {} : { endpoint: endpointLabel }
154
383
  });
155
384
  }
156
385
  read(homeDir, cwd) {
@@ -168,977 +397,403 @@ var ClaudeModelCatalog = class {
168
397
  dispose() {
169
398
  this.cache.dispose();
170
399
  }
171
- async probe(homeDir, cwd, signal) {
172
- const child = this.deps.spawn({
173
- argv: [
174
- this.deps.cli ?? "claude",
175
- "-p",
176
- "--verbose",
177
- "--input-format",
178
- "stream-json",
179
- "--output-format",
180
- "stream-json",
181
- "--no-session-persistence",
182
- "--settings",
183
- "{\"disableAllHooks\":true}",
184
- "--strict-mcp-config",
185
- "--mcp-config",
186
- "{\"mcpServers\":{}}",
187
- "--"
188
- ],
189
- cwd,
190
- stdio: {
191
- stdin: "pipe",
192
- stdout: "pipe",
193
- stderr: "pipe"
194
- },
195
- graceMs: 2e3,
196
- env: delegationEnv({
197
- CLAUDE_CONFIG_DIR: homeDir,
198
- ...this.deps.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.deps.baseUrl }
199
- })
200
- });
201
- const controls = new ClaudeControlRequests((frame) => {
202
- if (child.stdin === void 0) throw new Error("Claude discovery stdin is unavailable");
203
- child.stdin.write(JSON.stringify(frame) + "\n");
204
- }, this.deps.timeoutMs ?? 5e3);
205
- const decoder = new StringDecoder("utf8");
206
- let buffer = "";
207
- child.stdout?.on("data", (chunk) => {
208
- buffer += decoder.write(chunk);
209
- if (buffer.length > 1048576) {
210
- controls.close();
211
- child.terminate();
212
- return;
213
- }
214
- let index;
215
- while ((index = buffer.indexOf("\n")) >= 0) {
216
- const line = buffer.slice(0, index);
217
- buffer = buffer.slice(index + 1);
218
- let event;
219
- try {
220
- event = JSON.parse(line);
221
- } catch {
222
- continue;
223
- }
224
- if (controls.accept(event)) continue;
225
- if (event["type"] === "control_request" && typeof event["request_id"] === "string") child.stdin?.write(JSON.stringify({
226
- type: "control_response",
227
- response: {
228
- subtype: "error",
229
- request_id: event["request_id"],
230
- error: "Model discovery does not execute tools"
231
- }
232
- }) + "\n");
233
- }
234
- });
235
- child.stderr?.on("data", () => {});
236
- child.done.then(() => controls.close(), () => controls.close());
237
- let cleanupTimer;
238
- try {
239
- return claudeDirectory(await controls.request({
240
- subtype: "initialize",
241
- hooks: {}
242
- }, signal));
243
- } finally {
244
- controls.close();
245
- child.stdin?.end();
246
- await Promise.race([child.done.catch(() => {}), new Promise((resolve) => {
247
- cleanupTimer = setTimeout(resolve, 1e3);
248
- cleanupTimer.unref();
249
- })]);
250
- if (cleanupTimer !== void 0) clearTimeout(cleanupTimer);
251
- child.terminate();
252
- await child.waitForExit().catch(() => {});
400
+ async probe(homeDir, _cwd, signal) {
401
+ const endpoint = await this.deps.endpoint?.(homeDir);
402
+ 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");
403
+ const result = await fetchEndpointModels({
404
+ ...endpoint,
405
+ baseUrl: endpoint.baseUrl ?? "https://api.anthropic.com"
406
+ }, this.deps.fetchImpl, AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? 2e4)]));
407
+ if (result.kind === "unreachable") throw new Error(`endpoint unreachable: ${result.message}`);
408
+ if (result.kind === "malformed") return unsupported("The endpoint answered but the model list did not parse (not /v1/models-shaped)");
409
+ if (result.kind === "http") {
410
+ 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`);
411
+ return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
253
412
  }
413
+ const entries = result.models.map((model) => ({
414
+ value: model.id,
415
+ label: model.displayName ?? model.id,
416
+ source: "native",
417
+ ...model.efforts === void 0 ? {} : { reasoning: { options: model.efforts.map((effort) => ({
418
+ value: effort,
419
+ label: effort
420
+ })) } }
421
+ }));
422
+ if (entries.length === 0) return unsupported("The endpoint serves no models");
423
+ return {
424
+ entries,
425
+ complete: true,
426
+ customInput: true
427
+ };
254
428
  }
255
429
  };
256
430
  //#endregion
257
- //#region lib/types/records.js
258
- /**
259
- * Claude Code session records adapter: scans the scoped home's `projects/`
260
- * session files (one JSONL per session under `projects/<cwd-slug>/`, the
261
- * slug being the workspace path with separators replaced by dashes), plus
262
- * the transcript model read-back ({@link readClaudeTranscriptModel}) the
263
- * model broker's `lastObserved` backstop runs on. The
264
- * slug is a LOSSY encoding of the cwd — different paths can collide on the
265
- * same slug — so the listed cwd always comes from the file CONTENT (the
266
- * first `user` event's `cwd` field), never from the directory name. Only the
267
- * head of each file is read — the first user event carries the id, cwd, and
268
- * timestamp the listing needs; the rest of the file (conversation) is not
269
- * parsed. Files are append-only with torn-tail tolerance, and contents are
270
- * always untrusted input.
271
- * @module @khorsheed/dsh-local-agent-claude-code/records
272
- */
273
- /** Session files live under this directory inside the scoped home. */
274
- const PROJECTS_ROOT = "projects";
275
- /** Bound on the head prefix read from one session file. */
276
- const HEAD_BYTES = 65536;
277
- /**
278
- * Bound on the tail read when extracting a transcript's model. Assistant
279
- * lines are frequent, so the last window almost always names one; a bigger
280
- * conversation is never read whole.
281
- */
282
- const TRANSCRIPT_TAIL_BYTES = 262144;
283
- /** Cap on project directories the transcript reads scan. */
284
- const TRANSCRIPT_SCAN_DIRS = 200;
285
- /** Cap on transcript files the harness-level newest-scan stats. */
286
- const TRANSCRIPT_SCAN_FILES = 500;
287
- /** Cap on sidechain (`agent-*.jsonl`) candidates inspected for one session id. */
288
- const TRANSCRIPT_SIDECHAIN_FILES = 100;
289
- /** How many newest transcripts the harness-level read tries before giving up. */
290
- const TRANSCRIPT_NEWEST_TRIES = 3;
291
- /** Test-swappable process exec (the keychain read goes through `security`). */
292
- const internals = { exec: promisify(execFile) };
293
- /**
294
- * The scoped `.claude.json`'s modification stamp (epoch ms), undefined when
295
- * absent. A completed login rewrites this file, so the stamp distinguishes a
296
- * fresh login from a leftover marker (a revoked token's record lingers).
297
- * @param homeDir - the `claude-code` harness's scoped home.
298
- * @returns the marker's mtime, or undefined when no scoped config exists.
299
- */
300
- async function claudeCredentialStamp(homeDir) {
301
- try {
302
- return (await stat(join(homeDir, ".claude.json"))).mtimeMs;
303
- } catch {
304
- return;
431
+ //#region lib/types/control-requests.js
432
+ /** Bounded, correlated Claude stream-json controls, shared by discovery and live sessions. */
433
+ var ClaudeControlRequests = class {
434
+ send;
435
+ timeoutMs;
436
+ sequence = 0;
437
+ closed = false;
438
+ pending = /* @__PURE__ */ new Map();
439
+ constructor(send, timeoutMs = 5e3) {
440
+ this.send = send;
441
+ this.timeoutMs = timeoutMs;
305
442
  }
306
- }
307
- /**
308
- * The keychain entry name current claude uses for a non-default config dir:
309
- * the bare name belongs to `~/.claude`, other dirs get a path-hash suffix.
310
- * @param homeDir - the `claude-code` harness's scoped home.
311
- * @returns the macOS keychain service name holding the OAuth credential.
312
- */
313
- function keychainService(homeDir) {
314
- return `Claude Code-credentials-${createHash("sha256").update(homeDir).digest("hex").slice(0, 8)}`;
315
- }
316
- /**
317
- * Whether a credential blob is USABLE: the token record nests under
318
- * `claudeAiOauth`, and a blob whose access AND refresh tokens are both empty
319
- * is a shell. macOS keeps historical items under the same service (an early
320
- * claude wrote an `acct=unknown` placeholder with blank tokens and
321
- * `expiresAt: 0`), and mirroring one into the credentials file makes every
322
- * delegation report "OAuth session expired and could not be refreshed" —
323
- * indistinguishable from a real expiry while login looks successful. A
324
- * non-empty token is the bar for mirroring a blob or keeping a file.
325
- * @param text - the raw credential JSON.
326
- * @returns true when the blob carries a non-empty token.
327
- */
328
- function credentialUsable(text) {
329
- try {
330
- const oauth = JSON.parse(text).claudeAiOauth;
331
- return typeof oauth?.accessToken === "string" && oauth.accessToken !== "" || typeof oauth?.refreshToken === "string" && oauth.refreshToken !== "";
332
- } catch {
333
- return false;
443
+ accept(event) {
444
+ if (event["type"] !== "control_response") return false;
445
+ const response = event["response"];
446
+ const id = response?.["request_id"];
447
+ if (typeof id !== "string") return true;
448
+ const pending = this.pending.get(id);
449
+ if (pending === void 0) return true;
450
+ if (response?.["subtype"] !== "success") pending.reject(new Error(typeof response?.["error"] === "string" ? response["error"] : "Claude rejected the control request"));
451
+ else {
452
+ const result = response["response"];
453
+ pending.resolve(typeof result === "object" && result !== null ? result : {});
454
+ }
455
+ return true;
334
456
  }
335
- }
336
- /**
337
- * A `security dump-keychain` timedate as epoch ms; undefined when the shape is
338
- * not recognized (the item then sorts as oldest).
339
- *
340
- * The shape that matters is the COMPACT Zulu stamp `20260916040855Z`, because
341
- * that is what the tool actually prints — with the attribute's trailing NUL
342
- * rendered as a literal `\000` after the closing digit, so the match must not
343
- * anchor at the end. Only the spaced `2026-09-01 02:03:04 +0000` form was
344
- * parsed before, which no `security` build was observed to emit: every item
345
- * fell to the `?? 0` default, the newest-first sort over all-equal keys
346
- * degenerated to the dump's own order (a stable sort), and
347
- * `readKeychainCredential` returned whichever usable item the dump happened to
348
- * list first. A scoped home that had been logged in twice then kept serving the
349
- * SUPERSEDED credential — the fresh login sat unread in the keychain while the
350
- * runtime ran on a generation whose refresh token was already spent. The
351
- * spaced form is still accepted in case some build does emit it.
352
- * @param text - the quoted timedate string from the dump.
353
- * @returns the epoch milliseconds, or undefined.
354
- */
355
- function keychainTimestamp(text) {
356
- const trimmed = text.trim();
357
- const compact = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/.exec(trimmed);
358
- if (compact !== null) {
359
- const at = (index) => Number(compact[index]);
360
- const ms = Date.UTC(at(1), at(2) - 1, at(3), at(4), at(5), at(6));
361
- return Number.isFinite(ms) ? ms : void 0;
362
- }
363
- const spaced = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(trimmed);
364
- if (spaced === null) return void 0;
365
- const part = (index) => Number(spaced[index]);
366
- const offsetMinutes = (part(8) * 60 + part(9)) * (spaced[7] === "+" ? 1 : -1);
367
- const ms = Date.UTC(part(1), part(2) - 1, part(3), part(4), part(5), part(6)) - offsetMinutes * 6e4;
368
- return Number.isFinite(ms) ? ms : void 0;
369
- }
370
- /**
371
- * Every account holding an item under the service, newest write first. macOS
372
- * allows SEVERAL items per service, and an unscoped `find-generic-password
373
- * -w` returns the FIRST match — which can be a historical empty shell (see
374
- * `credentialUsable`) — so the sync enumerates instead of trusting the first
375
- * hit. The dump prints metadata only (no secrets); a failing dump or a parse
376
- * miss yields an empty list, and the caller then falls back to the unscoped
377
- * read.
378
- * @param service - the keychain service name.
379
- * @returns the accounts, newest modification first.
380
- */
381
- async function keychainItems(service) {
382
- let stdout;
383
- try {
384
- ({stdout} = await internals.exec("security", ["dump-keychain"]));
385
- } catch {
386
- return [];
387
- }
388
- const items = [];
389
- let svce;
390
- let acct;
391
- let mdat = 0;
392
- const flush = () => {
393
- if (svce === service && acct !== void 0) items.push({
394
- acct,
395
- mdat
457
+ request(request, signal) {
458
+ if (this.closed || signal?.aborted) return Promise.reject(/* @__PURE__ */ new Error("Claude control channel is closed"));
459
+ const id = `dsh-control-${++this.sequence}`;
460
+ return new Promise((resolve, reject) => {
461
+ const finish = () => {
462
+ this.pending.delete(id);
463
+ clearTimeout(timer);
464
+ signal?.removeEventListener("abort", abort);
465
+ };
466
+ const fail = (error) => {
467
+ finish();
468
+ reject(error);
469
+ };
470
+ const abort = () => {
471
+ fail(/* @__PURE__ */ new Error("Claude control request cancelled"));
472
+ };
473
+ const timer = setTimeout(() => fail(/* @__PURE__ */ new Error(`Claude ${String(request["subtype"])} control timed out`)), this.timeoutMs);
474
+ timer.unref();
475
+ this.pending.set(id, {
476
+ resolve: (value) => {
477
+ finish();
478
+ resolve(value);
479
+ },
480
+ reject: fail
481
+ });
482
+ signal?.addEventListener("abort", abort, { once: true });
483
+ try {
484
+ this.send({
485
+ type: "control_request",
486
+ request_id: id,
487
+ request
488
+ });
489
+ } catch (error) {
490
+ fail(error instanceof Error ? error : /* @__PURE__ */ new Error("Claude control write failed"));
491
+ }
396
492
  });
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
493
  }
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;
494
+ close() {
495
+ if (this.closed) return;
496
+ this.closed = true;
497
+ for (const request of [...this.pending.values()]) request.reject(/* @__PURE__ */ new Error("Claude control channel closed before acknowledgement"));
495
498
  }
496
- }
499
+ };
500
+ //#endregion
501
+ //#region lib/types/live-driver.js
497
502
  /**
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
+ * The claude provider's live driver: one resident Claude Code stream-json
504
+ * process per member (child session), driven over the vendor's
505
+ * `--input-format stream-json --output-format stream-json` realtime mode
506
+ * (protocol-probed against claude 2.1.236; the Agent SDK's streaming-input
507
+ * mode is this same channel, driven here directly so no SDK dependency
508
+ * lands). A delegation round is one `user` message on the resident process's
509
+ * stdin instead of a fresh `claude -p` — which buys what the process boundary
510
+ * could not: runtime-level graceful interrupt (`control_request
511
+ * {subtype:'interrupt'}`; the process survives and the session stays
512
+ * continuable) and the same-shape push stream folded live per turn.
503
513
  *
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.
514
+ * Auth discipline (a hard requirement): the resident process runs under the
515
+ * family's scoped `CLAUDE_CONFIG_DIR` plus the resolved endpoint credential's
516
+ * `ANTHROPIC_*` variables (the scope's endpoint-auth.json over the plugin
517
+ * config — the exec path's exact env rule, resolved per spawn). The driver
518
+ * never touches the user's global `~/.claude` and never runs any `auth` verb.
516
519
  *
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.
520
+ * Protocol facts (all probed): the first stdin `user` message triggers
521
+ * `system/init` carrying the server-assigned session id (EVERY turn emits a
522
+ * fresh init — the delegation records the first one); each turn closes with
523
+ * a `result` event (`is_error`, usage, session id); the interrupt control
524
+ * request answers with a `control_response` success; a new process with
525
+ * `--resume <session_id>` reattaches the on-disk session in the same mode
526
+ * (crash recovery); stdin EOF quiesces the process (the reclaim ladder).
527
+ *
528
+ * Fold: the resident stream speaks the exec path's exact event vocabulary
529
+ * (`system`/`assistant`/`user`/`result`), so each turn folds through the
530
+ * shared `ClaudeStreamParser` with the exec live mirror's hold-back rule (the
531
+ * volatile last line waits for `result`, which carries the round's usage).
532
+ * Every line folds 1:1. The always-enabled
533
+ * `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
534
+ * stream (native message id + block index, with ordered legacy fallback),
535
+ * which reserves a (turn, step) at its first delta and
536
+ * appends throttled snapshot `assistant/message`s there — the host folds
537
+ * repeated settles at one coordinate into one live-updating chat node, the
538
+ * only streaming channel left after 0.1.5 retired the durable per-chunk
539
+ * event. A streamed line's completion folds at its stream's reserved step and
540
+ * finalizes it (no duplicated content); a stream whose completion never
541
+ * arrives is force-finalized at settle (interrupted on a non-completed round,
542
+ * so a cancelled turn reads 已停止 legitimately), the last one carrying the
543
+ * round's usage when no folded line did.
544
+ *
545
+ * Lifecycle mirrors M1–M3's discipline: lazy spawn, one in-flight spawn per
546
+ * member, idle-timeout reclaim (stdin EOF → grace → SIGTERM ladder), crash
547
+ * re-spawn with `--resume`, a channel breaker with cooldown, `disposeAll` on
548
+ * unload, and cancellation honored in every window (abort listener before
549
+ * any await; the init wait races the abort signal).
550
+ *
551
+ * Two member-aware additions on top: the spawn binds the member's EFFECTIVE
552
+ * model (session-level override, then the round's delegation model, then the
553
+ * settings key — resolved by the config callback per spawn, scratched into
554
+ * the scoped settings.json because a `--resume` respawn honors the file over
555
+ * the flag), and a runtime whose bound model no longer matches is retired so
556
+ * the round respawns onto the right one; and server→client control requests
557
+ * (a `permissionMode: 'normal'` spawn's `can_use_tool` approval surface) are
558
+ * auto-answered so an unattended runtime never hangs on a permission prompt.
559
+ * @module @khorsheed/dsh-local-agent-claude-code/live-driver
525
560
  */
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
- }
561
+ /** Default idle lifetime of an unused resident runtime before reclaim. */
562
+ const DEFAULT_LIVE_IDLE_MS = 18e5;
563
+ /** Default timeout for the turn's system/init ack (a cold boot can be slow). */
564
+ const DEFAULT_LIVE_INIT_TIMEOUT_MS = 6e4;
565
+ /** Bounded wait for an interrupted turn to converge while a run disposes. */
566
+ const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5e3;
567
+ /** How long a tripped channel breaker stays on the exec fallback. */
568
+ const DEFAULT_LIVE_CHANNEL_RETRY_MS = 3e5;
569
+ /** Grace between stdin EOF and SIGTERM when reclaiming a stream-json process. */
570
+ const RECLAIM_EOF_GRACE_MS = 1e3;
571
+ /** Default minimum interval between one streaming kind's snapshot messages. */
572
+ const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
551
573
  /**
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.
574
+ * The stream-json channel could not come up (spawn failure or the first
575
+ * turn's init never arrived). The provider catches exactly this and falls
576
+ * back to the exec one-shot; any other error is a real round failure.
559
577
  */
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;
578
+ var LiveChannelUnavailableError = class extends Error {
579
+ constructor(message) {
580
+ super(message);
581
+ this.name = "LiveChannelUnavailableError";
571
582
  }
583
+ };
584
+ const DEFAULT_TIMEOUTS = {
585
+ initMs: DEFAULT_LIVE_INIT_TIMEOUT_MS,
586
+ convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
587
+ channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS
588
+ };
589
+ function thrown$1(value) {
590
+ return value instanceof Error ? value : new Error(String(value));
572
591
  }
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();
592
+ function delay(ms) {
593
+ return new Promise((resolve) => setTimeout(resolve, ms));
616
594
  }
617
595
  /**
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.
596
+ * One resident stream-json process: the NDJSON frame pump, the session id it
597
+ * carries, the per-session turn chain (one turn at a time — claude queues
598
+ * stdin user messages, but the cancelled turn's result must land before the
599
+ * next turn's message goes out, keeping the stop-then-rephrase gesture's
600
+ * streams unambiguous), and the reclaim ladder (stdin EOF → grace → SIGTERM).
624
601
  */
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;
602
+ var ClaudeLiveRuntime = class {
603
+ child;
604
+ warn;
605
+ dead = false;
606
+ reclaimed = false;
607
+ /** The member's claude session id (first turn's system/init). */
608
+ sessionId;
609
+ /** The model this runtime's process was spawned with (undefined = no flag). */
610
+ configurationKey;
611
+ model;
612
+ /** Serializes turns per member (converge-before-next-message). */
613
+ turnChain = Promise.resolve();
614
+ /** The active round's event sink; installed per round, cleared at settle. */
615
+ onEvent;
616
+ controls = new ClaudeControlRequests((message) => this.send(message));
617
+ onDead;
618
+ buffer = "";
619
+ decoder = new StringDecoder("utf8");
620
+ stderrTail = "";
621
+ stderrDecoder = new StringDecoder("utf8");
622
+ constructor(child, warn) {
623
+ this.child = child;
624
+ this.warn = warn;
625
+ child.stdout?.on("data", (chunk) => {
626
+ this.feed(this.decoder.write(chunk));
627
+ });
628
+ child.stderr?.on("data", (chunk) => {
629
+ this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
630
+ });
631
+ child.done.then(() => {
632
+ this.markDead();
633
+ }, () => {
634
+ this.markDead();
635
+ });
645
636
  }
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;
637
+ get diagnostics() {
638
+ return this.stderrTail.trim();
662
639
  }
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;
640
+ feed(text) {
641
+ this.buffer += text;
642
+ let index = this.buffer.indexOf("\n");
643
+ while (index >= 0) {
644
+ const line = this.buffer.slice(0, index);
645
+ this.buffer = this.buffer.slice(index + 1);
646
+ index = this.buffer.indexOf("\n");
647
+ if (line.trim() === "") continue;
648
+ let event;
672
649
  try {
673
- const handle = await open(join(homeDir, PROJECTS_ROOT, projectDir, name), "r");
674
- let text;
675
- try {
676
- const buffer = Buffer.alloc(HEAD_BYTES);
677
- const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
678
- text = buffer.subarray(0, bytesRead).toString("utf8");
679
- } finally {
680
- await handle.close();
681
- }
682
- const record = projectRecord(text);
683
- if (record !== void 0) records.push(record);
650
+ event = JSON.parse(line);
684
651
  } catch {
652
+ this.warn("subagent-claude live: ignored a malformed stream line");
685
653
  continue;
686
654
  }
655
+ try {
656
+ this.dispatchEvent(event);
657
+ } catch (error) {
658
+ this.warn(`subagent-claude live: stream handler failed: ${thrown$1(error).message}`);
659
+ }
687
660
  }
688
661
  }
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();
662
+ dispatchEvent(event) {
663
+ if (this.controls.accept(event)) return;
664
+ if (event["type"] === "control_request") {
665
+ this.answerControlRequest(event);
666
+ return;
714
667
  }
715
- } catch {
716
- return;
668
+ this.onEvent?.(event);
717
669
  }
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;
670
+ /**
671
+ * Answer a server→client control request so the runtime never hangs on
672
+ * one. A `permissionMode: 'normal'` spawn omits
673
+ * `--dangerously-skip-permissions`, so the CLI routes its approval surface
674
+ * (`can_use_tool`) here — and a subagent has no human to approve. The
675
+ * auto-allow mirrors the exec path's effective behavior (its one-shot
676
+ * spawns pre-allow the member bridge tool and otherwise run the same
677
+ * unattended policy) and the kimi live driver's auto-allow of
678
+ * `session/request_permission`. Any other subtype answers with an error so
679
+ * the CLI's own fallback path runs instead of waiting forever.
680
+ */
681
+ answerControlRequest(event) {
682
+ const requestId = event["request_id"];
683
+ if (typeof requestId !== "string") return;
684
+ const request = event["request"];
685
+ const subtype = request?.["subtype"];
686
+ if (subtype === "can_use_tool") {
687
+ const input = request?.["input"];
688
+ this.send({
689
+ type: "control_response",
690
+ response: {
691
+ subtype: "success",
692
+ request_id: requestId,
693
+ response: {
694
+ behavior: "allow",
695
+ updatedInput: typeof input === "object" && input !== null ? input : {}
696
+ }
697
+ }
698
+ });
699
+ return;
727
700
  }
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;
701
+ this.send({
702
+ type: "control_response",
703
+ response: {
704
+ subtype: "error",
705
+ request_id: requestId,
706
+ error: `subagent-claude live: unsupported control request ${String(subtype)}`
707
+ }
708
+ });
730
709
  }
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 [];
710
+ /** Write one stdin frame. */
711
+ send(message) {
712
+ this.child.stdin?.write(JSON.stringify(message) + "\n");
738
713
  }
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 [];
714
+ /** Native initialization and model binding precede any user prompt. */
715
+ async configure(configuration, signal) {
716
+ const entry = claudeDirectory(await this.controls.request({
717
+ subtype: "initialize",
718
+ hooks: {}
719
+ }, signal)).entries.find((entry) => entry.value === configuration.model || entry.resolvedModel === configuration.model);
720
+ if (configuration.effort !== void 0 && !entry?.reasoning?.options.some((option) => option.value === configuration.effort)) throw new Error("The running Claude CLI does not advertise the requested model/effort combination");
721
+ if (configuration.model !== void 0) await this.controls.request({
722
+ subtype: "set_model",
723
+ model: configuration.model
724
+ }, signal);
746
725
  }
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;
726
+ /** The graceful runtime interrupt, bounded and checked for an actual success ack. */
727
+ async interrupt() {
728
+ if (this.dead) return;
729
+ await this.controls.request({ subtype: "interrupt" });
766
730
  }
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
- }
731
+ markDead() {
732
+ if (this.dead) return;
733
+ this.dead = true;
734
+ this.controls.close();
735
+ this.onDead?.();
775
736
  }
776
- return false;
777
- }
778
- /**
779
- * The model a member's OWN CLI transcript names — the history backstop for
780
- * the broker's `lastObserved` when no delegation record observed one (every
781
- * member whose rounds predate the live-settle report). The live-settle
782
- * channel remains primary (the record's `observedModel` wins when present);
783
- * this reader only answers "what did this session last run" from the CLI's
784
- * own files, read-only — nothing is backfilled into `delegations.jsonl`.
785
- *
786
- * Location rule: with `cliSessionId`, the main transcript
787
- * `projects/<cwd-slug>/<cliSessionId>.jsonl` (found by NAME across the
788
- * project dirs — the slug is lossy, so it is never computed); when it is
789
- * absent, a sidechain `agent-*.jsonl` whose head names the session id
790
- * (bounded candidate count) answers instead. Without `cliSessionId` (the
791
- * settings card's memberless read), the NEWEST transcript across the
792
- * projects tree (mtime, bounded dir/file scan) answers; a newest file that
793
- * names no model yields to the next, up to a few tries. Every failure —
794
- * missing tree, unreadable file, no model in the read window — is
795
- * `undefined`, never a throw.
796
- * @param homeDir - the `claude-code` harness's scoped home.
797
- * @param cliSessionId - the delegation record's CLI session id, when the
798
- * member's own transcript is wanted.
799
- * @returns the model identifier, or undefined.
800
- */
801
- async function readClaudeTranscriptModel(homeDir, cliSessionId) {
802
- if (cliSessionId !== void 0) {
803
- let main;
804
- const sidechains = [];
805
- for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
806
- if (name === `${cliSessionId}.jsonl` && main === void 0) {
807
- main = join(homeDir, PROJECTS_ROOT, projectDir, name);
808
- continue;
809
- }
810
- if (name.startsWith("agent-") && sidechains.length < TRANSCRIPT_SIDECHAIN_FILES) sidechains.push(join(homeDir, PROJECTS_ROOT, projectDir, name));
811
- }
812
- if (main !== void 0) {
813
- const model = await transcriptFileModel(main);
814
- if (model !== void 0) return model;
737
+ /** Graceful teardown: EOF stdin, grace, then the terminate ladder. */
738
+ async reclaim() {
739
+ if (this.reclaimed) return;
740
+ this.reclaimed = true;
741
+ if (!this.dead) {
742
+ this.child.stdin?.end();
743
+ await Promise.race([this.child.done.catch(() => {}), delay(RECLAIM_EOF_GRACE_MS)]);
815
744
  }
816
- for (const path of sidechains) if (await sidechainSessionMatches(path, cliSessionId)) {
817
- const model = await transcriptFileModel(path);
818
- if (model !== void 0) return model;
745
+ if (!this.dead) {
746
+ this.child.terminate();
747
+ await this.child.waitForExit();
819
748
  }
820
- return;
749
+ await this.child.done.catch(() => {});
750
+ this.markDead();
821
751
  }
822
- const candidates = [];
823
- let scanned = 0;
824
- for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
825
- if (scanned >= TRANSCRIPT_SCAN_FILES) break;
826
- scanned += 1;
827
- const path = join(homeDir, PROJECTS_ROOT, projectDir, name);
828
- try {
829
- candidates.push({
830
- path,
831
- mtimeMs: (await stat(path)).mtimeMs
832
- });
833
- } catch {
834
- continue;
835
- }
836
- }
837
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
838
- for (const candidate of candidates.slice(0, TRANSCRIPT_NEWEST_TRIES)) {
839
- const model = await transcriptFileModel(candidate.path);
840
- if (model !== void 0) return model;
841
- }
842
- }
843
- //#endregion
844
- //#region lib/types/live-driver.js
845
- /**
846
- * The claude provider's live driver: one resident Claude Code stream-json
847
- * process per member (child session), driven over the vendor's
848
- * `--input-format stream-json --output-format stream-json` realtime mode
849
- * (protocol-probed against claude 2.1.236; the Agent SDK's streaming-input
850
- * mode is this same channel, driven here directly so no SDK dependency
851
- * lands). A delegation round is one `user` message on the resident process's
852
- * stdin instead of a fresh `claude -p` — which buys what the process boundary
853
- * could not: runtime-level graceful interrupt (`control_request
854
- * {subtype:'interrupt'}`; the process survives and the session stays
855
- * continuable) and the same-shape push stream folded live per turn.
856
- *
857
- * Auth discipline (a hard requirement): the resident process runs under the
858
- * family's scoped `CLAUDE_CONFIG_DIR` and nothing else — the OAuth marker in
859
- * the scoped `.claude.json` and the keychain entry hashed to that path are
860
- * exactly what the exec mode's one-shot processes already use. The driver
861
- * never touches the user's global `~/.claude`, never runs any `auth` verb,
862
- * and only adds `ANTHROPIC_BASE_URL` when the plugin config supplies it (the
863
- * exec path's exact env rule).
864
- *
865
- * Protocol facts (all probed): the first stdin `user` message triggers
866
- * `system/init` carrying the server-assigned session id (EVERY turn emits a
867
- * fresh init — the delegation records the first one); each turn closes with
868
- * a `result` event (`is_error`, usage, session id); the interrupt control
869
- * request answers with a `control_response` success; a new process with
870
- * `--resume <session_id>` reattaches the on-disk session in the same mode
871
- * (crash recovery); stdin EOF quiesces the process (the reclaim ladder).
872
- *
873
- * Fold: the resident stream speaks the exec path's exact event vocabulary
874
- * (`system`/`assistant`/`user`/`result`), so each turn folds through the
875
- * shared `ClaudeStreamParser` with the exec live mirror's hold-back rule (the
876
- * volatile last line waits for `result`, which carries the round's usage).
877
- * Every line folds 1:1. The always-enabled
878
- * `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
879
- * stream (native message id + block index, with ordered legacy fallback),
880
- * which reserves a (turn, step) at its first delta and
881
- * appends throttled snapshot `assistant/message`s there — the host folds
882
- * repeated settles at one coordinate into one live-updating chat node, the
883
- * only streaming channel left after 0.1.5 retired the durable per-chunk
884
- * event. A streamed line's completion folds at its stream's reserved step and
885
- * finalizes it (no duplicated content); a stream whose completion never
886
- * arrives is force-finalized at settle (interrupted on a non-completed round,
887
- * so a cancelled turn reads 已停止 legitimately), the last one carrying the
888
- * round's usage when no folded line did.
889
- *
890
- * Lifecycle mirrors M1–M3's discipline: lazy spawn, one in-flight spawn per
891
- * member, idle-timeout reclaim (stdin EOF → grace → SIGTERM ladder), crash
892
- * re-spawn with `--resume`, a channel breaker with cooldown, `disposeAll` on
893
- * unload, and cancellation honored in every window (abort listener before
894
- * any await; the init wait races the abort signal).
895
- *
896
- * Two member-aware additions on top: the spawn binds the member's EFFECTIVE
897
- * model (session-level override, then the round's delegation model, then the
898
- * settings key — resolved by the config callback per spawn, scratched into
899
- * the scoped settings.json because a `--resume` respawn honors the file over
900
- * the flag), and a runtime whose bound model no longer matches is retired so
901
- * the round respawns onto the right one; and server→client control requests
902
- * (a `permissionMode: 'normal'` spawn's `can_use_tool` approval surface) are
903
- * auto-answered so an unattended runtime never hangs on a permission prompt.
904
- * @module @khorsheed/dsh-local-agent-claude-code/live-driver
905
- */
906
- /** Default idle lifetime of an unused resident runtime before reclaim. */
907
- const DEFAULT_LIVE_IDLE_MS = 18e5;
908
- /** Default timeout for the turn's system/init ack (a cold boot can be slow). */
909
- const DEFAULT_LIVE_INIT_TIMEOUT_MS = 6e4;
910
- /** Bounded wait for an interrupted turn to converge while a run disposes. */
911
- const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5e3;
912
- /** How long a tripped channel breaker stays on the exec fallback. */
913
- const DEFAULT_LIVE_CHANNEL_RETRY_MS = 3e5;
914
- /** Grace between stdin EOF and SIGTERM when reclaiming a stream-json process. */
915
- const RECLAIM_EOF_GRACE_MS = 1e3;
916
- /** Default minimum interval between one streaming kind's snapshot messages. */
917
- const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
918
- /**
919
- * The stream-json channel could not come up (spawn failure or the first
920
- * turn's init never arrived). The provider catches exactly this and falls
921
- * back to the exec one-shot; any other error is a real round failure.
922
- */
923
- var LiveChannelUnavailableError = class extends Error {
924
- constructor(message) {
925
- super(message);
926
- this.name = "LiveChannelUnavailableError";
927
- }
928
- };
929
- const DEFAULT_TIMEOUTS = {
930
- initMs: DEFAULT_LIVE_INIT_TIMEOUT_MS,
931
- convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
932
- channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS
933
752
  };
934
- function thrown$1(value) {
935
- return value instanceof Error ? value : new Error(String(value));
936
- }
937
- function delay(ms) {
938
- return new Promise((resolve) => setTimeout(resolve, ms));
939
- }
940
753
  /**
941
- * One resident stream-json process: the NDJSON frame pump, the session id it
942
- * carries, the per-session turn chain (one turn at a time — claude queues
943
- * stdin user messages, but the cancelled turn's result must land before the
944
- * next turn's message goes out, keeping the stop-then-rephrase gesture's
945
- * streams unambiguous), and the reclaim ladder (stdin EOF → grace → SIGTERM).
754
+ * The claude live driver owns every resident stream-json runtime of this
755
+ * provider generation. Created only when `live: true`; disposed with the
756
+ * plugin.
946
757
  */
947
- var ClaudeLiveRuntime = class {
948
- child;
949
- warn;
950
- dead = false;
951
- reclaimed = false;
952
- /** The member's claude session id (first turn's system/init). */
953
- sessionId;
954
- /** The model this runtime's process was spawned with (undefined = no flag). */
955
- configurationKey;
956
- model;
957
- /** Serializes turns per member (converge-before-next-message). */
958
- turnChain = Promise.resolve();
959
- /** The active round's event sink; installed per round, cleared at settle. */
960
- onEvent;
961
- controls = new ClaudeControlRequests((message) => this.send(message));
962
- onDead;
963
- buffer = "";
964
- decoder = new StringDecoder("utf8");
965
- stderrTail = "";
966
- stderrDecoder = new StringDecoder("utf8");
967
- constructor(child, warn) {
968
- this.child = child;
969
- this.warn = warn;
970
- child.stdout?.on("data", (chunk) => {
971
- this.feed(this.decoder.write(chunk));
972
- });
973
- child.stderr?.on("data", (chunk) => {
974
- this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
975
- });
976
- child.done.then(() => {
977
- this.markDead();
978
- }, () => {
979
- this.markDead();
980
- });
981
- }
982
- get diagnostics() {
983
- return this.stderrTail.trim();
758
+ var ClaudeLiveDriver = class {
759
+ ctx;
760
+ config;
761
+ timeouts;
762
+ runtimes = /* @__PURE__ */ new Map();
763
+ ensuring = /* @__PURE__ */ new Map();
764
+ idleTimers = /* @__PURE__ */ new Map();
765
+ /** Per-member round serialization (the resume lock covers resume-vs-resume only). */
766
+ roundChains = /* @__PURE__ */ new Map();
767
+ channelBrokenAt;
768
+ disposed = false;
769
+ /**
770
+ * Set by drain() (a settings-driven generation handoff): new rounds are
771
+ * refused so the provider falls back to exec, while in-flight rounds finish
772
+ * on their runtime undisturbed. Unlike `disposed`, the driver still serves
773
+ * what it already accepted.
774
+ */
775
+ draining = false;
776
+ disposeController = new AbortController();
777
+ constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
778
+ this.ctx = ctx;
779
+ this.config = config;
780
+ this.timeouts = timeouts;
984
781
  }
985
- feed(text) {
986
- this.buffer += text;
987
- let index = this.buffer.indexOf("\n");
988
- while (index >= 0) {
989
- const line = this.buffer.slice(0, index);
990
- this.buffer = this.buffer.slice(index + 1);
991
- index = this.buffer.indexOf("\n");
992
- if (line.trim() === "") continue;
993
- let event;
994
- try {
995
- event = JSON.parse(line);
996
- } catch {
997
- this.warn("subagent-claude live: ignored a malformed stream line");
998
- continue;
999
- }
1000
- try {
1001
- this.dispatchEvent(event);
1002
- } catch (error) {
1003
- this.warn(`subagent-claude live: stream handler failed: ${thrown$1(error).message}`);
1004
- }
1005
- }
782
+ /** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
783
+ get disabled() {
784
+ return this.channelBrokenAt !== void 0 && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? 3e5);
1006
785
  }
1007
- dispatchEvent(event) {
1008
- if (this.controls.accept(event)) return;
1009
- if (event["type"] === "control_request") {
1010
- this.answerControlRequest(event);
1011
- return;
1012
- }
1013
- this.onEvent?.(event);
786
+ /** Count of resident runtimes currently registered (zombie accounting in tests). */
787
+ get liveCount() {
788
+ return this.runtimes.size;
1014
789
  }
1015
790
  /**
1016
- * Answer a server→client control request so the runtime never hangs on
1017
- * one. A `permissionMode: 'normal'` spawn omits
1018
- * `--dangerously-skip-permissions`, so the CLI routes its approval surface
1019
- * (`can_use_tool`) here — and a subagent has no human to approve. The
1020
- * auto-allow mirrors the exec path's effective behavior (its one-shot
1021
- * spawns pre-allow the member bridge tool and otherwise run the same
1022
- * unattended policy) and the kimi live driver's auto-allow of
1023
- * `session/request_permission`. Any other subtype answers with an error so
1024
- * the CLI's own fallback path runs instead of waiting forever.
791
+ * True while the member's runtime exists or is being spawned. The settings
792
+ * controller gates a new generation on this: a fresh driver must not serve
793
+ * a member whose retiring generation still hosts the (same) claude session.
1025
794
  */
1026
- answerControlRequest(event) {
1027
- const requestId = event["request_id"];
1028
- if (typeof requestId !== "string") return;
1029
- const request = event["request"];
1030
- const subtype = request?.["subtype"];
1031
- if (subtype === "can_use_tool") {
1032
- const input = request?.["input"];
1033
- this.send({
1034
- type: "control_response",
1035
- response: {
1036
- subtype: "success",
1037
- request_id: requestId,
1038
- response: {
1039
- behavior: "allow",
1040
- updatedInput: typeof input === "object" && input !== null ? input : {}
1041
- }
1042
- }
1043
- });
1044
- return;
1045
- }
1046
- this.send({
1047
- type: "control_response",
1048
- response: {
1049
- subtype: "error",
1050
- request_id: requestId,
1051
- error: `subagent-claude live: unsupported control request ${String(subtype)}`
1052
- }
1053
- });
1054
- }
1055
- /** Write one stdin frame. */
1056
- send(message) {
1057
- this.child.stdin?.write(JSON.stringify(message) + "\n");
1058
- }
1059
- /** Native initialization and model binding precede any user prompt. */
1060
- async configure(configuration, signal) {
1061
- const entry = claudeDirectory(await this.controls.request({
1062
- subtype: "initialize",
1063
- hooks: {}
1064
- }, signal)).entries.find((entry) => entry.value === configuration.model || entry.resolvedModel === configuration.model);
1065
- if (configuration.effort !== void 0 && !entry?.reasoning?.options.some((option) => option.value === configuration.effort)) throw new Error("The running Claude CLI does not advertise the requested model/effort combination");
1066
- if (configuration.model !== void 0) await this.controls.request({
1067
- subtype: "set_model",
1068
- model: configuration.model
1069
- }, signal);
1070
- }
1071
- /** The graceful runtime interrupt, bounded and checked for an actual success ack. */
1072
- async interrupt() {
1073
- if (this.dead) return;
1074
- await this.controls.request({ subtype: "interrupt" });
1075
- }
1076
- markDead() {
1077
- if (this.dead) return;
1078
- this.dead = true;
1079
- this.controls.close();
1080
- this.onDead?.();
1081
- }
1082
- /** Graceful teardown: EOF stdin, grace, then the terminate ladder. */
1083
- async reclaim() {
1084
- if (this.reclaimed) return;
1085
- this.reclaimed = true;
1086
- if (!this.dead) {
1087
- this.child.stdin?.end();
1088
- await Promise.race([this.child.done.catch(() => {}), delay(RECLAIM_EOF_GRACE_MS)]);
1089
- }
1090
- if (!this.dead) {
1091
- this.child.terminate();
1092
- await this.child.waitForExit();
1093
- }
1094
- await this.child.done.catch(() => {});
1095
- this.markDead();
1096
- }
1097
- };
1098
- /**
1099
- * The claude live driver owns every resident stream-json runtime of this
1100
- * provider generation. Created only when `live: true`; disposed with the
1101
- * plugin.
1102
- */
1103
- var ClaudeLiveDriver = class {
1104
- ctx;
1105
- config;
1106
- timeouts;
1107
- runtimes = /* @__PURE__ */ new Map();
1108
- ensuring = /* @__PURE__ */ new Map();
1109
- idleTimers = /* @__PURE__ */ new Map();
1110
- /** Per-member round serialization (the resume lock covers resume-vs-resume only). */
1111
- roundChains = /* @__PURE__ */ new Map();
1112
- channelBrokenAt;
1113
- disposed = false;
1114
- /**
1115
- * Set by drain() (a settings-driven generation handoff): new rounds are
1116
- * refused so the provider falls back to exec, while in-flight rounds finish
1117
- * on their runtime undisturbed. Unlike `disposed`, the driver still serves
1118
- * what it already accepted.
1119
- */
1120
- draining = false;
1121
- disposeController = new AbortController();
1122
- constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
1123
- this.ctx = ctx;
1124
- this.config = config;
1125
- this.timeouts = timeouts;
1126
- }
1127
- /** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
1128
- get disabled() {
1129
- return this.channelBrokenAt !== void 0 && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? 3e5);
1130
- }
1131
- /** Count of resident runtimes currently registered (zombie accounting in tests). */
1132
- get liveCount() {
1133
- return this.runtimes.size;
1134
- }
1135
- /**
1136
- * True while the member's runtime exists or is being spawned. The settings
1137
- * controller gates a new generation on this: a fresh driver must not serve
1138
- * a member whose retiring generation still hosts the (same) claude session.
1139
- */
1140
- hasRuntime(key) {
1141
- return this.runtimes.has(key) || this.ensuring.has(key);
795
+ hasRuntime(key) {
796
+ return this.runtimes.has(key) || this.ensuring.has(key);
1142
797
  }
1143
798
  /** @deprecated Compatibility no-op: live output is always incremental. */
1144
799
  setLiveMirrorGranularity(_granularity) {}
@@ -1238,9 +893,7 @@ var ClaudeLiveDriver = class {
1238
893
  async spawnRuntime(spec, signal) {
1239
894
  const key = String(spec.childSession.id);
1240
895
  if (this.disposed) throw new LiveChannelUnavailableError("the live driver is disposed");
1241
- await syncClaudeCredentialFile(spec.homeDir, (message) => {
1242
- this.ctx.logger.warn(message);
1243
- }).catch(() => false);
896
+ const endpoint = await this.config.endpoint?.(spec.homeDir);
1244
897
  const member = registerClaudeMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
1245
898
  const model = this.spawnModel(key, spec);
1246
899
  if (spec.configuration === void 0 && this.config.provisionModel !== void 0) await this.config.provisionModel(spec.homeDir, model).catch(() => void 0);
@@ -1275,7 +928,7 @@ var ClaudeLiveDriver = class {
1275
928
  env: delegationEnv({
1276
929
  CLAUDE_CONFIG_DIR: spec.homeDir,
1277
930
  ...spec.configuration === void 0 ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? "auto" },
1278
- ...this.config.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.config.baseUrl }
931
+ ...endpointAuthEnv(endpoint)
1279
932
  })
1280
933
  };
1281
934
  let child;
@@ -1795,7 +1448,7 @@ var ClaudeLiveDriver = class {
1795
1448
  });
1796
1449
  await Promise.race([accepted, abortBranch.catch(() => void 0)]);
1797
1450
  return subprocessRunHandle({
1798
- id: childSession.id,
1451
+ id: SessionId(randomUUID()),
1799
1452
  result,
1800
1453
  signal: request.signal,
1801
1454
  onAbort,
@@ -1829,10 +1482,13 @@ var ClaudeLiveDriver = class {
1829
1482
  const DEFAULT_DISPOSE_GRACE_MS = 3e3;
1830
1483
  /**
1831
1484
  * Auth-shaped failure signatures in claude's result error or stderr: the
1832
- * endpoint rejected the credential (401/403, revoked token). Narrow on
1833
- * purpose — matched against provider-controlled error strings only.
1485
+ * endpoint rejected the credential (401/403, revoked key). Matches the CLI's
1486
+ * own phrasing ("Failed to authenticate. API Error: 401 …", the
1487
+ * `authentication_failed` error tag) plus the relay phrasings seen in the
1488
+ * wild ("invalid x-api-key" and friends). Narrow on purpose — matched against
1489
+ * provider-controlled error strings only.
1834
1490
  */
1835
- const CLAUDE_AUTH_FAILURE = /failed to authenticate|authentication_failed|oauth access token/i;
1491
+ 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;
1836
1492
  /** Register one member run with the channel; see {@link ClaudeMemberRunHandle}. */
1837
1493
  function registerClaudeMemberRun(ctx, childSessionId, parentSessionId) {
1838
1494
  const registry = ctx.localAgent;
@@ -1907,7 +1563,7 @@ function claudeCliVersion(ctx, homeDir) {
1907
1563
  var ClaudeCliProvider = class {
1908
1564
  ctx;
1909
1565
  permissionMode;
1910
- baseUrl;
1566
+ endpoint;
1911
1567
  live;
1912
1568
  model;
1913
1569
  name = "claude-local";
@@ -1918,6 +1574,11 @@ var ClaudeCliProvider = class {
1918
1574
  * generation's driver per member (the settings toggle swaps generations;
1919
1575
  * a resolver may return undefined to steer one member's round to exec
1920
1576
  * while a retiring generation still hosts it).
1577
+ * @param endpoint - resolver for the round's endpoint credential (endpoint-auth.ts:
1578
+ * the scope's endpoint-auth.json over the plugin config triple), read PER
1579
+ * ROUND against the round's scoped home, so a key rotation or a profile
1580
+ * edit takes effect on the next delegation without a reload. Absent, no
1581
+ * `ANTHROPIC_*` variables reach the child at all.
1921
1582
  * @param model - resolver for the member's effective model, read PER ROUND
1922
1583
  * so a settings-card write or a composer switch takes effect on the next
1923
1584
  * delegation without a reload. Receives the member (the child session id)
@@ -1926,10 +1587,10 @@ var ClaudeCliProvider = class {
1926
1587
  * resolver not passed), the delegation's own model still rides, exactly
1927
1588
  * as before the key existed.
1928
1589
  */
1929
- constructor(ctx, permissionMode = "skip", baseUrl, live, model) {
1590
+ constructor(ctx, permissionMode = "skip", endpoint, live, model) {
1930
1591
  this.ctx = ctx;
1931
1592
  this.permissionMode = permissionMode;
1932
- this.baseUrl = baseUrl;
1593
+ this.endpoint = endpoint;
1933
1594
  this.live = live;
1934
1595
  this.model = model;
1935
1596
  }
@@ -1971,7 +1632,7 @@ var ClaudeCliProvider = class {
1971
1632
  ...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
1972
1633
  }, (configuration) => this.startClaudeResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
1973
1634
  }
1974
- const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
1635
+ const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
1975
1636
  if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
1976
1637
  if (intent?.effort !== void 0) throw new Error("Claude effort requires the configuration admission core");
1977
1638
  return this.startClaudeFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
@@ -2000,18 +1661,22 @@ var ClaudeCliProvider = class {
2000
1661
  delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
2001
1662
  } });
2002
1663
  const harness = this.ctx.localAgent.get("claude-code");
2003
- const label = subagentDelegationLabel(harness?.displayName ?? "Claude Code", request.descriptor.label);
1664
+ const label = subagentDelegationLabel(harness?.displayName ?? "Claude Code", request.label || void 0);
2004
1665
  childSession.append("subagent/descriptor", {
2005
- ...request.descriptor,
1666
+ version: SUBAGENT_DESCRIPTOR_VERSION,
1667
+ mode: "one-shot",
1668
+ provider: this.name,
2006
1669
  label
2007
1670
  });
2008
1671
  establishSubagentCatalogChild(request.parent.session, childSession.header, label);
2009
1672
  } catch (error) {
2010
1673
  this.ctx.logger.warn(`subagent-claude: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
2011
1674
  }
2012
- const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
1675
+ const endpoint = await this.endpoint?.(homeDir);
1676
+ const effectiveBaseUrl = endpoint?.baseUrl;
2013
1677
  this.ctx.logger.info(`subagent-claude: delegating via ${effectiveBaseUrl ?? "claude default endpoint"}`);
2014
1678
  const live = exec === void 0 ? this.liveDriver(runId) : void 0;
1679
+ const round = configuration?.resolved ?? this.roundModel(runId, requestedModel);
2015
1680
  if (live !== void 0 && childSession !== void 0 && !live.disabled) try {
2016
1681
  return await live.startRound(request, {
2017
1682
  cwd,
@@ -2028,7 +1693,7 @@ var ClaudeCliProvider = class {
2028
1693
  cliSessionId: sessionId,
2029
1694
  cwd,
2030
1695
  ...scope === void 0 ? {} : { scope },
2031
- ...requestedModel === void 0 ? {} : { model: requestedModel }
1696
+ ...round.model === void 0 ? {} : { model: round.model }
2032
1697
  });
2033
1698
  }
2034
1699
  });
@@ -2042,12 +1707,12 @@ var ClaudeCliProvider = class {
2042
1707
  cwd,
2043
1708
  env: delegationEnv({
2044
1709
  CLAUDE_CONFIG_DIR: homeDir,
2045
- ...this.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.baseUrl }
1710
+ ...endpointAuthEnv(endpoint)
2046
1711
  }),
2047
1712
  ...exec === void 0 ? {} : { exec },
2048
1713
  endpointLabel: effectiveBaseUrl,
2049
1714
  permissionMode: this.permissionMode,
2050
- ...configuration?.resolved ?? this.roundModel(runId, requestedModel),
1715
+ ...round,
2051
1716
  ...configuration === void 0 ? {} : { controlled: true },
2052
1717
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
2053
1718
  spawn: (spec) => this.ctx.subprocess.spawn(spec),
@@ -2076,7 +1741,7 @@ var ClaudeCliProvider = class {
2076
1741
  cliSessionId: sessionId,
2077
1742
  cwd,
2078
1743
  ...scope === void 0 ? {} : { scope },
2079
- ...requestedModel === void 0 ? {} : { model: requestedModel }
1744
+ ...round.model === void 0 ? {} : { model: round.model }
2080
1745
  });
2081
1746
  },
2082
1747
  onRoundSettled: (round) => {
@@ -2121,7 +1786,8 @@ var ClaudeCliProvider = class {
2121
1786
  if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
2122
1787
  this.ctx.logger.warn(`subagent-claude: live driver unavailable, using the exec one-shot: ${error.message}`);
2123
1788
  }
2124
- const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
1789
+ const endpoint = await this.endpoint?.(homeDir);
1790
+ const effectiveBaseUrl = endpoint?.baseUrl;
2125
1791
  this.ctx.logger.info(`subagent-claude: resuming via ${effectiveBaseUrl ?? "claude default endpoint"}`);
2126
1792
  const member = exec === void 0 ? this.memberRun(intent.childSessionId, request.parent.session.id) : void 0;
2127
1793
  let run;
@@ -2130,7 +1796,7 @@ var ClaudeCliProvider = class {
2130
1796
  cwd,
2131
1797
  env: delegationEnv({
2132
1798
  CLAUDE_CONFIG_DIR: homeDir,
2133
- ...this.baseUrl === void 0 ? {} : { ANTHROPIC_BASE_URL: this.baseUrl }
1799
+ ...endpointAuthEnv(endpoint)
2134
1800
  }),
2135
1801
  ...exec === void 0 ? {} : { exec },
2136
1802
  endpointLabel: effectiveBaseUrl,
@@ -2644,10 +2310,6 @@ function usageFromClaude(usage) {
2644
2310
  async function startClaudeCliRun(request, spec) {
2645
2311
  const task = textTask(request.prompt);
2646
2312
  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
2313
  const turn = spec.resume?.turn ?? 1;
2652
2314
  const memberArgv = spec.member === void 0 ? [] : [
2653
2315
  "--mcp-config",
@@ -2824,7 +2486,7 @@ async function startClaudeCliRun(request, spec) {
2824
2486
  return mirrorClaudeAfterExit(spec, task, turn, output, liveMirror);
2825
2487
  }, () => {});
2826
2488
  return Promise.resolve(subprocessRunHandle({
2827
- id: spec.childSession?.id ?? SessionId(randomUUID()),
2489
+ id: SessionId(randomUUID()),
2828
2490
  result,
2829
2491
  signal: request.signal,
2830
2492
  onAbort,
@@ -3036,503 +2698,787 @@ async function appendClaudeResponse(spec, task, turn, parsed, fromLines = 0, use
3036
2698
  });
3037
2699
  step += 1;
3038
2700
  }
3039
- if (parsed.usage !== void 0 && usageIndex !== -1 && usageIndex < fromLines) spec.ctx.logger.warn(`subagent-claude: round ${turn} usage arrived after its carrier line was mirrored; the accounting is dropped`);
3040
- await persistChildSession(spec.ctx, childSession);
3041
- localAgent?.reportRunProgress(childSession.id, {
3042
- kind: "mirror",
3043
- mirroredLines: parsed.lines.length
3044
- });
3045
- }
3046
- /**
3047
- * Mirror the claude stream-json into the child session AFTER the CLI process
3048
- * has exited, whatever its stop reason. A cancelled or failed round still
3049
- * preserves the events the stream already emitted (thinking, tool calls with
3050
- * results, replies, usage) instead of leaving the child blank — the run result
3051
- * settles 'aborted'/'error' at the cancel moment, but claude may have produced
3052
- * content before the kill landed. Also records the session id (fresh rounds)
3053
- * so a later resume can continue the partial session.
3054
- * @param spec - the run spec carrying the child session and host context.
3055
- * @param task - the one-shot task text (the user prompt).
3056
- * @param turn - the round's turn number.
3057
- * @param output - the collected stream-json stdout.
3058
- */
3059
- async function mirrorClaudeAfterExit(spec, task, turn, output, live) {
3060
- if (spec.childSession === void 0 || spec.ctx === void 0) return;
3061
- const childSession = spec.childSession;
3062
- const work = async () => {
3063
- const parsed = parseClaudeStreamJson(output);
3064
- if (spec.resume === void 0) spec.onSessionId?.(parsed.sessionId);
3065
- if (parsed.todoSkew === true) spec.ctx?.logger.warn("subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line");
3066
- if (parsed.todos !== void 0) appendTodosIfChanged(childSession, parsed.todos);
3067
- const fromLines = live?.mirroredLines ?? 0;
3068
- const userMirrored = live?.userMirrored ?? false;
3069
- const cliVersion = parsed.cliVersion ?? await spec.cliVersion?.();
3070
- spec.onRoundSettled?.({
3071
- ...parsed.model === void 0 ? {} : { observedModel: parsed.model },
3072
- ...cliVersion === void 0 ? {} : { cliVersion },
3073
- ...parsed.usage === void 0 ? {} : { usage: parsed.usage },
3074
- ...parsed.toolCalls === void 0 ? {} : { toolCalls: parsed.toolCalls }
3075
- });
3076
- if (parsed.lines.length === 0 && !userMirrored) return;
3077
- const trimmed = parsed.text?.trim();
3078
- await appendClaudeResponse(spec, task, turn, {
3079
- lines: parsed.lines,
3080
- output: trimmed === void 0 || trimmed === "" ? [] : [{
3081
- type: "text",
3082
- text: trimmed
3083
- }],
3084
- ...parsed.usage === void 0 ? {} : { usage: parsed.usage }
3085
- }, fromLines, userMirrored);
3086
- };
3087
- try {
3088
- if (live === void 0) await work();
3089
- else await live.enqueue(work);
3090
- } catch (error) {
3091
- spec.onError?.(thrown(error), "error");
2701
+ if (parsed.usage !== void 0 && usageIndex !== -1 && usageIndex < fromLines) spec.ctx.logger.warn(`subagent-claude: round ${turn} usage arrived after its carrier line was mirrored; the accounting is dropped`);
2702
+ await persistChildSession(spec.ctx, childSession);
2703
+ localAgent?.reportRunProgress(childSession.id, {
2704
+ kind: "mirror",
2705
+ mirroredLines: parsed.lines.length
2706
+ });
2707
+ }
2708
+ /**
2709
+ * Mirror the claude stream-json into the child session AFTER the CLI process
2710
+ * has exited, whatever its stop reason. A cancelled or failed round still
2711
+ * preserves the events the stream already emitted (thinking, tool calls with
2712
+ * results, replies, usage) instead of leaving the child blank — the run result
2713
+ * settles 'aborted'/'error' at the cancel moment, but claude may have produced
2714
+ * content before the kill landed. Also records the session id (fresh rounds)
2715
+ * so a later resume can continue the partial session.
2716
+ * @param spec - the run spec carrying the child session and host context.
2717
+ * @param task - the one-shot task text (the user prompt).
2718
+ * @param turn - the round's turn number.
2719
+ * @param output - the collected stream-json stdout.
2720
+ */
2721
+ async function mirrorClaudeAfterExit(spec, task, turn, output, live) {
2722
+ if (spec.childSession === void 0 || spec.ctx === void 0) return;
2723
+ const childSession = spec.childSession;
2724
+ const work = async () => {
2725
+ const parsed = parseClaudeStreamJson(output);
2726
+ if (spec.resume === void 0) spec.onSessionId?.(parsed.sessionId);
2727
+ if (parsed.todoSkew === true) spec.ctx?.logger.warn("subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line");
2728
+ if (parsed.todos !== void 0) appendTodosIfChanged(childSession, parsed.todos);
2729
+ const fromLines = live?.mirroredLines ?? 0;
2730
+ const userMirrored = live?.userMirrored ?? false;
2731
+ const cliVersion = parsed.cliVersion ?? await spec.cliVersion?.();
2732
+ spec.onRoundSettled?.({
2733
+ ...parsed.model === void 0 ? {} : { observedModel: parsed.model },
2734
+ ...cliVersion === void 0 ? {} : { cliVersion },
2735
+ ...parsed.usage === void 0 ? {} : { usage: parsed.usage },
2736
+ ...parsed.toolCalls === void 0 ? {} : { toolCalls: parsed.toolCalls }
2737
+ });
2738
+ if (parsed.lines.length === 0 && !userMirrored) return;
2739
+ const trimmed = parsed.text?.trim();
2740
+ await appendClaudeResponse(spec, task, turn, {
2741
+ lines: parsed.lines,
2742
+ output: trimmed === void 0 || trimmed === "" ? [] : [{
2743
+ type: "text",
2744
+ text: trimmed
2745
+ }],
2746
+ ...parsed.usage === void 0 ? {} : { usage: parsed.usage }
2747
+ }, fromLines, userMirrored);
2748
+ };
2749
+ try {
2750
+ if (live === void 0) await work();
2751
+ else await live.enqueue(work);
2752
+ } catch (error) {
2753
+ spec.onError?.(thrown(error), "error");
2754
+ }
2755
+ }
2756
+ //#endregion
2757
+ //#region lib/types/live-switch.js
2758
+ /**
2759
+ * The settings-driven live-driver switch: owns the active driver generation
2760
+ * and retires old ones on a settings change, without a plugin reload.
2761
+ *
2762
+ * Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
2763
+ * new rounds are refused (the provider's catch falls back to exec), in-flight
2764
+ * rounds finish on their runtime, idle runtimes are reclaimed at once.
2765
+ * Toggling ON builds the next generation lazily (no process until the first
2766
+ * round). Legacy granularity settings are ignored.
2767
+ *
2768
+ * @module @khorsheed/dsh-local-agent-claude-code — internal, unit-tested directly.
2769
+ */
2770
+ var LiveDriverSwitch = class {
2771
+ ctx;
2772
+ driverBase;
2773
+ liveOn = false;
2774
+ active;
2775
+ /** Retiring generations still draining their in-flight rounds. */
2776
+ draining = /* @__PURE__ */ new Set();
2777
+ unwatch;
2778
+ constructor(ctx, face, driverBase) {
2779
+ this.ctx = ctx;
2780
+ this.driverBase = driverBase;
2781
+ this.apply(face.get());
2782
+ this.unwatch = face.watch((next) => {
2783
+ this.apply(next);
2784
+ });
2785
+ }
2786
+ /**
2787
+ * Resolve the live driver for one round's member (the provider's call
2788
+ * site). A retiring generation still hosting the member gates the new one —
2789
+ * the round falls back to exec rather than double-loading the same claude
2790
+ * session into two processes.
2791
+ */
2792
+ resolve = (childSessionId) => {
2793
+ if (this.active === void 0) return void 0;
2794
+ for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return void 0;
2795
+ return this.active;
2796
+ };
2797
+ /**
2798
+ * The generation currently hosting the member's runtime — the active one,
2799
+ * or a retiring generation still draining it (the model broker retires the
2800
+ * member's runtime through whichever generation owns it on a model switch).
2801
+ */
2802
+ hostingDriver(childSessionId) {
2803
+ if (this.active?.hasRuntime(childSessionId) === true) return this.active;
2804
+ for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return retiring;
2805
+ }
2806
+ /** Mirror the resolved settings into the driver generation. */
2807
+ apply(next) {
2808
+ if (next.live === this.liveOn) return;
2809
+ this.liveOn = next.live;
2810
+ const retiring = this.active;
2811
+ this.active = next.live ? new ClaudeLiveDriver(this.ctx, { ...this.driverBase }) : void 0;
2812
+ if (retiring !== void 0) {
2813
+ this.draining.add(retiring);
2814
+ retiring.drain().then(() => {
2815
+ this.draining.delete(retiring);
2816
+ }, (error) => {
2817
+ this.draining.delete(retiring);
2818
+ this.ctx.logger.warn(`local-agent-claude-code: live driver drain failed: ${error instanceof Error ? error.message : String(error)}`);
2819
+ });
2820
+ }
2821
+ }
2822
+ /** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
2823
+ dispose() {
2824
+ this.unwatch();
2825
+ this.active?.disposeAll();
2826
+ for (const retiring of this.draining) retiring.disposeAll();
2827
+ this.draining.clear();
2828
+ }
2829
+ };
2830
+ //#endregion
2831
+ //#region lib/types/provision.js
2832
+ /**
2833
+ * Claude Code scoped-home provisioning. The harness authenticates by
2834
+ * endpoint key (see endpoint-auth.ts), so a scoped home needs nothing beyond
2835
+ * the directory itself: `claude -p` scaffolds `.claude.json` on its first
2836
+ * run, and the endpoint credential arrives per round through the spawn env
2837
+ * (resolved from the scope's endpoint-auth.json over the plugin config).
2838
+ * When `proxyUrl` is configured, the scoped `settings.json` gains an env
2839
+ * block pointing the CLI's own traffic at it — claude reads proxy settings
2840
+ * from this file, so the child works even when the host process environment
2841
+ * carries no proxy (a supervisor-spawned instance's env is not the user's
2842
+ * shell env).
2843
+ * @module @khorsheed/dsh-local-agent-claude-code/provision
2844
+ */
2845
+ /** The scoped settings file carrying the CLI's env block. */
2846
+ const SCOPED_SETTINGS = "settings.json";
2847
+ /**
2848
+ * Provision the scoped home directory. Claude creates it lazily on first
2849
+ * use, but creating it eagerly keeps the harness's homeDir contract uniform
2850
+ * with the other harnesses. Two maintenance duties run here:
2851
+ *
2852
+ * - Legacy OAuth sweep: the endpoint-key era never reads `.credentials.json`
2853
+ * or the `.claude.json` `oauthAccount` marker — both are deleted so a
2854
+ * pre-migration scoped home carries no dead grant (measured: claude
2855
+ * 2.1.296 prefers the env credential over a stored one anyway; the sweep
2856
+ * is hygiene, not priority repair). Other `.claude.json` keys survive.
2857
+ * - When `proxyUrl` is configured, the scoped `settings.json` gains an env
2858
+ * block pointing the CLI's own traffic at it — claude reads proxy settings
2859
+ * from this file, so the child works even when the host process environment
2860
+ * carries no proxy (a supervisor-spawned instance's env is not the user's
2861
+ * shell env). An existing settings file is merged, not overwritten; only
2862
+ * the two proxy keys are managed.
2863
+ * @param homeDir - the `claude-code` harness's scoped home.
2864
+ * @param proxyUrl - optional HTTP proxy URL for the child CLI's traffic.
2865
+ */
2866
+ async function provisionClaudeHome(homeDir, proxyUrl) {
2867
+ await mkdir(homeDir, { recursive: true });
2868
+ await sweepLegacyOauth(homeDir);
2869
+ if (proxyUrl === void 0) return;
2870
+ const path = join(homeDir, SCOPED_SETTINGS);
2871
+ let settings = {};
2872
+ try {
2873
+ settings = JSON.parse(await readFile(path, "utf8"));
2874
+ } catch {}
2875
+ const env = settings.env ?? {};
2876
+ if (env.https_proxy === proxyUrl && env.http_proxy === proxyUrl) return;
2877
+ env.https_proxy = proxyUrl;
2878
+ env.http_proxy = proxyUrl;
2879
+ settings.env = env;
2880
+ await writeFile(path, JSON.stringify(settings, void 0, 2) + "\n");
2881
+ }
2882
+ /**
2883
+ * Remove the OAuth era's artifacts from the scoped home: the
2884
+ * `.credentials.json` grant file outright, and the `oauthAccount` marker key
2885
+ * from `.claude.json` (the file itself carries live CLI state and stays).
2886
+ * Idempotent and best-effort per file — a missing or malformed file is
2887
+ * simply not a sweep target. The macOS keychain entry is deliberately left
2888
+ * alone: the CLI never consults it once an env credential rides the spawn,
2889
+ * and keychain writes are outside the harness's file-based contract.
2890
+ * @param homeDir - the `claude-code` harness's scoped home.
2891
+ */
2892
+ async function sweepLegacyOauth(homeDir) {
2893
+ await rm(join(homeDir, ".credentials.json"), { force: true });
2894
+ const configPath = join(homeDir, ".claude.json");
2895
+ let config;
2896
+ try {
2897
+ config = JSON.parse(await readFile(configPath, "utf8"));
2898
+ } catch {
2899
+ return;
2900
+ }
2901
+ if (typeof config !== "object" || config === null || !("oauthAccount" in config)) return;
2902
+ delete config["oauthAccount"];
2903
+ await writeFile(configPath, JSON.stringify(config, void 0, 2) + "\n");
2904
+ }
2905
+ /**
2906
+ * Read the scoped settings' configured model: the `model` key of
2907
+ * `<homeDir>/settings.json` — the model a delegation round pins when the
2908
+ * scoped settings name one at all. Claude's own default model is decided by
2909
+ * the CLI and is deliberately NOT guessed here. Only the scoped file is
2910
+ * consulted (never the host's `~/.claude`); absent or malformed content
2911
+ * yields undefined.
2912
+ * @param homeDir - the `claude-code` harness's scoped home.
2913
+ * @returns the configured model identifier, or undefined when none is set.
2914
+ */
2915
+ async function readClaudeConfiguredModel(homeDir) {
2916
+ let text;
2917
+ try {
2918
+ text = await readFile(join(homeDir, SCOPED_SETTINGS), "utf8");
2919
+ } catch {
2920
+ return;
2921
+ }
2922
+ try {
2923
+ const settings = JSON.parse(text);
2924
+ return typeof settings.model === "string" && settings.model !== "" ? settings.model : void 0;
2925
+ } catch {
2926
+ return;
2927
+ }
2928
+ }
2929
+ /** Read the scoped native effort setting without changing the settings file. */
2930
+ async function readClaudeConfiguredEffort(homeDir) {
2931
+ try {
2932
+ const settings = JSON.parse(await readFile(join(homeDir, SCOPED_SETTINGS), "utf8"));
2933
+ return typeof settings.effortLevel === "string" && settings.effortLevel.trim() !== "" ? settings.effortLevel.trim() : void 0;
2934
+ } catch {
2935
+ return;
2936
+ }
2937
+ }
2938
+ /**
2939
+ * Write (or, with undefined, remove) the scoped settings.json `model` key,
2940
+ * merging the rest of the file exactly as {@link provisionClaudeHome} merges
2941
+ * its env block. Only the live driver calls this: a resident runtime needs
2942
+ * its member's effective model IN THE FILE, because a `--resume` respawn
2943
+ * restores the session's own stored model over the `--model` flag. The exec
2944
+ * path never calls it — its `--model` flag always applies, and the file
2945
+ * stays exactly as the person edited it.
2946
+ * @param homeDir - the `claude-code` harness's scoped home.
2947
+ * @param model - the model to scratch in, or undefined to remove the key.
2948
+ */
2949
+ async function writeClaudeScopedModel(homeDir, model) {
2950
+ const path = join(homeDir, SCOPED_SETTINGS);
2951
+ let settings = {};
2952
+ try {
2953
+ settings = JSON.parse(await readFile(path, "utf8"));
2954
+ } catch {}
2955
+ if (model === void 0) {
2956
+ if (!("model" in settings)) return;
2957
+ delete settings["model"];
2958
+ } else {
2959
+ if (settings["model"] === model) return;
2960
+ settings["model"] = model;
2961
+ }
2962
+ await writeFile(path, JSON.stringify(settings, void 0, 2) + "\n");
2963
+ }
2964
+ //#endregion
2965
+ //#region lib/types/model-broker.js
2966
+ /**
2967
+ * The claude-code harness's model broker: the family gateway routes
2968
+ * `harnessModel` / `memberModel` / `setMemberModel` here, and the broker
2969
+ * answers the family's fixed resolution order — the session-level override
2970
+ * (the composer's picker), the delegation's recorded model, the plugin-config
2971
+ * `model` key (the settings card), the scoped settings.json's own `model`,
2972
+ * and last the CLI's built-in default, which names nothing.
2973
+ *
2974
+ * The override map is IN-MEMORY by design (it deliberately does not survive a
2975
+ * host restart) and SHARED: index.ts hands the same map to the provider's and
2976
+ * the live driver's model resolvers, so a composer switch reaches the next
2977
+ * round through every drive. With the live driver on, switching to a
2978
+ * DIFFERENT model retires the member's resident runtime so the next round
2979
+ * respawns onto the new model — the CLI session itself resumes on disk, so
2980
+ * the conversation carries over. A switch while a round is in flight is
2981
+ * refused: retiring a runtime mid-round would kill the run.
2982
+ *
2983
+ * `lastObserved` (the cli-builtin layer's display hint): the delegation
2984
+ * record's `observedModel` answers when the live-settle channel reported
2985
+ * one; the member's own CLI transcript (`transcriptModel`, a bounded read of
2986
+ * the projects tree) is the history backstop for rounds that predate that
2987
+ * channel — read-only, TTL-cached, never a write into `delegations.jsonl`.
2988
+ *
2989
+ * @module @khorsheed/dsh-local-agent-claude-code/model-broker — internal, unit-tested directly.
2990
+ */
2991
+ /**
2992
+ * The scoped settings.json `model` key across live-spawn scratch writes. The
2993
+ * live driver writes a member's effective model into the file before a
2994
+ * resident spawn (a `--resume` respawn restores the session's stored model
2995
+ * over the `--model` flag), and this memory keeps the person's own configured
2996
+ * value straight across those writes: what the file held before our first
2997
+ * write is the `cli-config` layer, and a spawn with NO effective model
2998
+ * restores that value instead of leaking the previous member's model into
2999
+ * the next runtime. In-memory on purpose: a restart re-reads the file as it
3000
+ * stands. Writes serialize on an internal queue so concurrent member spawns
3001
+ * cannot lose each other's read-modify-write.
3002
+ */
3003
+ var ClaudeScopedModelMemory = class {
3004
+ /** The value we last scratched in and believe the file still holds. */
3005
+ written;
3006
+ /** What the file held before our first write; null = it named no model. */
3007
+ previous = null;
3008
+ queue = Promise.resolve();
3009
+ /**
3010
+ * Bring the scoped settings.json's `model` key to the effective model a
3011
+ * spawn is about to bind — writing it, or restoring the person's value when
3012
+ * the spawn binds none and the file still holds our earlier scratch.
3013
+ * @param homeDir - the scoped home the spawn runs against.
3014
+ * @param model - the spawn's effective model, or undefined for none.
3015
+ */
3016
+ provision(homeDir, model) {
3017
+ const run = this.queue.then(() => this.provisionLocked(homeDir, model));
3018
+ this.queue = run.catch(() => void 0);
3019
+ return run;
3020
+ }
3021
+ /**
3022
+ * The person's configured model — the `cli-config` layer. A file value we
3023
+ * did not write reports as read; our own scratch value reports what the
3024
+ * file held before we wrote it.
3025
+ * @param homeDir - the scoped home to read.
3026
+ * @returns the person's configured model, or undefined when none.
3027
+ */
3028
+ async cliDefault(homeDir) {
3029
+ const file = await readClaudeConfiguredModel(homeDir);
3030
+ if (file !== this.written) {
3031
+ this.previous = file ?? null;
3032
+ this.written = void 0;
3033
+ return file;
3034
+ }
3035
+ return this.previous ?? void 0;
3036
+ }
3037
+ async provisionLocked(homeDir, model) {
3038
+ const file = await readClaudeConfiguredModel(homeDir);
3039
+ if (file !== this.written) {
3040
+ this.previous = file ?? null;
3041
+ this.written = void 0;
3042
+ }
3043
+ if (model === void 0) {
3044
+ if (this.written === void 0) return;
3045
+ await writeClaudeScopedModel(homeDir, this.previous ?? void 0);
3046
+ this.written = void 0;
3047
+ return;
3048
+ }
3049
+ if (file === model) return;
3050
+ await writeClaudeScopedModel(homeDir, model);
3051
+ this.written = model;
3052
+ }
3053
+ };
3054
+ var ClaudeModelBroker = class {
3055
+ deps;
3056
+ constructor(deps) {
3057
+ this.deps = deps;
3058
+ }
3059
+ configurationAdapter(binding) {
3060
+ const context = this.deps.configurationContext?.(binding);
3061
+ if (context === void 0) throw new Error("Claude member configuration context is unavailable");
3062
+ const resolve = async (selection) => {
3063
+ let directory = await context.directory();
3064
+ if (directory.status === "loading") directory = await context.directory(true);
3065
+ 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;
3066
+ const entry = directory.entries.find((entry) => entry.value === model || entry.resolvedModel === model);
3067
+ const effort = (selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0) ?? await readClaudeConfiguredEffort(context.home) ?? entry?.reasoning?.default;
3068
+ 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");
3069
+ return {
3070
+ ...model === void 0 ? {} : { model },
3071
+ ...effort === void 0 ? {} : { effort }
3072
+ };
3073
+ };
3074
+ const retire = async () => {
3075
+ await this.deps.liveSwitch.hostingDriver(binding.childSessionId)?.retireRuntime(binding.childSessionId);
3076
+ };
3077
+ return {
3078
+ validate: async (selection) => {
3079
+ await resolve(selection);
3080
+ },
3081
+ prepare: resolve,
3082
+ apply: async (selection) => {
3083
+ const resolved = await resolve(selection);
3084
+ await retire();
3085
+ return resolved;
3086
+ },
3087
+ reconcile: async (state) => {
3088
+ if (this.deps.localAgent.isDelegationActive(binding.childSessionId)) return {
3089
+ active: true,
3090
+ matches: "unknown",
3091
+ resolved: {}
3092
+ };
3093
+ await retire();
3094
+ return {
3095
+ active: false,
3096
+ matches: "current",
3097
+ resolved: await resolve(state.current.selection)
3098
+ };
3099
+ }
3100
+ };
3101
+ }
3102
+ async modelDirectory(childSessionId, refresh = false) {
3103
+ if (refresh) await this.deps.directory?.(childSessionId, true);
3104
+ return (await this.modelInfo(childSessionId, childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId)?.model)).directory ?? {
3105
+ entries: [],
3106
+ complete: false,
3107
+ customInput: true,
3108
+ status: "unsupported",
3109
+ refreshing: false,
3110
+ revision: 0
3111
+ };
3092
3112
  }
3093
- }
3094
- //#endregion
3095
- //#region lib/types/live-switch.js
3096
- /**
3097
- * The settings-driven live-driver switch: owns the active driver generation
3098
- * and retires old ones on a settings change, without a plugin reload.
3099
- *
3100
- * Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
3101
- * new rounds are refused (the provider's catch falls back to exec), in-flight
3102
- * rounds finish on their runtime, idle runtimes are reclaimed at once.
3103
- * Toggling ON builds the next generation lazily (no process until the first
3104
- * round). Legacy granularity settings are ignored.
3105
- *
3106
- * @module @khorsheed/dsh-local-agent-claude-code — internal, unit-tested directly.
3107
- */
3108
- var LiveDriverSwitch = class {
3109
- ctx;
3110
- driverBase;
3111
- liveOn = false;
3112
- active;
3113
- /** Retiring generations still draining their in-flight rounds. */
3114
- draining = /* @__PURE__ */ new Set();
3115
- unwatch;
3116
- constructor(ctx, face, driverBase) {
3117
- this.ctx = ctx;
3118
- this.driverBase = driverBase;
3119
- this.apply(face.get());
3120
- this.unwatch = face.watch((next) => {
3121
- this.apply(next);
3122
- });
3113
+ async *followModelDirectory(childSessionId, signal) {
3114
+ if (this.deps.followDirectory === void 0) {
3115
+ yield await this.modelDirectory(childSessionId);
3116
+ return;
3117
+ }
3118
+ for await (const _snapshot of this.deps.followDirectory(childSessionId, signal)) yield await this.modelDirectory(childSessionId);
3123
3119
  }
3124
3120
  /**
3125
- * Resolve the live driver for one round's member (the provider's call
3126
- * site). A retiring generation still hosting the member gates the new one —
3127
- * the round falls back to exec rather than double-loading the same claude
3128
- * session into two processes.
3121
+ * The `lastObserved` cache: childSessionId (empty key = the memberless
3122
+ * harness read) → the answer and when it was read. A miss is cached too —
3123
+ * a member with no transcript yet should not pay a rescan per card open.
3129
3124
  */
3130
- resolve = (childSessionId) => {
3131
- if (this.active === void 0) return void 0;
3132
- for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return void 0;
3133
- return this.active;
3134
- };
3125
+ observedCache = /* @__PURE__ */ new Map();
3135
3126
  /**
3136
- * The generation currently hosting the member's runtime — the active one,
3137
- * or a retiring generation still draining it (the model broker retires the
3138
- * member's runtime through whichever generation owns it on a model switch).
3127
+ * The last model the member (or the harness) was observed running. The
3128
+ * live-settle channel is primary — a record whose latest round reported
3129
+ * `observedModel` answers without touching disk; the CLI's own transcripts
3130
+ * are the history backstop for every round that predates it.
3139
3131
  */
3140
- hostingDriver(childSessionId) {
3141
- if (this.active?.hasRuntime(childSessionId) === true) return this.active;
3142
- for (const retiring of this.draining) if (retiring.hasRuntime(childSessionId)) return retiring;
3132
+ async lastObserved(childSessionId) {
3133
+ const record = childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId);
3134
+ if (childSessionId !== void 0 && record === void 0) return void 0;
3135
+ if (record?.observedModel !== void 0 && record.observedModel.trim() !== "") return record.observedModel;
3136
+ const key = childSessionId ?? "";
3137
+ const hit = this.observedCache.get(key);
3138
+ if (hit !== void 0 && Date.now() - hit.at < 6e4) return hit.model;
3139
+ const model = await this.deps.transcriptModel(record?.cliSessionId, childSessionId).catch(() => void 0);
3140
+ this.observedCache.set(key, {
3141
+ at: Date.now(),
3142
+ model
3143
+ });
3144
+ return model;
3143
3145
  }
3144
- /** Mirror the resolved settings into the driver generation. */
3145
- apply(next) {
3146
- if (next.live === this.liveOn) return;
3147
- this.liveOn = next.live;
3148
- const retiring = this.active;
3149
- this.active = next.live ? new ClaudeLiveDriver(this.ctx, { ...this.driverBase }) : void 0;
3150
- if (retiring !== void 0) {
3151
- this.draining.add(retiring);
3152
- retiring.drain().then(() => {
3153
- this.draining.delete(retiring);
3154
- }, (error) => {
3155
- this.draining.delete(retiring);
3156
- this.ctx.logger.warn(`local-agent-claude-code: live driver drain failed: ${error instanceof Error ? error.message : String(error)}`);
3157
- });
3158
- }
3146
+ /**
3147
+ * Read the model surface in the family's fixed order. Without a member the
3148
+ * override and delegation layers stay absent — the settings card's "what
3149
+ * would a round run with" read.
3150
+ */
3151
+ async modelInfo(childSessionId, delegationModel) {
3152
+ const record = childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId);
3153
+ const override = childSessionId === void 0 ? void 0 : this.deps.overrides.get(childSessionId);
3154
+ const delegation = delegationModel?.trim() === "" ? void 0 : delegationModel?.trim();
3155
+ const settings = this.deps.settingsModel(record?.scope);
3156
+ const cliDefault = await this.deps.cliDefault(childSessionId);
3157
+ const native = await this.deps.directory?.(childSessionId);
3158
+ 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"];
3159
+ const curated = this.deps.modelList?.(record?.scope) ?? [];
3160
+ const choices = curated.length > 0 ? [...curated] : [...new Set([
3161
+ settings,
3162
+ cliDefault,
3163
+ ...native?.entries.filter((entry) => !entry.hidden).map((entry) => entry.value) ?? [],
3164
+ ...this.deps.recentModels()
3165
+ ].filter((value) => value !== void 0))];
3166
+ const lastObserved = await this.lastObserved(childSessionId);
3167
+ const active = childSessionId !== void 0 && this.deps.localAgent.isDelegationActive(childSessionId);
3168
+ return {
3169
+ ...effective === void 0 ? {} : { effective },
3170
+ source,
3171
+ ...override === void 0 ? {} : { override },
3172
+ ...delegation === void 0 ? {} : { delegation },
3173
+ ...settings === void 0 ? {} : { settings },
3174
+ ...cliDefault === void 0 ? {} : { cliDefault },
3175
+ ...lastObserved === void 0 ? {} : { lastObserved },
3176
+ choices,
3177
+ ...native === void 0 ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], curated.length > 0 ? [] : [...this.deps.recentModels(), lastObserved]) },
3178
+ live: this.deps.live(),
3179
+ switchable: !active,
3180
+ ...active ? { reason: "该成员有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight)" } : {}
3181
+ };
3159
3182
  }
3160
- /** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
3161
- dispose() {
3162
- this.unwatch();
3163
- this.active?.disposeAll();
3164
- for (const retiring of this.draining) retiring.disposeAll();
3165
- this.draining.clear();
3183
+ /**
3184
+ * Set or clear a member's session-level override. Throws while a round is
3185
+ * in flight for the member; a same-model set is a no-op; a real change
3186
+ * retires the member's resident runtime when its bound model is no longer
3187
+ * what the next round would spawn with (the CLI session resumes on the
3188
+ * respawn, so the conversation carries over).
3189
+ */
3190
+ async setMemberModel(childSessionId, model) {
3191
+ if (this.deps.localAgent.setMemberModel !== void 0) return this.deps.localAgent.setMemberModel(childSessionId, model);
3192
+ if (this.deps.localAgent.isDelegationActive(childSessionId)) throw new Error(`subagent-claude: 成员 ${childSessionId} 有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight for this member)`);
3193
+ const trimmed = model?.trim();
3194
+ const next = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
3195
+ if (next === this.deps.overrides.get(childSessionId)) return;
3196
+ if (next === void 0) this.deps.overrides.delete(childSessionId);
3197
+ else this.deps.overrides.set(childSessionId, next);
3198
+ const record = this.deps.localAgent.getDelegation(childSessionId);
3199
+ const delegation = record?.model?.trim();
3200
+ const spawnModel = next ?? (delegation === void 0 || delegation === "" ? void 0 : delegation) ?? this.deps.settingsModel(record?.scope);
3201
+ const host = this.deps.liveSwitch.hostingDriver(childSessionId);
3202
+ if (host !== void 0 && host.runtimeModel(childSessionId) !== spawnModel) await host.retireRuntime(childSessionId);
3166
3203
  }
3167
3204
  };
3168
3205
  //#endregion
3169
- //#region lib/types/provision.js
3206
+ //#region lib/types/records.js
3207
+ /**
3208
+ * Claude Code session records adapter: scans the scoped home's `projects/`
3209
+ * session files (one JSONL per session under `projects/<cwd-slug>/`, the
3210
+ * slug being the workspace path with separators replaced by dashes), plus
3211
+ * the transcript model read-back ({@link readClaudeTranscriptModel}) the
3212
+ * model broker's `lastObserved` backstop runs on. The
3213
+ * slug is a LOSSY encoding of the cwd — different paths can collide on the
3214
+ * same slug — so the listed cwd always comes from the file CONTENT (the
3215
+ * first `user` event's `cwd` field), never from the directory name. Only the
3216
+ * head of each file is read — the first user event carries the id, cwd, and
3217
+ * timestamp the listing needs; the rest of the file (conversation) is not
3218
+ * parsed. Files are append-only with torn-tail tolerance, and contents are
3219
+ * always untrusted input.
3220
+ *
3221
+ * Credentials are NOT this module's business: the harness authenticates by
3222
+ * endpoint key only (see endpoint-auth.ts), which needs no keychain read,
3223
+ * no `.credentials.json` reconcile, and no OAuth marker probe.
3224
+ * @module @khorsheed/dsh-local-agent-claude-code/records
3225
+ */
3226
+ /** Session files live under this directory inside the scoped home. */
3227
+ const PROJECTS_ROOT = "projects";
3228
+ /** Bound on the head prefix read from one session file. */
3229
+ const HEAD_BYTES = 65536;
3230
+ /**
3231
+ * Bound on the tail read when extracting a transcript's model. Assistant
3232
+ * lines are frequent, so the last window almost always names one; a bigger
3233
+ * conversation is never read whole.
3234
+ */
3235
+ const TRANSCRIPT_TAIL_BYTES = 262144;
3236
+ /** Cap on project directories the transcript reads scan. */
3237
+ const TRANSCRIPT_SCAN_DIRS = 200;
3238
+ /** Cap on transcript files the harness-level newest-scan stats. */
3239
+ const TRANSCRIPT_SCAN_FILES = 500;
3240
+ /** Cap on sidechain (`agent-*.jsonl`) candidates inspected for one session id. */
3241
+ const TRANSCRIPT_SIDECHAIN_FILES = 100;
3242
+ /** How many newest transcripts the harness-level read tries before giving up. */
3243
+ const TRANSCRIPT_NEWEST_TRIES = 3;
3170
3244
  /**
3171
- * Claude Code scoped-home provisioning. Unlike codex (whose credential store
3172
- * must be pinned to a file) and kimi (which needs a provider config),
3173
- * claude's `CLAUDE_CONFIG_DIR` scoped home needs nothing beyond the
3174
- * directory itself: login via `claude auth login` writes `.claude.json`
3175
- * (with the `oauthAccount` record) into it, and the real credential lands in
3176
- * the macOS keychain under a hashed entry or — on Linux, despite upstream
3177
- * bug #47661 — in the default `~/.claude/.credentials.json` while the config
3178
- * stays scoped. Sign-out removes the scoped config so `status` reports not
3179
- * authenticated; the keychain entry on macOS persists until the CLI removes
3180
- * it, which the framework's file-based logout cannot reach.
3181
- * @module @khorsheed/dsh-local-agent-claude-code/provision
3245
+ * Parse the head of a session file for the listing fields. The first `user`
3246
+ * event carries `sessionId`, `cwd`, and `timestamp`; its `cwd` is the only
3247
+ * trustworthy workspace source (the directory slug is lossy). Unknown or
3248
+ * malformed values yield an undefined record rather than failing the list.
3249
+ * @param text - the raw session file head (only the head is read).
3250
+ * @returns the record, or undefined when no user event is found.
3182
3251
  */
3183
- /** The scoped config file holding the oauthAccount auth marker. */
3184
- const SCOPED_CONFIG = ".claude.json";
3185
- /** The scoped settings file carrying the CLI's env block. */
3186
- const SCOPED_SETTINGS = "settings.json";
3252
+ function projectRecord(text) {
3253
+ for (const raw of text.split("\n")) {
3254
+ const line = raw.trim();
3255
+ if (line === "") continue;
3256
+ let event;
3257
+ try {
3258
+ event = JSON.parse(line);
3259
+ } catch {
3260
+ continue;
3261
+ }
3262
+ if (event.type !== "user") continue;
3263
+ if (typeof event.sessionId !== "string" || event.sessionId === "") return void 0;
3264
+ if (typeof event.cwd !== "string") return void 0;
3265
+ const record = {
3266
+ id: event.sessionId,
3267
+ workDir: event.cwd
3268
+ };
3269
+ const startedAt = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : void 0;
3270
+ if (startedAt !== void 0 && Number.isFinite(startedAt)) record.startedAt = startedAt;
3271
+ return record;
3272
+ }
3273
+ }
3187
3274
  /**
3188
- * Provision the scoped home directory. Claude creates it lazily on first
3189
- * use, but creating it eagerly keeps the harness's homeDir contract uniform
3190
- * with the other harnesses. When `proxyUrl` is configured, the scoped
3191
- * `settings.json` gains an env block pointing the CLI's own traffic at it —
3192
- * claude reads proxy settings from this file, so the child works even when
3193
- * the host process environment carries no proxy (a supervisor-spawned
3194
- * instance's env is not the user's shell env). An existing settings file is
3195
- * merged, not overwritten; only the two proxy keys are managed.
3275
+ * Read the scoped home's session files. The listing walks the projects
3276
+ * directory and reads each jsonl head; a torn or malformed file is skipped
3277
+ * rather than failing the whole list.
3196
3278
  * @param homeDir - the `claude-code` harness's scoped home.
3197
- * @param proxyUrl - optional HTTP proxy URL for the child CLI's traffic.
3279
+ * @returns the parsed records, empty when no sessions exist yet.
3198
3280
  */
3199
- async function provisionClaudeHome(homeDir, proxyUrl) {
3200
- await mkdir(homeDir, { recursive: true });
3201
- if (proxyUrl === void 0) return;
3202
- const path = join(homeDir, SCOPED_SETTINGS);
3203
- let settings = {};
3281
+ async function listClaudeSessions(homeDir) {
3282
+ const records = [];
3283
+ let projectDirs;
3204
3284
  try {
3205
- settings = JSON.parse(await readFile(path, "utf8"));
3206
- } catch {}
3207
- const env = settings.env ?? {};
3208
- if (env.https_proxy === proxyUrl && env.http_proxy === proxyUrl) return;
3209
- env.https_proxy = proxyUrl;
3210
- env.http_proxy = proxyUrl;
3211
- settings.env = env;
3212
- await writeFile(path, JSON.stringify(settings, void 0, 2) + "\n");
3285
+ projectDirs = await readdir(join(homeDir, PROJECTS_ROOT));
3286
+ } catch (error) {
3287
+ if (error.code === "ENOENT") return [];
3288
+ throw error;
3289
+ }
3290
+ for (const projectDir of projectDirs) {
3291
+ let files;
3292
+ try {
3293
+ files = await readdir(join(homeDir, PROJECTS_ROOT, projectDir));
3294
+ } catch {
3295
+ continue;
3296
+ }
3297
+ for (const name of files) {
3298
+ if (!name.endsWith(".jsonl")) continue;
3299
+ try {
3300
+ const handle = await open(join(homeDir, PROJECTS_ROOT, projectDir, name), "r");
3301
+ let text;
3302
+ try {
3303
+ const buffer = Buffer.alloc(HEAD_BYTES);
3304
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
3305
+ text = buffer.subarray(0, bytesRead).toString("utf8");
3306
+ } finally {
3307
+ await handle.close();
3308
+ }
3309
+ const record = projectRecord(text);
3310
+ if (record !== void 0) records.push(record);
3311
+ } catch {
3312
+ continue;
3313
+ }
3314
+ }
3315
+ }
3316
+ return records;
3213
3317
  }
3214
3318
  /**
3215
- * Read the scoped settings' configured model: the `model` key of
3216
- * `<homeDir>/settings.json` — the model a delegation round pins when the
3217
- * scoped settings name one at all. Claude's own default model is decided by
3218
- * the CLI and is deliberately NOT guessed here. Only the scoped file is
3219
- * consulted (never the host's `~/.claude`); absent or malformed content
3220
- * yields undefined.
3221
- * @param homeDir - the `claude-code` harness's scoped home.
3222
- * @returns the configured model identifier, or undefined when none is set.
3319
+ * Extract the model one transcript names, reading its TAIL (bounded, never
3320
+ * the whole conversation) from the newest line backward: the first line
3321
+ * carrying a model wins — the top-level `model` of the stream-json init
3322
+ * event's shape, or the nested `message.model` of an assistant line. Newest
3323
+ * first because a session's later rounds may run a different model than its
3324
+ * first, and "what ran last" is the question the caller asks. A torn final
3325
+ * line is skipped, never failed on.
3326
+ * @param path - the transcript file.
3327
+ * @returns the model identifier, or undefined when the window names none.
3223
3328
  */
3224
- async function readClaudeConfiguredModel(homeDir) {
3329
+ async function transcriptFileModel(path) {
3225
3330
  let text;
3226
3331
  try {
3227
- text = await readFile(join(homeDir, SCOPED_SETTINGS), "utf8");
3332
+ const handle = await open(path, "r");
3333
+ try {
3334
+ const { size } = await handle.stat();
3335
+ const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
3336
+ const buffer = Buffer.alloc(length);
3337
+ const { bytesRead } = await handle.read(buffer, 0, length, Math.max(0, size - length));
3338
+ text = buffer.subarray(0, bytesRead).toString("utf8");
3339
+ } finally {
3340
+ await handle.close();
3341
+ }
3228
3342
  } catch {
3229
3343
  return;
3230
3344
  }
3231
- try {
3232
- const settings = JSON.parse(text);
3233
- return typeof settings.model === "string" && settings.model !== "" ? settings.model : void 0;
3234
- } catch {
3235
- return;
3345
+ const lines = text.split("\n");
3346
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
3347
+ const line = lines[index].trim();
3348
+ if (line === "") continue;
3349
+ let event;
3350
+ try {
3351
+ event = JSON.parse(line);
3352
+ } catch {
3353
+ continue;
3354
+ }
3355
+ if (typeof event.model === "string" && event.model !== "") return event.model;
3356
+ if (typeof event.message?.model === "string" && event.message.model !== "") return event.message.model;
3236
3357
  }
3237
3358
  }
3238
- /** Read the scoped native effort setting without changing the settings file. */
3239
- async function readClaudeConfiguredEffort(homeDir) {
3359
+ /** The project directories under one scoped home, capped; empty on any failure. */
3360
+ async function projectDirs(homeDir) {
3240
3361
  try {
3241
- const settings = JSON.parse(await readFile(join(homeDir, SCOPED_SETTINGS), "utf8"));
3242
- return typeof settings.effortLevel === "string" && settings.effortLevel.trim() !== "" ? settings.effortLevel.trim() : void 0;
3362
+ return (await readdir(join(homeDir, PROJECTS_ROOT))).slice(0, TRANSCRIPT_SCAN_DIRS);
3243
3363
  } catch {
3244
- return;
3364
+ return [];
3245
3365
  }
3246
3366
  }
3247
- /**
3248
- * Write (or, with undefined, remove) the scoped settings.json `model` key,
3249
- * merging the rest of the file exactly as {@link provisionClaudeHome} merges
3250
- * its env block. Only the live driver calls this: a resident runtime needs
3251
- * its member's effective model IN THE FILE, because a `--resume` respawn
3252
- * restores the session's own stored model over the `--model` flag. The exec
3253
- * path never calls it — its `--model` flag always applies, and the file
3254
- * stays exactly as the person edited it.
3255
- * @param homeDir - the `claude-code` harness's scoped home.
3256
- * @param model - the model to scratch in, or undefined to remove the key.
3257
- */
3258
- async function writeClaudeScopedModel(homeDir, model) {
3259
- const path = join(homeDir, SCOPED_SETTINGS);
3260
- let settings = {};
3367
+ /** The jsonl file names inside one project directory; empty on any failure. */
3368
+ async function projectFiles(homeDir, projectDir) {
3261
3369
  try {
3262
- settings = JSON.parse(await readFile(path, "utf8"));
3263
- } catch {}
3264
- if (model === void 0) {
3265
- if (!("model" in settings)) return;
3266
- delete settings["model"];
3267
- } else {
3268
- if (settings["model"] === model) return;
3269
- settings["model"] = model;
3370
+ return (await readdir(join(homeDir, PROJECTS_ROOT, projectDir))).filter((name) => name.endsWith(".jsonl"));
3371
+ } catch {
3372
+ return [];
3270
3373
  }
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
3374
  }
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
3375
  /**
3314
- * The scoped settings.json `model` key across live-spawn scratch writes. The
3315
- * live driver writes a member's effective model into the file before a
3316
- * resident spawn (a `--resume` respawn restores the session's stored model
3317
- * over the `--model` flag), and this memory keeps the person's own configured
3318
- * value straight across those writes: what the file held before our first
3319
- * write is the `cli-config` layer, and a spawn with NO effective model
3320
- * restores that value instead of leaking the previous member's model into
3321
- * the next runtime. In-memory on purpose: a restart re-reads the file as it
3322
- * stands. Writes serialize on an internal queue so concurrent member spawns
3323
- * cannot lose each other's read-modify-write.
3324
- */
3325
- var ClaudeScopedModelMemory = class {
3326
- /** The value we last scratched in and believe the file still holds. */
3327
- written;
3328
- /** What the file held before our first write; null = it named no model. */
3329
- previous = null;
3330
- queue = Promise.resolve();
3331
- /**
3332
- * Bring the scoped settings.json's `model` key to the effective model a
3333
- * spawn is about to bind — writing it, or restoring the person's value when
3334
- * the spawn binds none and the file still holds our earlier scratch.
3335
- * @param homeDir - the scoped home the spawn runs against.
3336
- * @param model - the spawn's effective model, or undefined for none.
3337
- */
3338
- provision(homeDir, model) {
3339
- const run = this.queue.then(() => this.provisionLocked(homeDir, model));
3340
- this.queue = run.catch(() => void 0);
3341
- return run;
3342
- }
3343
- /**
3344
- * The person's configured model — the `cli-config` layer. A file value we
3345
- * did not write reports as read; our own scratch value reports what the
3346
- * file held before we wrote it.
3347
- * @param homeDir - the scoped home to read.
3348
- * @returns the person's configured model, or undefined when none.
3349
- */
3350
- async cliDefault(homeDir) {
3351
- const file = await readClaudeConfiguredModel(homeDir);
3352
- if (file !== this.written) {
3353
- this.previous = file ?? null;
3354
- this.written = void 0;
3355
- return file;
3376
+ * Whether a sidechain (`agent-*.jsonl`) transcript belongs to one session:
3377
+ * its head lines carry the PARENT session's id (`isSidechain: true`). Only
3378
+ * the bounded head is read.
3379
+ */
3380
+ async function sidechainSessionMatches(path, cliSessionId) {
3381
+ let text;
3382
+ try {
3383
+ const handle = await open(path, "r");
3384
+ try {
3385
+ const buffer = Buffer.alloc(HEAD_BYTES);
3386
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
3387
+ text = buffer.subarray(0, bytesRead).toString("utf8");
3388
+ } finally {
3389
+ await handle.close();
3356
3390
  }
3357
- return this.previous ?? void 0;
3391
+ } catch {
3392
+ return false;
3358
3393
  }
3359
- async provisionLocked(homeDir, model) {
3360
- const file = await readClaudeConfiguredModel(homeDir);
3361
- if (file !== this.written) {
3362
- this.previous = file ?? null;
3363
- this.written = void 0;
3364
- }
3365
- if (model === void 0) {
3366
- if (this.written === void 0) return;
3367
- await writeClaudeScopedModel(homeDir, this.previous ?? void 0);
3368
- this.written = void 0;
3369
- return;
3394
+ for (const raw of text.split("\n")) {
3395
+ const line = raw.trim();
3396
+ if (line === "") continue;
3397
+ try {
3398
+ if (JSON.parse(line).sessionId === cliSessionId) return true;
3399
+ } catch {
3400
+ continue;
3370
3401
  }
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
3402
  }
3381
- configurationAdapter(binding) {
3382
- const context = this.deps.configurationContext?.(binding);
3383
- if (context === void 0) throw new Error("Claude member configuration context is unavailable");
3384
- const resolve = async (selection) => {
3385
- let directory = await context.directory();
3386
- if (directory.status === "loading") directory = await context.directory(true);
3387
- const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel() ?? await readClaudeConfiguredModel(context.home) ?? directory.defaultModel;
3388
- const entry = directory.entries.find((entry) => entry.value === model || entry.resolvedModel === model);
3389
- const effort = (selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0) ?? await readClaudeConfiguredEffort(context.home) ?? entry?.reasoning?.default;
3390
- if (effort !== void 0 && !entry?.reasoning?.options.some((option) => option.value === effort)) throw new Error("Claude has not advertised this reasoning effort for the selected model");
3391
- return {
3392
- ...model === void 0 ? {} : { model },
3393
- ...effort === void 0 ? {} : { effort }
3394
- };
3395
- };
3396
- const retire = async () => {
3397
- await this.deps.liveSwitch.hostingDriver(binding.childSessionId)?.retireRuntime(binding.childSessionId);
3398
- };
3399
- return {
3400
- validate: async (selection) => {
3401
- await resolve(selection);
3402
- },
3403
- prepare: resolve,
3404
- apply: async (selection) => {
3405
- const resolved = await resolve(selection);
3406
- await retire();
3407
- return resolved;
3408
- },
3409
- reconcile: async (state) => {
3410
- if (this.deps.localAgent.isDelegationActive(binding.childSessionId)) return {
3411
- active: true,
3412
- matches: "unknown",
3413
- resolved: {}
3414
- };
3415
- await retire();
3416
- return {
3417
- active: false,
3418
- matches: "current",
3419
- resolved: await resolve(state.current.selection)
3420
- };
3403
+ return false;
3404
+ }
3405
+ /**
3406
+ * The model a member's OWN CLI transcript names — the history backstop for
3407
+ * the broker's `lastObserved` when no delegation record observed one (every
3408
+ * member whose rounds predate the live-settle report). The live-settle
3409
+ * channel remains primary (the record's `observedModel` wins when present);
3410
+ * this reader only answers "what did this session last run" from the CLI's
3411
+ * own files, read-only — nothing is backfilled into `delegations.jsonl`.
3412
+ *
3413
+ * Location rule: with `cliSessionId`, the main transcript
3414
+ * `projects/<cwd-slug>/<cliSessionId>.jsonl` (found by NAME across the
3415
+ * project dirs — the slug is lossy, so it is never computed); when it is
3416
+ * absent, a sidechain `agent-*.jsonl` whose head names the session id
3417
+ * (bounded candidate count) answers instead. Without `cliSessionId` (the
3418
+ * settings card's memberless read), the NEWEST transcript across the
3419
+ * projects tree (mtime, bounded dir/file scan) answers; a newest file that
3420
+ * names no model yields to the next, up to a few tries. Every failure —
3421
+ * missing tree, unreadable file, no model in the read window — is
3422
+ * `undefined`, never a throw.
3423
+ * @param homeDir - the `claude-code` harness's scoped home.
3424
+ * @param cliSessionId - the delegation record's CLI session id, when the
3425
+ * member's own transcript is wanted.
3426
+ * @returns the model identifier, or undefined.
3427
+ */
3428
+ async function readClaudeTranscriptModel(homeDir, cliSessionId) {
3429
+ if (cliSessionId !== void 0) {
3430
+ let main;
3431
+ const sidechains = [];
3432
+ for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
3433
+ if (name === `${cliSessionId}.jsonl` && main === void 0) {
3434
+ main = join(homeDir, PROJECTS_ROOT, projectDir, name);
3435
+ continue;
3421
3436
  }
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;
3437
+ if (name.startsWith("agent-") && sidechains.length < TRANSCRIPT_SIDECHAIN_FILES) sidechains.push(join(homeDir, PROJECTS_ROOT, projectDir, name));
3439
3438
  }
3440
- for await (const _snapshot of this.deps.followDirectory(childSessionId, signal)) yield await this.modelDirectory(childSessionId);
3441
- }
3442
- /**
3443
- * The `lastObserved` cache: childSessionId (empty key = the memberless
3444
- * harness read) → the answer and when it was read. A miss is cached too —
3445
- * a member with no transcript yet should not pay a rescan per card open.
3446
- */
3447
- observedCache = /* @__PURE__ */ new Map();
3448
- /**
3449
- * The last model the member (or the harness) was observed running. The
3450
- * live-settle channel is primary — a record whose latest round reported
3451
- * `observedModel` answers without touching disk; the CLI's own transcripts
3452
- * are the history backstop for every round that predates it.
3453
- */
3454
- async lastObserved(childSessionId) {
3455
- const record = childSessionId === void 0 ? void 0 : this.deps.localAgent.getDelegation(childSessionId);
3456
- if (childSessionId !== void 0 && record === void 0) return void 0;
3457
- if (record?.observedModel !== void 0 && record.observedModel.trim() !== "") return record.observedModel;
3458
- const key = childSessionId ?? "";
3459
- const hit = this.observedCache.get(key);
3460
- if (hit !== void 0 && Date.now() - hit.at < 6e4) return hit.model;
3461
- const model = await this.deps.transcriptModel(record?.cliSessionId, childSessionId).catch(() => void 0);
3462
- this.observedCache.set(key, {
3463
- at: Date.now(),
3464
- model
3465
- });
3466
- return model;
3439
+ if (main !== void 0) {
3440
+ const model = await transcriptFileModel(main);
3441
+ if (model !== void 0) return model;
3442
+ }
3443
+ for (const path of sidechains) if (await sidechainSessionMatches(path, cliSessionId)) {
3444
+ const model = await transcriptFileModel(path);
3445
+ if (model !== void 0) return model;
3446
+ }
3447
+ return;
3467
3448
  }
3468
- /**
3469
- * Read the model surface in the family's fixed order. Without a member the
3470
- * override and delegation layers stay absent — the settings card's "what
3471
- * would a round run with" read.
3472
- */
3473
- async modelInfo(childSessionId, delegationModel) {
3474
- const override = childSessionId === void 0 ? void 0 : this.deps.overrides.get(childSessionId);
3475
- const delegation = delegationModel?.trim() === "" ? void 0 : delegationModel?.trim();
3476
- const settings = this.deps.settingsModel();
3477
- const cliDefault = await this.deps.cliDefault(childSessionId);
3478
- const native = await this.deps.directory?.(childSessionId);
3479
- const [effective, source] = override !== void 0 ? [override, "override"] : delegation !== void 0 ? [delegation, "delegation"] : settings !== void 0 ? [settings, "settings"] : cliDefault !== void 0 ? [cliDefault, "cli-config"] : [native?.defaultModel, "cli-builtin"];
3480
- const choices = [...new Set([
3481
- settings,
3482
- cliDefault,
3483
- ...native?.entries.filter((entry) => !entry.hidden).map((entry) => entry.value) ?? [],
3484
- ...this.deps.recentModels()
3485
- ].filter((value) => value !== void 0))];
3486
- const lastObserved = await this.lastObserved(childSessionId);
3487
- const active = childSessionId !== void 0 && this.deps.localAgent.isDelegationActive(childSessionId);
3488
- return {
3489
- ...effective === void 0 ? {} : { effective },
3490
- source,
3491
- ...override === void 0 ? {} : { override },
3492
- ...delegation === void 0 ? {} : { delegation },
3493
- ...settings === void 0 ? {} : { settings },
3494
- ...cliDefault === void 0 ? {} : { cliDefault },
3495
- ...lastObserved === void 0 ? {} : { lastObserved },
3496
- choices,
3497
- ...native === void 0 ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], [...this.deps.recentModels(), lastObserved]) },
3498
- live: this.deps.live(),
3499
- switchable: !active,
3500
- ...active ? { reason: "该成员有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight)" } : {}
3501
- };
3449
+ const candidates = [];
3450
+ let scanned = 0;
3451
+ for (const projectDir of await projectDirs(homeDir)) for (const name of await projectFiles(homeDir, projectDir)) {
3452
+ if (scanned >= TRANSCRIPT_SCAN_FILES) break;
3453
+ scanned += 1;
3454
+ const path = join(homeDir, PROJECTS_ROOT, projectDir, name);
3455
+ try {
3456
+ candidates.push({
3457
+ path,
3458
+ mtimeMs: (await stat(path)).mtimeMs
3459
+ });
3460
+ } catch {
3461
+ continue;
3462
+ }
3502
3463
  }
3503
- /**
3504
- * Set or clear a member's session-level override. Throws while a round is
3505
- * in flight for the member; a same-model set is a no-op; a real change
3506
- * retires the member's resident runtime when its bound model is no longer
3507
- * what the next round would spawn with (the CLI session resumes on the
3508
- * respawn, so the conversation carries over).
3509
- */
3510
- async setMemberModel(childSessionId, model) {
3511
- if (this.deps.localAgent.setMemberModel !== void 0) return this.deps.localAgent.setMemberModel(childSessionId, model);
3512
- if (this.deps.localAgent.isDelegationActive(childSessionId)) throw new Error(`subagent-claude: 成员 ${childSessionId} 有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight for this member)`);
3513
- const trimmed = model?.trim();
3514
- const next = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
3515
- if (next === this.deps.overrides.get(childSessionId)) return;
3516
- if (next === void 0) this.deps.overrides.delete(childSessionId);
3517
- else this.deps.overrides.set(childSessionId, next);
3518
- const delegation = this.deps.localAgent.getDelegation(childSessionId)?.model?.trim();
3519
- const spawnModel = next ?? (delegation === void 0 || delegation === "" ? void 0 : delegation) ?? this.deps.settingsModel();
3520
- const host = this.deps.liveSwitch.hostingDriver(childSessionId);
3521
- if (host !== void 0 && host.runtimeModel(childSessionId) !== spawnModel) await host.retireRuntime(childSessionId);
3464
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
3465
+ for (const candidate of candidates.slice(0, TRANSCRIPT_NEWEST_TRIES)) {
3466
+ const model = await transcriptFileModel(candidate.path);
3467
+ if (model !== void 0) return model;
3522
3468
  }
3523
- };
3469
+ }
3524
3470
  //#endregion
3525
3471
  //#region lib/types/index.js
3526
3472
  /**
3527
3473
  * Claude Code harness instantiation of the local-agent family. Registers the
3528
3474
  * `claude-code` harness into the core registry: scoped home under the shared
3529
- * homes root (`CLAUDE_CONFIG_DIR`), login as a manual handoff (claude ≥2.1
3530
- * prints no OAuth URL off a TTY, so `/login` replies with the exact terminal
3531
- * command and the registry watches for the credential), and project-file
3532
- * session records. Delegation mounts through the `claude-local` provider
3533
- * (one-shot `claude -p --output-format json` under the scoped home); the
3534
- * bundle patch's `tool-subagent-claude-code-local` row puts the tool on the
3535
- * profile root, so every agent preset can delegate.
3475
+ * homes root (`CLAUDE_CONFIG_DIR`), endpoint-key authentication (the scope's
3476
+ * `endpoint-auth.json` over the plugin config's `baseUrl`/`apiKey`/
3477
+ * `authToken` triple — no OAuth, no browser flow, nothing to authorize inside
3478
+ * a container), and project-file session records. Delegation mounts through
3479
+ * the `claude-local` provider (one-shot `claude -p` under the scoped home);
3480
+ * the bundle patch's `tool-subagent-claude-code-local` row puts the tool on
3481
+ * the profile root, so every agent preset can delegate.
3536
3482
  *
3537
3483
  * @module @khorsheed/dsh-local-agent-claude-code
3538
3484
  */
@@ -3547,7 +3493,8 @@ const inject = [
3547
3493
  ];
3548
3494
  /**
3549
3495
  * The settings fields (`live`, `liveMirrorGranularity`, `model`,
3550
- * `recentModels`) ride the plugin Config, marked `.volatile()` where the
3496
+ * `recentModels`, and the endpoint credential `baseUrl`/`apiKey`/`authToken`)
3497
+ * ride the plugin Config, marked `.volatile()` where the
3551
3498
  * host's schemastery has it (3.18.4+, the rc.1 line — probed, never sniffed):
3552
3499
  * there the SettingsForms writes hot-track the running fiber's Volatile
3553
3500
  * references without a remount, and the official one-shot import moves an old
@@ -3572,6 +3519,8 @@ const Config = z.object({
3572
3519
  permissionMode: z.union([z.const("skip"), z.const("normal")]),
3573
3520
  model: volatilize(SETTINGS_FIELDS.model),
3574
3521
  baseUrl: z.string(),
3522
+ apiKey: z.string(),
3523
+ authToken: z.string(),
3575
3524
  proxyUrl: z.string(),
3576
3525
  live: volatilize(SETTINGS_FIELDS.live),
3577
3526
  liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
@@ -3599,7 +3548,6 @@ const CLAUDE_SETTINGS_SCHEMA = z.object(SETTINGS_FIELDS);
3599
3548
  function apply(ctx, config) {
3600
3549
  ctx.effect(() => {
3601
3550
  const permissionMode = config.permissionMode ?? "skip";
3602
- const baseUrl = config.baseUrl;
3603
3551
  const homeDir = ctx.localAgent.homeDir("claude-code");
3604
3552
  provisionClaudeHome(homeDir, config.proxyUrl).catch((error) => {
3605
3553
  ctx.logger.warn(`local-agent-claude-code: scoped home provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -3629,10 +3577,17 @@ function apply(ctx, config) {
3629
3577
  legacyBase,
3630
3578
  read: readSettings
3631
3579
  });
3632
- const resolveModel = () => {
3580
+ const endpointConfig = {
3581
+ ...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
3582
+ ...config.apiKey === void 0 ? {} : { apiKey: config.apiKey },
3583
+ ...config.authToken === void 0 ? {} : { authToken: config.authToken }
3584
+ };
3585
+ const resolveEndpoint = (scopeHome) => resolveEndpointAuth(scopeHome, endpointConfig);
3586
+ const settingsModelKey = () => {
3633
3587
  const model = face.get().model?.trim();
3634
3588
  return model === void 0 || model === "" ? void 0 : model;
3635
3589
  };
3590
+ const resolveModel = (scope) => firstModelOf(readModelListSync(ctx.localAgent.homeDir("claude-code", scope))) ?? settingsModelKey();
3636
3591
  const overrides = /* @__PURE__ */ new Map();
3637
3592
  const scopedModelMemory = new ClaudeScopedModelMemory();
3638
3593
  const effectiveModel = (childSessionId, delegationModel) => {
@@ -3640,18 +3595,19 @@ function apply(ctx, config) {
3640
3595
  if (own !== void 0 && own !== "") return own;
3641
3596
  const named = delegationModel?.trim();
3642
3597
  if (named !== void 0 && named !== "") return named;
3643
- return resolveModel();
3598
+ const record = ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId);
3599
+ return resolveModel(record?.scope);
3644
3600
  };
3645
3601
  const liveSwitch = new LiveDriverSwitch(ctx, face, {
3646
3602
  ...config.permissionMode === void 0 ? {} : { permissionMode: config.permissionMode },
3647
- ...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
3603
+ endpoint: resolveEndpoint,
3648
3604
  ...config.liveIdleMs === void 0 ? {} : { liveIdleMs: config.liveIdleMs },
3649
3605
  model: effectiveModel,
3650
3606
  provisionModel: (home, model) => scopedModelMemory.provision(home, model)
3651
3607
  });
3652
3608
  const modelCatalog = new ClaudeModelCatalog({
3653
- spawn: (spec) => ctx.subprocess.spawn(spec),
3654
- ...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
3609
+ endpoint: resolveEndpoint,
3610
+ endpointLabel: () => config.baseUrl,
3655
3611
  warn: (message) => ctx.logger.warn(message)
3656
3612
  });
3657
3613
  const modelContext = (childSessionId) => {
@@ -3671,6 +3627,7 @@ function apply(ctx, config) {
3671
3627
  };
3672
3628
  },
3673
3629
  settingsModel: resolveModel,
3630
+ modelList: (scope) => readModelListSync(ctx.localAgent.homeDir("claude-code", scope)),
3674
3631
  cliDefault: (childSessionId) => {
3675
3632
  const context = modelContext(childSessionId);
3676
3633
  return context.home === homeDir ? scopedModelMemory.cliDefault(homeDir) : readClaudeConfiguredModel(context.home);
@@ -3689,7 +3646,7 @@ function apply(ctx, config) {
3689
3646
  liveSwitch,
3690
3647
  transcriptModel: (cliSessionId, childSessionId) => readClaudeTranscriptModel(modelContext(childSessionId).home, cliSessionId)
3691
3648
  });
3692
- const disposeProvider = ctx.subagents.registerProvider(new ClaudeCliProvider(ctx, permissionMode, baseUrl, liveSwitch.resolve, effectiveModel));
3649
+ const disposeProvider = ctx.subagents.registerProvider(new ClaudeCliProvider(ctx, permissionMode, resolveEndpoint, liveSwitch.resolve, effectiveModel));
3693
3650
  const disposeHarness = ctx.localAgent.register({
3694
3651
  prepareMember: async ({ binding, childSession, configuration, signal }) => {
3695
3652
  const driver = liveSwitch.resolve(binding.childSessionId);
@@ -3706,46 +3663,53 @@ function apply(ctx, config) {
3706
3663
  displayName: "Claude Code",
3707
3664
  homeEnvVar: "CLAUDE_CONFIG_DIR",
3708
3665
  delegationProvider: "claude-local",
3709
- login: {
3710
- pty: {
3711
- command: "env",
3712
- args: (scopedHome) => [
3713
- "-u",
3714
- "ANTHROPIC_API_KEY",
3715
- "-u",
3716
- "ANTHROPIC_BASE_URL",
3717
- `CLAUDE_CONFIG_DIR=${scopedHome}`,
3718
- "claude",
3719
- "auth",
3720
- "login"
3721
- ]
3666
+ records: { listSessions: (homeDir) => listClaudeSessions(homeDir) },
3667
+ provision: (scopedHome) => provisionClaudeHome(scopedHome, config.proxyUrl).then(() => {}),
3668
+ isAuthenticated: (home) => resolveEndpoint(home).then((endpoint) => hasEndpointCredential(endpoint)),
3669
+ checkEndpoint: (home) => resolveEndpoint(home).then((endpoint) => checkClaudeEndpoint(endpoint)),
3670
+ endpointAuth: {
3671
+ read: async (home) => {
3672
+ const endpoint = await resolveEndpoint(home);
3673
+ return {
3674
+ ...endpoint.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
3675
+ apiKeySet: endpoint.apiKey !== void 0,
3676
+ authTokenSet: endpoint.authToken !== void 0
3677
+ };
3722
3678
  },
3723
- watch: async (home) => {
3724
- await syncClaudeCredentialFile(home, (message) => {
3725
- ctx.logger.warn(message);
3726
- });
3727
- return claudeAuthenticated(home, (message) => {
3728
- ctx.logger.warn(message);
3729
- });
3679
+ write: async (home, fields) => {
3680
+ const next = { ...await readEndpointAuthFile(home) ?? {} };
3681
+ for (const [field, value] of Object.entries(fields)) {
3682
+ if (value === void 0) continue;
3683
+ const trimmed = typeof value === "string" ? value.trim() : "";
3684
+ if (value === null || trimmed === "") delete next[field];
3685
+ else next[field] = trimmed;
3686
+ }
3687
+ if (Object.keys(next).length === 0) await clearEndpointAuth(home);
3688
+ else await writeEndpointAuth(home, next);
3730
3689
  }
3731
3690
  },
3732
- records: { listSessions: (homeDir) => listClaudeSessions(homeDir) },
3733
- provision: (scopedHome) => provisionClaudeHome(scopedHome, config.proxyUrl).then(() => {}),
3734
- isAuthenticated: (home) => claudeAuthenticated(home, (message) => {
3735
- ctx.logger.warn(message);
3736
- }),
3737
- credentialStamp: claudeCredentialStamp,
3738
- logout: claudeLogout,
3691
+ credentialStamp: endpointAuthStamp,
3692
+ logout: async (home) => {
3693
+ if (await endpointAuthFileExists(home)) {
3694
+ await clearEndpointAuth(home);
3695
+ return;
3696
+ }
3697
+ 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");
3698
+ },
3739
3699
  modelBroker,
3700
+ modelList: {
3701
+ read: (home) => readModelList(home),
3702
+ write: (home, models) => writeModelList(home, models)
3703
+ },
3740
3704
  effectiveSettings: async (scopedHome) => {
3741
- const effectiveBaseUrl = baseUrl ?? process.env.ANTHROPIC_BASE_URL;
3742
- const baseUrlHost = effectiveBaseUrl !== void 0 ? endpointHost(effectiveBaseUrl) : void 0;
3705
+ const endpoint = await resolveEndpoint(scopedHome);
3706
+ const baseUrlHost = endpoint.baseUrl !== void 0 ? endpointHost(endpoint.baseUrl) : void 0;
3743
3707
  const [scopedModel, cliVersion] = await Promise.all([readClaudeConfiguredModel(scopedHome).catch(() => void 0), claudeCliVersion(ctx, scopedHome).catch(() => void 0)]);
3744
3708
  const model = resolveModel() ?? scopedModel;
3745
3709
  return {
3746
3710
  drive: face.get().live ? "live" : "exec",
3747
3711
  permissionMode,
3748
- baseUrlSet: effectiveBaseUrl !== void 0,
3712
+ baseUrlSet: endpoint.baseUrl !== void 0,
3749
3713
  ...baseUrlHost !== void 0 ? { baseUrlHost } : {},
3750
3714
  ...model !== void 0 ? { model } : {},
3751
3715
  ...cliVersion !== void 0 ? { cliVersion } : {}
@@ -3757,6 +3721,13 @@ function apply(ctx, config) {
3757
3721
  kind: "success",
3758
3722
  text: records.length === 0 ? "No claude sessions in the scoped home yet." : records.map((r) => `${r.id} ${r.workDir}`).join("\n")
3759
3723
  }));
3724
+ if (verb === "check") {
3725
+ const scopeHome = invocation.agent.ctx.localAgent.homeDir("claude-code");
3726
+ return resolveEndpoint(scopeHome).then((endpoint) => checkClaudeEndpoint(endpoint)).then((text) => ({
3727
+ kind: "success",
3728
+ text
3729
+ }));
3730
+ }
3760
3731
  }
3761
3732
  });
3762
3733
  return () => {