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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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). */
@@ -15,11 +16,6 @@ export declare const DEFAULT_LIVE_CHANNEL_RETRY_MS: number;
15
16
  export declare const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 50;
16
17
  /** Default minimum text growth between one streaming kind's snapshot messages. */
17
18
  export declare const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
18
- /**
19
- * The stream-json channel could not come up (spawn failure or the first
20
- * turn's init never arrived). The provider catches exactly this and falls
21
- * back to the exec one-shot; any other error is a real round failure.
22
- */
23
19
  export declare class LiveChannelUnavailableError extends Error {
24
20
  constructor(message: string);
25
21
  }
@@ -85,7 +81,14 @@ export declare class ClaudeLiveDriver {
85
81
  */
86
82
  private draining;
87
83
  private readonly disposeController;
88
- constructor(ctx: Context, config: Pick<Config, 'permissionMode' | 'baseUrl'> & {
84
+ constructor(ctx: Context, config: Pick<Config, 'permissionMode'> & {
85
+ /**
86
+ * Resolver for the spawn's endpoint credential (endpoint-auth.ts: the
87
+ * member's scoped endpoint-auth.json over the plugin config triple),
88
+ * read per RUNTIME SPAWN — a key rotation reaches the resident process
89
+ * at its next respawn. Absent, no `ANTHROPIC_*` variables are set.
90
+ */
91
+ endpoint?: (homeDir: string) => Promise<ClaudeEndpointAuth>;
89
92
  liveIdleMs?: number;
90
93
  liveMirrorGranularity?: ClaudeLiveMirrorGranularity;
91
94
  /** Maximum batching wait for incremental streaming messages. */