@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.
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Claude Code harness instantiation of the local-agent family. Registers the
3
3
  * `claude-code` harness into the core registry: scoped home under the shared
4
- * homes root (`CLAUDE_CONFIG_DIR`), login as a manual handoff (claude ≥2.1
5
- * prints no OAuth URL off a TTY, so `/login` replies with the exact terminal
6
- * command and the registry watches for the credential), and project-file
7
- * session records. Delegation mounts through the `claude-local` provider
8
- * (one-shot `claude -p --output-format json` under the scoped home); the
9
- * bundle patch's `tool-subagent-claude-code-local` row puts the tool on the
10
- * profile root, so every agent preset can delegate.
4
+ * homes root (`CLAUDE_CONFIG_DIR`), endpoint-key authentication (the scope's
5
+ * `endpoint-auth.json` over the plugin config's `baseUrl`/`apiKey`/
6
+ * `authToken` triple — no OAuth, no browser flow, nothing to authorize inside
7
+ * a container), and project-file session records. Delegation mounts through
8
+ * the `claude-local` provider (one-shot `claude -p` under the scoped home);
9
+ * the bundle patch's `tool-subagent-claude-code-local` row puts the tool on
10
+ * the profile root, so every agent preset can delegate.
11
11
  *
12
12
  * @module @khorsheed/dsh-local-agent-claude-code
13
13
  */
@@ -17,7 +17,7 @@ import z from '@deepseek-ai/schemastery';
17
17
  export declare const name = "local-agent-claude-code";
18
18
  /** Services required before the harness can register. */
19
19
  export declare const inject: string[];
20
- /** Plugin config: the permission mode fresh delegations default to, plus the live driver. */
20
+ /** Plugin config: the permission mode fresh delegations default to, the endpoint credential, plus the live driver. */
21
21
  export interface Config {
22
22
  /**
23
23
  * `claude -p` permission handling; `skip` passes
@@ -36,17 +36,33 @@ export interface Config {
36
36
  */
37
37
  model?: string;
38
38
  /**
39
- * `ANTHROPIC_BASE_URL` for the child CLI; absent inherits the host
40
- * process environment (a user-level proxy like `https://proxy.example.com/anthropic`
41
- * is typically exported there). Set when delegations must route through a
42
- * different endpoint than the login flow used.
39
+ * The deployment-level endpoint: `ANTHROPIC_BASE_URL` for every delegation
40
+ * (e.g. a relay like `https://openproxy.to/claude`, or absent for the
41
+ * official API). A scope's own `endpoint-auth.json` overrides it field by
42
+ * field — the settings card's credential form edits THAT file (never the
43
+ * profile). See endpoint-auth.ts for the whole auth model — there is no
44
+ * OAuth path in this package.
43
45
  */
44
46
  baseUrl?: string;
45
47
  /**
46
- * HTTP proxy for the child CLI's own traffic (model calls AND OAuth
47
- * refresh), provisioned into the scoped `settings.json` env block. Needed
48
- * when the host process environment carries no proxy (a supervisor-spawned
49
- * instance does not inherit the user's shell exports).
48
+ * The deployment-level credential, sent as `ANTHROPIC_API_KEY` (the
49
+ * `x-api-key` header). Keep it in the LOCAL profile row only — never commit
50
+ * it (the repo hygiene gate blocks credential-shaped strings, but the
51
+ * profile is not the repo's business).
52
+ */
53
+ apiKey?: string;
54
+ /**
55
+ * The deployment-level credential, sent as `ANTHROPIC_AUTH_TOKEN` (the
56
+ * `Authorization: Bearer` header) — some relays expect Bearer instead of
57
+ * `x-api-key`. When both keys are set the CLI's own precedence applies.
58
+ */
59
+ authToken?: string;
60
+ /**
61
+ * HTTP proxy for the child CLI's own traffic, provisioned into the scoped
62
+ * `settings.json` env block. Needed when the host process environment
63
+ * carries no proxy (a supervisor-spawned instance does not inherit the
64
+ * user's shell exports). Distinct from {@link baseUrl}: this is a local
65
+ * traffic forwarder, not the model endpoint.
50
66
  */
51
67
  proxyUrl?: string;
52
68
  /**
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Claude Code harness instantiation of the local-agent family. Registers the
3
3
  * `claude-code` harness into the core registry: scoped home under the shared
4
- * homes root (`CLAUDE_CONFIG_DIR`), login as a manual handoff (claude ≥2.1
5
- * prints no OAuth URL off a TTY, so `/login` replies with the exact terminal
6
- * command and the registry watches for the credential), and project-file
7
- * session records. Delegation mounts through the `claude-local` provider
8
- * (one-shot `claude -p --output-format json` under the scoped home); the
9
- * bundle patch's `tool-subagent-claude-code-local` row puts the tool on the
10
- * profile root, so every agent preset can delegate.
4
+ * homes root (`CLAUDE_CONFIG_DIR`), endpoint-key authentication (the scope's
5
+ * `endpoint-auth.json` over the plugin config's `baseUrl`/`apiKey`/
6
+ * `authToken` triple — no OAuth, no browser flow, nothing to authorize inside
7
+ * a container), and project-file session records. Delegation mounts through
8
+ * the `claude-local` provider (one-shot `claude -p` under the scoped home);
9
+ * the bundle patch's `tool-subagent-claude-code-local` row puts the tool on
10
+ * the profile root, so every agent preset can delegate.
11
11
  *
12
12
  * @module @khorsheed/dsh-local-agent-claude-code
13
13
  */
@@ -15,19 +15,22 @@ import z from '@deepseek-ai/schemastery';
15
15
  import { settingsFace, vol } from '@khorsheed/dsh-local-agent';
16
16
  import { endpointHost } from '@khorsheed/dsh-local-agent/types';
17
17
  import { ClaudeCliProvider, claudeCliVersion } from './claude-cli-provider.js';
18
+ import { checkClaudeEndpoint, clearEndpointAuth, endpointAuthFileExists, endpointAuthStamp, hasEndpointCredential, readEndpointAuthFile, resolveEndpointAuth, writeEndpointAuth, } from './endpoint-auth.js';
18
19
  import { DEFAULT_LIVE_IDLE_MS } from './live-driver.js';
19
20
  import { LiveDriverSwitch } from './live-switch.js';
20
21
  import { ClaudeModelCatalog } from './model-catalog.js';
21
22
  import { ClaudeModelBroker, ClaudeScopedModelMemory } from './model-broker.js';
22
- import { claudeAuthenticated, claudeCredentialStamp, listClaudeSessions, readClaudeTranscriptModel, syncClaudeCredentialFile } from './records.js';
23
- import { claudeLogout, provisionClaudeHome, readClaudeConfiguredModel } from './provision.js';
23
+ import { firstModelOf, readModelList, readModelListSync, writeModelList } from '@khorsheed/dsh-local-agent';
24
+ import { listClaudeSessions, readClaudeTranscriptModel } from './records.js';
25
+ import { provisionClaudeHome, readClaudeConfiguredModel } from './provision.js';
24
26
  /** Stable Cordis plugin name; the bundle patch row id. */
25
27
  export const name = 'local-agent-claude-code';
26
28
  /** Services required before the harness can register. */
27
29
  export const inject = ['localAgent', 'subagents', 'subprocess', 'settings'];
28
30
  /**
29
31
  * The settings fields (`live`, `liveMirrorGranularity`, `model`,
30
- * `recentModels`) ride the plugin Config, marked `.volatile()` where the
32
+ * `recentModels`, and the endpoint credential `baseUrl`/`apiKey`/`authToken`)
33
+ * ride the plugin Config, marked `.volatile()` where the
31
34
  * host's schemastery has it (3.18.4+, the rc.1 line — probed, never sniffed):
32
35
  * there the SettingsForms writes hot-track the running fiber's Volatile
33
36
  * references without a remount, and the official one-shot import moves an old
@@ -58,6 +61,8 @@ export const Config = z.object({
58
61
  ]),
59
62
  model: volatilize(SETTINGS_FIELDS.model),
60
63
  baseUrl: z.string(),
64
+ apiKey: z.string(),
65
+ authToken: z.string(),
61
66
  proxyUrl: z.string(),
62
67
  live: volatilize(SETTINGS_FIELDS.live),
63
68
  liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
@@ -85,7 +90,6 @@ const CLAUDE_SETTINGS_SCHEMA = z.object(SETTINGS_FIELDS);
85
90
  export function apply(ctx, config) {
86
91
  ctx.effect(() => {
87
92
  const permissionMode = config.permissionMode ?? DEFAULT_PERMISSION_MODE;
88
- const baseUrl = config.baseUrl;
89
93
  const homeDir = ctx.localAgent.homeDir('claude-code');
90
94
  // Claude creates the scoped home lazily; create it eagerly so the
91
95
  // harness's homeDir contract is uniform with the other harnesses.
@@ -123,15 +127,28 @@ export function apply(ctx, config) {
123
127
  legacyBase,
124
128
  read: readSettings,
125
129
  });
126
- // The model is read PER ROUND, not captured at apply: the settings card
127
- // writes the same namespace field, so a change has to reach the next
128
- // delegation without a plugin reload — exactly like the live toggle.
129
- // Blank is not a model: a whitespace-only value means "unset", which is
130
- // the pre-key argv.
131
- const resolveModel = () => {
130
+ // The endpoint credential's deployment level: the plugin config triple,
131
+ // merged per field UNDER the scope's own endpoint-auth.json at resolve
132
+ // time. Resolved PER READ against the scope's home — a card write (which
133
+ // lands in the file through the endpointAuth face) takes effect on the
134
+ // next round with no reload.
135
+ const endpointConfig = {
136
+ ...config.baseUrl === undefined ? {} : { baseUrl: config.baseUrl },
137
+ ...config.apiKey === undefined ? {} : { apiKey: config.apiKey },
138
+ ...config.authToken === undefined ? {} : { authToken: config.authToken },
139
+ };
140
+ const resolveEndpoint = (scopeHome) => resolveEndpointAuth(scopeHome, endpointConfig);
141
+ // The memberless model key: the plugin-config/settings `model`, blank = unset.
142
+ const settingsModelKey = () => {
132
143
  const model = face.get().model?.trim();
133
144
  return model === undefined || model === '' ? undefined : model;
134
145
  };
146
+ // The harness's default model for a scope: the curated model list's
147
+ // FIRST entry (models.json, edited on the card), ahead of the settings
148
+ // key; an empty list leaves the pre-existing chain untouched. Read sync
149
+ // per call — one small file, and a card write always reaches the next
150
+ // round.
151
+ const resolveModel = (scope) => firstModelOf(readModelListSync(ctx.localAgent.homeDir('claude-code', scope))) ?? settingsModelKey();
135
152
  // The member model layers above the settings key: the session-level
136
153
  // override map (the composer's picker writes it through the broker; both
137
154
  // round paths and the live spawn read it) and the scoped-settings.json
@@ -152,18 +169,24 @@ export function apply(ctx, config) {
152
169
  const named = delegationModel?.trim();
153
170
  if (named !== undefined && named !== '')
154
171
  return named;
155
- return resolveModel();
172
+ // The member's scope decides whose list answers: a named scope's rounds
173
+ // read that scope's models.json, exactly like its endpoint credential.
174
+ const record = ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId);
175
+ return resolveModel(record?.scope);
156
176
  };
157
177
  const liveSwitch = new LiveDriverSwitch(ctx, face, {
158
178
  ...config.permissionMode === undefined ? {} : { permissionMode: config.permissionMode },
159
- ...config.baseUrl === undefined ? {} : { baseUrl: config.baseUrl },
179
+ endpoint: resolveEndpoint,
160
180
  ...config.liveIdleMs === undefined ? {} : { liveIdleMs: config.liveIdleMs },
161
181
  model: effectiveModel,
162
182
  provisionModel: (home, model) => scopedModelMemory.provision(home, model),
163
183
  });
164
184
  const modelCatalog = new ClaudeModelCatalog({
165
- spawn: spec => ctx.subprocess.spawn(spec),
166
- ...config.baseUrl === undefined ? {} : { baseUrl: config.baseUrl },
185
+ endpoint: resolveEndpoint,
186
+ // Re-key on the config baseUrl; the scope file's own edits (the card's
187
+ // credential writes land there) are covered by its fingerprint in the
188
+ // key's file list.
189
+ endpointLabel: () => config.baseUrl,
167
190
  warn: message => ctx.logger.warn(message),
168
191
  });
169
192
  const modelContext = (childSessionId) => {
@@ -177,6 +200,7 @@ export function apply(ctx, config) {
177
200
  return { home, directory: refresh => refresh ? modelCatalog.refresh(home, binding.cwd) : modelCatalog.read(home, binding.cwd) };
178
201
  },
179
202
  settingsModel: resolveModel,
203
+ modelList: scope => readModelListSync(ctx.localAgent.homeDir('claude-code', scope)),
180
204
  cliDefault: childSessionId => {
181
205
  const context = modelContext(childSessionId);
182
206
  return context.home === homeDir ? scopedModelMemory.cliDefault(homeDir) : readClaudeConfiguredModel(context.home);
@@ -198,7 +222,7 @@ export function apply(ctx, config) {
198
222
  // live-settle report.
199
223
  transcriptModel: (cliSessionId, childSessionId) => readClaudeTranscriptModel(modelContext(childSessionId).home, cliSessionId),
200
224
  });
201
- const disposeProvider = ctx.subagents.registerProvider(new ClaudeCliProvider(ctx, permissionMode, baseUrl, liveSwitch.resolve, effectiveModel));
225
+ const disposeProvider = ctx.subagents.registerProvider(new ClaudeCliProvider(ctx, permissionMode, resolveEndpoint, liveSwitch.resolve, effectiveModel));
202
226
  const disposeHarness = ctx.localAgent.register({
203
227
  prepareMember: async ({ binding, childSession, configuration, signal }) => {
204
228
  const driver = liveSwitch.resolve(binding.childSessionId);
@@ -211,57 +235,90 @@ export function apply(ctx, config) {
211
235
  displayName: 'Claude Code',
212
236
  homeEnvVar: 'CLAUDE_CONFIG_DIR',
213
237
  delegationProvider: 'claude-local',
214
- login: {
215
- // claude ≥2.1 (verified 2.1.235+) runs its auth only on a TTY: under
216
- // the pty wrapper it auto-opens the user's browser and prints the
217
- // OAuth URL as fallback; the page hands back a code the user pastes
218
- // via /claude-code code <value>. The relay env is scrubbed and the
219
- // scoped home pinned so the credential lands where delegations read
220
- // it (the wrapper passes the harness env through).
221
- // The argv pins the config directory itself (an `env NAME=VALUE`
222
- // assignment outranks the spawn env), so it is built from the
223
- // directory being logged in: `/claude-code login --scope <name>`
224
- // authorizes THAT scope, not the default one.
225
- pty: {
226
- command: 'env',
227
- args: (scopedHome) => ['-u', 'ANTHROPIC_API_KEY', '-u', 'ANTHROPIC_BASE_URL', `CLAUDE_CONFIG_DIR=${scopedHome}`, 'claude', 'auth', 'login'],
228
- },
229
- // The watch syncs the keychain credential into the runtime-readable
230
- // file first (claude 2.1.236 writes keychain but reads the file),
231
- // then probes.
232
- watch: async (home) => {
233
- await syncClaudeCredentialFile(home, message => { ctx.logger.warn(message); });
234
- return claudeAuthenticated(home, message => { ctx.logger.warn(message); });
235
- },
236
- },
238
+ // No `login` declaration: the harness authenticates by endpoint key
239
+ // (endpoint-auth.ts), so the core reports the harness as not loginable
240
+ // and `/claude-code login` answers that there is no login flow — no
241
+ // browser, no OAuth, nothing to authorize inside a container.
237
242
  records: { listSessions: homeDir => listClaudeSessions(homeDir) },
238
243
  // A NAMED scope's directory is provisioned through this hook when the
239
244
  // registry materializes it — the same eager creation and settings the
240
245
  // default scope gets from the apply above.
241
246
  provision: scopedHome => provisionClaudeHome(scopedHome, config.proxyUrl).then(() => { }),
242
- // The logger is wired here because this probe is the clobber site the
243
- // spawn sites do not cover: every status read and every readiness check
244
- // reconciles the two credential stores through it.
245
- isAuthenticated: home => claudeAuthenticated(home, message => { ctx.logger.warn(message); }),
246
- credentialStamp: claudeCredentialStamp,
247
- logout: claudeLogout,
247
+ // Endpoint-key presence: the scope's endpoint-auth.json merged over the
248
+ // plugin config must carry a non-empty key. The verdict rides the
249
+ // family's credential grades untouched: presence grades
250
+ // `present-unverified`, a completed round grades `verified`, a round's
251
+ // 401 grades `rejected` until the endpoint file's mtime moves past the
252
+ // mark (editing the file IS the re-login gesture).
253
+ isAuthenticated: home => resolveEndpoint(home).then(endpoint => hasEndpointCredential(endpoint)),
254
+ // The settings card's verify button: a cheap token-free probe of the
255
+ // SAVED endpoint (the reply tells the user to save first when a
256
+ // credential is missing). Session-less via the gateway Remote.
257
+ checkEndpoint: home => resolveEndpoint(home).then(endpoint => checkClaudeEndpoint(endpoint)),
258
+ // The settings card's credential form: read the resolved per-field
259
+ // state (secrets as booleans), write edits into the scope's own
260
+ // endpoint-auth.json — the harness's 0600 store, never the profile
261
+ // patch document. A cleared field falls back to the config triple.
262
+ endpointAuth: {
263
+ read: async (home) => {
264
+ const endpoint = await resolveEndpoint(home);
265
+ return {
266
+ ...endpoint.baseUrl === undefined ? {} : { baseUrl: endpoint.baseUrl },
267
+ apiKeySet: endpoint.apiKey !== undefined,
268
+ authTokenSet: endpoint.authToken !== undefined,
269
+ };
270
+ },
271
+ write: async (home, fields) => {
272
+ const current = await readEndpointAuthFile(home) ?? {};
273
+ const next = { ...current };
274
+ for (const [field, value] of Object.entries(fields)) {
275
+ if (value === undefined)
276
+ continue;
277
+ const trimmed = typeof value === 'string' ? value.trim() : '';
278
+ if (value === null || trimmed === '')
279
+ delete next[field];
280
+ else
281
+ next[field] = trimmed;
282
+ }
283
+ if (Object.keys(next).length === 0)
284
+ await clearEndpointAuth(home);
285
+ else
286
+ await writeEndpointAuth(home, next);
287
+ },
288
+ },
289
+ credentialStamp: endpointAuthStamp,
290
+ // Logout clears the scope's OWN endpoint file. A config-sourced
291
+ // credential outlives it — said out loud, or the status would flip
292
+ // right back and read as a failed logout.
293
+ logout: async (home) => {
294
+ if (await endpointAuthFileExists(home)) {
295
+ await clearEndpointAuth(home);
296
+ return;
297
+ }
298
+ 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');
299
+ },
248
300
  // The model surface: the settings card reads the memberless info ("what
249
301
  // would a round run with"), the member composer reads and switches a
250
302
  // member's session-level override through the same broker.
251
303
  modelBroker,
304
+ // The settings card's curated model list (the default scope's
305
+ // models.json; named scopes carry their own, read per round).
306
+ modelList: {
307
+ read: home => readModelList(home),
308
+ write: (home, models) => writeModelList(home, models),
309
+ },
252
310
  // The eval snapshot: the permission mode is the plugin config resolved
253
311
  // at apply (it selects the spawn flags); the endpoint mirrors the
254
- // provider's own resolution order — the config item wins over the host
255
- // process environment's ANTHROPIC_BASE_URL. The model follows the
256
- // family's fixed order: the plugin config key (it rides every argv)
257
- // before the scoped settings.json's own `model`. The CLI's own default
258
- // model is never guessed, so neither one configured means no field.
259
- // The directory is a PARAMETER, not the apply-time capture: a status
260
- // read (or an evaluation snapshot) of a named scope must report the
261
- // settings that scope's rounds would run with.
312
+ // provider's own resolution — the scope's file over the config triple.
313
+ // The model follows the family's fixed order: the plugin config key (it
314
+ // rides every argv) before the scoped settings.json's own `model`. The
315
+ // CLI's own default model is never guessed, so neither one configured
316
+ // means no field. The directory is a PARAMETER, not the apply-time
317
+ // capture: a status read (or an evaluation snapshot) of a named scope
318
+ // must report the settings that scope's rounds would run with.
262
319
  effectiveSettings: async (scopedHome) => {
263
- const effectiveBaseUrl = baseUrl ?? process.env.ANTHROPIC_BASE_URL;
264
- const baseUrlHost = effectiveBaseUrl !== undefined ? endpointHost(effectiveBaseUrl) : undefined;
320
+ const endpoint = await resolveEndpoint(scopedHome);
321
+ const baseUrlHost = endpoint.baseUrl !== undefined ? endpointHost(endpoint.baseUrl) : undefined;
265
322
  const [scopedModel, cliVersion] = await Promise.all([
266
323
  readClaudeConfiguredModel(scopedHome).catch(() => undefined),
267
324
  claudeCliVersion(ctx, scopedHome).catch(() => undefined),
@@ -270,7 +327,7 @@ export function apply(ctx, config) {
270
327
  return {
271
328
  drive: face.get().live ? 'live' : 'exec',
272
329
  permissionMode,
273
- baseUrlSet: effectiveBaseUrl !== undefined,
330
+ baseUrlSet: endpoint.baseUrl !== undefined,
274
331
  ...baseUrlHost !== undefined ? { baseUrlHost } : {},
275
332
  ...model !== undefined ? { model } : {},
276
333
  ...cliVersion !== undefined ? { cliVersion } : {},
@@ -287,6 +344,15 @@ export function apply(ctx, config) {
287
344
  : records.map(r => `${r.id} ${r.workDir}`).join('\n'),
288
345
  }));
289
346
  }
347
+ // `/claude-code check`: the settings card's verify button — a cheap
348
+ // token-free probe of the SAVED endpoint (the reply tells the user to
349
+ // save first when a credential is missing).
350
+ if (verb === 'check') {
351
+ const scopeHome = invocation.agent.ctx.localAgent.homeDir('claude-code');
352
+ return resolveEndpoint(scopeHome)
353
+ .then(endpoint => checkClaudeEndpoint(endpoint))
354
+ .then(text => ({ kind: 'success', text }));
355
+ }
290
356
  return undefined;
291
357
  },
292
358
  });
@@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis';
3
3
  import type { Session } from '@deepseek-ai/dsh-session';
4
4
  import { type SubagentRun, type SubagentStartRequest } from '@deepseek-ai/dsh-subagent';
5
5
  import type { Config } from './index.ts';
6
+ import type { ClaudeEndpointAuth } from './endpoint-auth.ts';
6
7
  /** Default idle lifetime of an unused resident runtime before reclaim. */
7
8
  export declare const DEFAULT_LIVE_IDLE_MS: number;
8
9
  /** Default timeout for the turn's system/init ack (a cold boot can be slow). */
@@ -85,7 +86,14 @@ export declare class ClaudeLiveDriver {
85
86
  */
86
87
  private draining;
87
88
  private readonly disposeController;
88
- constructor(ctx: Context, config: Pick<Config, 'permissionMode' | 'baseUrl'> & {
89
+ constructor(ctx: Context, config: Pick<Config, 'permissionMode'> & {
90
+ /**
91
+ * Resolver for the spawn's endpoint credential (endpoint-auth.ts: the
92
+ * member's scoped endpoint-auth.json over the plugin config triple),
93
+ * read per RUNTIME SPAWN — a key rotation reaches the resident process
94
+ * at its next respawn. Absent, no `ANTHROPIC_*` variables are set.
95
+ */
96
+ endpoint?: (homeDir: string) => Promise<ClaudeEndpointAuth>;
89
97
  liveIdleMs?: number;
90
98
  liveMirrorGranularity?: ClaudeLiveMirrorGranularity;
91
99
  /** Maximum batching wait for incremental streaming messages. */
@@ -12,12 +12,10 @@ import { claudeDirectory } from './model-catalog.js';
12
12
  * continuable) and the same-shape push stream folded live per turn.
13
13
  *
14
14
  * Auth discipline (a hard requirement): the resident process runs under the
15
- * family's scoped `CLAUDE_CONFIG_DIR` and nothing else — the OAuth marker in
16
- * the scoped `.claude.json` and the keychain entry hashed to that path are
17
- * exactly what the exec mode's one-shot processes already use. The driver
18
- * never touches the user's global `~/.claude`, never runs any `auth` verb,
19
- * and only adds `ANTHROPIC_BASE_URL` when the plugin config supplies it (the
20
- * exec path's exact env rule).
15
+ * family's scoped `CLAUDE_CONFIG_DIR` plus the resolved endpoint credential's
16
+ * `ANTHROPIC_*` variables (the scope's endpoint-auth.json over the plugin
17
+ * config — the exec path's exact env rule, resolved per spawn). The driver
18
+ * never touches the user's global `~/.claude` and never runs any `auth` verb.
21
19
  *
22
20
  * Protocol facts (all probed): the first stdin `user` message triggers
23
21
  * `system/init` carrying the server-assigned session id (EVERY turn emits a
@@ -62,11 +60,13 @@ import { claudeDirectory } from './model-catalog.js';
62
60
  */
63
61
  import { ClaudeControlRequests } from './control-requests.js';
64
62
  import { StringDecoder } from 'node:string_decoder';
63
+ import { randomUUID } from 'node:crypto';
65
64
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
65
+ import { SessionId } from '@deepseek-ai/dsh-session';
66
66
  import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
67
67
  import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
68
68
  import { appendClaudeTranscriptLine, assistantEvent, claudeLineText, ClaudeStreamParser, claudeVersionFromInit, DEFAULT_DISPOSE_GRACE_MS, registerClaudeMemberRun, textTask, toolCallsOf, } from './claude-cli-provider.js';
69
- import { syncClaudeCredentialFile } from './records.js';
69
+ import { endpointAuthEnv } from './endpoint-auth.js';
70
70
  /** Default idle lifetime of an unused resident runtime before reclaim. */
71
71
  export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
72
72
  /** Default timeout for the turn's system/init ack (a cold boot can be slow). */
@@ -419,14 +419,10 @@ export class ClaudeLiveDriver {
419
419
  if (this.disposed) {
420
420
  throw new LiveChannelUnavailableError('the live driver is disposed');
421
421
  }
422
- // Keychain→file sync before EVERY spawn: claude 2.1.236 reads
423
- // <home>/.credentials.json at runtime while login and the process's own
424
- // refresh write the keychain — the file goes stale on every rotation
425
- // (single-rotation refresh tokens make a consumed copy poison for the
426
- // next spawn). The login watch's sync alone cannot keep a resident
427
- // runtime authenticatable across rotations. Best-effort: a missing grant
428
- // fails the turn with the CLI's own "Not logged in", not here.
429
- await syncClaudeCredentialFile(spec.homeDir, message => { this.ctx.logger.warn(message); }).catch(() => false);
422
+ // The endpoint credential is resolved per spawn (the scope's
423
+ // endpoint-auth.json over the plugin config): a key rotation reaches the
424
+ // resident runtime at its next respawn, with no reload and no reconcile.
425
+ const endpoint = await this.config.endpoint?.(spec.homeDir);
430
426
  // The member bridge rides the spawn argv (`--mcp-config` + `--allowedTools`),
431
427
  // exactly as in the exec path; the resident process serves one member, so
432
428
  // its token lives with the process.
@@ -460,11 +456,11 @@ export class ClaudeLiveDriver {
460
456
  stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
461
457
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
462
458
  // The auth discipline: exactly the exec path's env — the scoped config
463
- // dir, plus the configured base URL override only.
459
+ // dir plus the resolved endpoint credential's `ANTHROPIC_*` variables.
464
460
  env: delegationEnv({
465
461
  CLAUDE_CONFIG_DIR: spec.homeDir,
466
462
  ...spec.configuration === undefined ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? 'auto' },
467
- ...this.config.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.config.baseUrl },
463
+ ...endpointAuthEnv(endpoint),
468
464
  }),
469
465
  };
470
466
  let child;
@@ -1039,7 +1035,11 @@ export class ClaudeLiveDriver {
1039
1035
  // never hold it hostage — a cancel returns immediately.
1040
1036
  await Promise.race([accepted, abortBranch.catch(() => undefined)]);
1041
1037
  return subprocessRunHandle({
1042
- id: childSession.id,
1038
+ // Host 0.2.1: a fresh PER-EXECUTION run id — the activation manager
1039
+ // rejects a run id naming a live Session (this child session is live),
1040
+ // and resume rounds reuse the child session id. The family's durable
1041
+ // tracking key (the child session id) rides the staged intent instead.
1042
+ id: SessionId(randomUUID()),
1043
1043
  result,
1044
1044
  signal: request.signal,
1045
1045
  onAbort,
@@ -13,6 +13,7 @@
13
13
  import type { Context } from '@deepseek-ai/cordis';
14
14
  import type { SettingsFace } from '@khorsheed/dsh-local-agent';
15
15
  import type { Config } from './index.ts';
16
+ import type { ClaudeEndpointAuth } from './endpoint-auth.ts';
16
17
  import { ClaudeLiveDriver } from './live-driver.ts';
17
18
  import type { ClaudeLiveMirrorGranularity } from './live-driver.ts';
18
19
  /** The resolved live settings (rc.1: volatile row config; 0.1.5: schema defaults ← YAML base ← user layer). */
@@ -25,8 +26,10 @@ export interface ClaudeLiveSettings {
25
26
  recentModels?: readonly string[];
26
27
  }
27
28
  /** The Cordis-config inputs every driver generation shares. */
28
- type DriverBase = Pick<Config, 'permissionMode' | 'baseUrl'> & {
29
+ type DriverBase = Pick<Config, 'permissionMode'> & {
29
30
  liveIdleMs?: number;
31
+ /** Per-spawn endpoint credential resolver, handed to every driver generation. */
32
+ endpoint?: (homeDir: string) => Promise<ClaudeEndpointAuth>;
30
33
  /**
31
34
  * Per-spawn member model resolver, handed to every driver generation:
32
35
  * answers the member's effective model from its session-level override, the
@@ -70,8 +70,10 @@ export interface ClaudeModelBrokerDeps {
70
70
  home: string;
71
71
  directory: (refresh?: boolean) => LocalAgentModelDirectory | Promise<LocalAgentModelDirectory>;
72
72
  };
73
- /** The settings layer: the plugin-config model, read per call. */
74
- readonly settingsModel: () => string | undefined;
73
+ /** The settings layer: the curated list's first entry, else the plugin-config model, read per call. */
74
+ readonly settingsModel: (scope?: string) => string | undefined;
75
+ /** The scope's curated model list, read per call (empty = the fallback chain decides the vocabulary too). */
76
+ readonly modelList?: (scope?: string) => readonly string[];
75
77
  /** The scoped settings.json's own model, minus live-spawn scratch writes. */
76
78
  readonly cliDefault: (childSessionId?: string) => Promise<string | undefined>;
77
79
  readonly directory?: (childSessionId?: string, refresh?: boolean) => LocalAgentModelDirectory | Promise<LocalAgentModelDirectory>;
@@ -111,7 +111,7 @@ export class ClaudeModelBroker {
111
111
  if (directory.status === 'loading')
112
112
  directory = await context.directory(true);
113
113
  const model = selection.model.mode === 'value' ? selection.model.value
114
- : (selection.model.mode === 'inherit' ? binding.model : undefined) ?? this.deps.settingsModel()
114
+ : (selection.model.mode === 'inherit' ? binding.model : undefined) ?? this.deps.settingsModel(binding.scope)
115
115
  ?? await readClaudeConfiguredModel(context.home) ?? directory.defaultModel;
116
116
  const entry = directory.entries.find(entry => entry.value === model || entry.resolvedModel === model);
117
117
  const effort = (selection.effort.mode === 'value' ? selection.effort.value : selection.effort.mode === 'inherit' ? binding.effort : undefined)
@@ -179,9 +179,10 @@ export class ClaudeModelBroker {
179
179
  * would a round run with" read.
180
180
  */
181
181
  async modelInfo(childSessionId, delegationModel) {
182
+ const record = childSessionId === undefined ? undefined : this.deps.localAgent.getDelegation(childSessionId);
182
183
  const override = childSessionId === undefined ? undefined : this.deps.overrides.get(childSessionId);
183
184
  const delegation = delegationModel?.trim() === '' ? undefined : delegationModel?.trim();
184
- const settings = this.deps.settingsModel();
185
+ const settings = this.deps.settingsModel(record?.scope);
185
186
  const cliDefault = await this.deps.cliDefault(childSessionId);
186
187
  const native = await this.deps.directory?.(childSessionId);
187
188
  const [effective, source] = override !== undefined
@@ -193,9 +194,14 @@ export class ClaudeModelBroker {
193
194
  : cliDefault !== undefined
194
195
  ? [cliDefault, 'cli-config']
195
196
  : [native?.defaultModel, 'cli-builtin'];
196
- // Native candidates plus labelled configuration and historical suggestions.
197
- const choices = [...new Set([settings, cliDefault, ...native?.entries.filter(entry => !entry.hidden).map(entry => entry.value) ?? [], ...this.deps.recentModels()]
198
- .filter((value) => value !== undefined))];
197
+ // The curated list is the whole vocabulary when non-empty — nothing from
198
+ // outside the endpoint's served catalog sneaks in. Empty falls back to
199
+ // the pre-list chain (native candidates plus configuration and history).
200
+ const curated = this.deps.modelList?.(record?.scope) ?? [];
201
+ const choices = curated.length > 0
202
+ ? [...curated]
203
+ : [...new Set([settings, cliDefault, ...native?.entries.filter(entry => !entry.hidden).map(entry => entry.value) ?? [], ...this.deps.recentModels()]
204
+ .filter((value) => value !== undefined))];
199
205
  const lastObserved = await this.lastObserved(childSessionId);
200
206
  const active = childSessionId !== undefined && this.deps.localAgent.isDelegationActive(childSessionId);
201
207
  return {
@@ -207,7 +213,11 @@ export class ClaudeModelBroker {
207
213
  ...cliDefault === undefined ? {} : { cliDefault },
208
214
  ...lastObserved === undefined ? {} : { lastObserved },
209
215
  choices,
210
- ...native === undefined ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], [...this.deps.recentModels(), lastObserved]) },
216
+ // The picker's extended vocabulary follows the same rule as choices:
217
+ // a curated list is complete on its own (no history entries sneak in);
218
+ // settings/cliDefault still extend so the member's CURRENT bound model
219
+ // stays selectable even when it is not in the list.
220
+ ...native === undefined ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], curated.length > 0 ? [] : [...this.deps.recentModels(), lastObserved]) },
211
221
  live: this.deps.live(),
212
222
  switchable: !active,
213
223
  ...active
@@ -238,8 +248,9 @@ export class ClaudeModelBroker {
238
248
  else
239
249
  this.deps.overrides.set(childSessionId, next);
240
250
  // What the next spawn would bind, in the round resolvers' own order.
241
- const delegation = this.deps.localAgent.getDelegation(childSessionId)?.model?.trim();
242
- const spawnModel = next ?? (delegation === undefined || delegation === '' ? undefined : delegation) ?? this.deps.settingsModel();
251
+ const record = this.deps.localAgent.getDelegation(childSessionId);
252
+ const delegation = record?.model?.trim();
253
+ const spawnModel = next ?? (delegation === undefined || delegation === '' ? undefined : delegation) ?? this.deps.settingsModel(record?.scope);
243
254
  const host = this.deps.liveSwitch.hostingDriver(childSessionId);
244
255
  if (host !== undefined && host.runtimeModel(childSessionId) !== spawnModel) {
245
256
  await host.retireRuntime(childSessionId);