@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.
@@ -17,19 +17,35 @@
17
17
  import { randomUUID } from 'node:crypto';
18
18
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
19
19
  import { SessionId } from '@deepseek-ai/dsh-session';
20
- import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
20
+ import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
21
21
  import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, persistChildSession, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
22
22
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
23
23
  import { LiveChannelUnavailableError } from './live-driver.js';
24
- import { syncClaudeCredentialFile } from './records.js';
24
+ import { endpointAuthEnv } from './endpoint-auth.js';
25
+ /**
26
+ * The synthesized result for a call whose result the stream never recorded
27
+ * (a settle-pass fold after a kill or a truncated stream). The released-log
28
+ * contract resolves every advertised call before its step closes, so an
29
+ * interrupted call folds with this error result. Shared verbatim with the
30
+ * kimi provider (`KIMI_INTERRUPTED_TOOL_RESULT`) so the family reads one text.
31
+ */
32
+ export const CLAUDE_INTERRUPTED_TOOL_RESULT = '(工具调用被中断,未能记录返回结果)';
25
33
  /** Default POSIX grace between subprocess termination tiers. */
26
34
  export const DEFAULT_DISPOSE_GRACE_MS = 3_000;
27
35
  /**
28
36
  * Auth-shaped failure signatures in claude's result error or stderr: the
29
- * endpoint rejected the credential (401/403, revoked token). Narrow on
30
- * purpose — matched against provider-controlled error strings only.
37
+ * endpoint rejected the credential (401/403, revoked key). Matches the CLI's
38
+ * own phrasing ("Failed to authenticate. API Error: 401 …", the
39
+ * `authentication_failed` error tag) plus the relay phrasings seen in the
40
+ * wild ("invalid x-api-key" and friends). Narrow on purpose — matched against
41
+ * provider-controlled error strings only.
42
+ */
43
+ export 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;
44
+ /**
45
+ * The MCP tool-call bound for a member's claude process (`MCP_TOOL_TIMEOUT`,
46
+ * ms): a bridge call may wait up to 30 minutes for the human's answer.
31
47
  */
32
- export const CLAUDE_AUTH_FAILURE = /failed to authenticate|authentication_failed|oauth access token/i;
48
+ export const MEMBER_TOOL_TIMEOUT_MS = String(30 * 60_000);
33
49
  /** Register one member run with the channel; see {@link ClaudeMemberRunHandle}. */
34
50
  export function registerClaudeMemberRun(ctx, childSessionId, parentSessionId) {
35
51
  const registry = ctx.localAgent;
@@ -119,7 +135,7 @@ export function claudeCliVersion(ctx, homeDir) {
119
135
  export class ClaudeCliProvider {
120
136
  ctx;
121
137
  permissionMode;
122
- baseUrl;
138
+ endpoint;
123
139
  live;
124
140
  model;
125
141
  name = 'claude-local';
@@ -130,6 +146,11 @@ export class ClaudeCliProvider {
130
146
  * generation's driver per member (the settings toggle swaps generations;
131
147
  * a resolver may return undefined to steer one member's round to exec
132
148
  * while a retiring generation still hosts it).
149
+ * @param endpoint - resolver for the round's endpoint credential (endpoint-auth.ts:
150
+ * the scope's endpoint-auth.json over the plugin config triple), read PER
151
+ * ROUND against the round's scoped home, so a key rotation or a profile
152
+ * edit takes effect on the next delegation without a reload. Absent, no
153
+ * `ANTHROPIC_*` variables reach the child at all.
133
154
  * @param model - resolver for the member's effective model, read PER ROUND
134
155
  * so a settings-card write or a composer switch takes effect on the next
135
156
  * delegation without a reload. Receives the member (the child session id)
@@ -138,10 +159,10 @@ export class ClaudeCliProvider {
138
159
  * resolver not passed), the delegation's own model still rides, exactly
139
160
  * as before the key existed.
140
161
  */
141
- constructor(ctx, permissionMode = 'skip', baseUrl, live, model) {
162
+ constructor(ctx, permissionMode = 'skip', endpoint, live, model) {
142
163
  this.ctx = ctx;
143
164
  this.permissionMode = permissionMode;
144
- this.baseUrl = baseUrl;
165
+ this.endpoint = endpoint;
145
166
  this.live = live;
146
167
  this.model = model;
147
168
  }
@@ -168,8 +189,8 @@ export class ClaudeCliProvider {
168
189
  // The scoped home this round runs against: the staged intent's scope
169
190
  // names a sibling directory under the homes root, absent means the
170
191
  // default one — the directory every round used before scopes existed.
171
- // claude's keychain item is keyed by the config directory's path, so a
172
- // named scope gets its own credential item for free.
192
+ // A named scope's endpoint-auth.json overrides the deployment credential
193
+ // for its rounds (endpoint-auth.ts).
173
194
  const scope = intent?.scope;
174
195
  const homeDir = this.ctx.localAgent.homeDir('claude-code', scope);
175
196
  // The effective cwd: the caller's override (the staged intent's `cwd`,
@@ -209,7 +230,7 @@ export class ClaudeCliProvider {
209
230
  ...record?.configurationLock === undefined ? {} : { configurationLock: record.configurationLock },
210
231
  }, configuration => this.startClaudeResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
211
232
  }
212
- const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
233
+ const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
213
234
  if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function') {
214
235
  if (intent?.effort !== undefined)
215
236
  throw new Error('Claude effort requires the configuration admission core');
@@ -242,14 +263,20 @@ export class ClaudeCliProvider {
242
263
  },
243
264
  });
244
265
  const harness = this.ctx.localAgent.get('claude-code');
245
- const label = subagentDelegationLabel(harness?.displayName ?? 'Claude Code', request.descriptor.label);
266
+ // Host 0.2.1 no longer resolves a descriptor onto the request (the
267
+ // runtime's one-shot snapshot is gone), so the provider stamps the same
268
+ // payload itself; the activation spec's required label arrives as the
269
+ // empty string when the caller named none.
270
+ const label = subagentDelegationLabel(harness?.displayName ?? 'Claude Code', request.label || undefined);
246
271
  childSession.append('subagent/descriptor', {
247
- ...request.descriptor,
272
+ version: SUBAGENT_DESCRIPTOR_VERSION,
273
+ mode: 'one-shot',
274
+ provider: this.name,
248
275
  label,
249
276
  });
250
- // The official runtime appends the parent-side subagent/catalog
251
- // discovery row only for in-process children (run.localAgent); this run
252
- // is remote, so the provider appends it here — once per child, where
277
+ // The 0.2.1 manager appends the parent-side subagent/catalog discovery
278
+ // row only for parent-delivered children; the family runs caller
279
+ // delivery, so the provider appends it here — once per child, where
253
280
  // the descriptor lands. A failure degrades to the warn below; a missing
254
281
  // row never blocks the delegation.
255
282
  establishSubagentCatalogChild(request.parent.session, childSession.header, label);
@@ -262,12 +289,12 @@ export class ClaudeCliProvider {
262
289
  catch (error) {
263
290
  this.ctx.logger.warn(`subagent-claude: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
264
291
  }
265
- // Resolve the effective endpoint once per run: the cordis Config field
266
- // wins, else the host environment's ANTHROPIC_BASE_URL (a STARTUP-time
267
- // snapshot — a long-running dsh process does not see later shell exports),
268
- // else the CLI's own default. Log it (without the key) so a failing
269
- // delegation reports which endpoint it actually used.
270
- const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
292
+ // Resolve the effective endpoint once per run: the scope's
293
+ // endpoint-auth.json over the plugin config triple (endpoint-auth.ts).
294
+ // Logged WITHOUT the key so a failing delegation reports which endpoint
295
+ // it actually used.
296
+ const endpoint = await this.endpoint?.(homeDir);
297
+ const effectiveBaseUrl = endpoint?.baseUrl;
271
298
  this.ctx.logger.info(`subagent-claude: delegating via ${effectiveBaseUrl ?? 'claude default endpoint'}`);
272
299
  // Live driver: the round goes to the member's resident stream-json
273
300
  // process. A channel that fails at spawn falls through to the exec
@@ -276,6 +303,14 @@ export class ClaudeCliProvider {
276
303
  // a resident stream-json process on the HOST, which is the transport the
277
304
  // target exists to replace.
278
305
  const live = exec === undefined ? this.liveDriver(runId) : undefined;
306
+ // The round's model, computed once: the configuration admission's
307
+ // resolution wins, else the family's order (session override, the
308
+ // delegation's own model, the scope's curated list / settings key).
309
+ // Whatever it is — explicit or followed — the first round STAMPS it into
310
+ // the delegation record: the member is bound to this model from then on,
311
+ // and a later edit of the curated list moves the default only for NEW
312
+ // members, never underneath a conversation (its cache lineage included).
313
+ const round = configuration?.resolved ?? this.roundModel(runId, requestedModel);
279
314
  if (live !== undefined && childSession !== undefined && !live.disabled) {
280
315
  // The member-bound live driver receives this exact scoped home.
281
316
  try {
@@ -303,9 +338,9 @@ export class ClaudeCliProvider {
303
338
  cwd,
304
339
  // …and its scoped home anchors the resume-scope check.
305
340
  ...scope === undefined ? {} : { scope },
306
- // The model this delegation asked for: every resume round reads
307
- // it back from here instead of the caller restating it.
308
- ...requestedModel === undefined ? {} : { model: requestedModel },
341
+ // The model the member is BOUND to (see `round` above): every
342
+ // resume round re-requests it from here.
343
+ ...round.model === undefined ? {} : { model: round.model },
309
344
  });
310
345
  },
311
346
  });
@@ -327,12 +362,14 @@ export class ClaudeCliProvider {
327
362
  cwd,
328
363
  env: delegationEnv({
329
364
  CLAUDE_CONFIG_DIR: homeDir,
330
- ...this.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.baseUrl },
365
+ // A bridge call may wait for the human (see MEMBER_TOOL_TIMEOUT_MS).
366
+ ...member === undefined ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
367
+ ...endpointAuthEnv(endpoint),
331
368
  }),
332
369
  ...exec === undefined ? {} : { exec },
333
370
  endpointLabel: effectiveBaseUrl,
334
371
  permissionMode: this.permissionMode,
335
- ...(configuration?.resolved ?? this.roundModel(runId, requestedModel)),
372
+ ...round,
336
373
  ...configuration === undefined ? {} : { controlled: true },
337
374
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
338
375
  spawn: spec => this.ctx.subprocess.spawn(spec),
@@ -360,8 +397,9 @@ export class ClaudeCliProvider {
360
397
  cwd,
361
398
  // …and its scoped home anchors the resume-scope check.
362
399
  ...scope === undefined ? {} : { scope },
363
- // The model this delegation asked for (see the live branch).
364
- ...requestedModel === undefined ? {} : { model: requestedModel },
400
+ // The model the member is BOUND to (see `round` above; the exec
401
+ // spawn below runs this same value).
402
+ ...round.model === undefined ? {} : { model: round.model },
365
403
  });
366
404
  },
367
405
  // Every settled round reports its observed model and usage through the
@@ -426,7 +464,8 @@ export class ClaudeCliProvider {
426
464
  this.ctx.logger.warn(`subagent-claude: live driver unavailable, using the exec one-shot: ${error.message}`);
427
465
  }
428
466
  }
429
- const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
467
+ const endpoint = await this.endpoint?.(homeDir);
468
+ const effectiveBaseUrl = endpoint?.baseUrl;
430
469
  this.ctx.logger.info(`subagent-claude: resuming via ${effectiveBaseUrl ?? 'claude default endpoint'}`);
431
470
  // Member channel: register the resume round (same child session, fresh
432
471
  // per-run token) before the spawn.
@@ -438,7 +477,8 @@ export class ClaudeCliProvider {
438
477
  cwd,
439
478
  env: delegationEnv({
440
479
  CLAUDE_CONFIG_DIR: homeDir,
441
- ...this.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.baseUrl },
480
+ ...member === undefined ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
481
+ ...endpointAuthEnv(endpoint),
442
482
  }),
443
483
  ...exec === undefined ? {} : { exec },
444
484
  endpointLabel: effectiveBaseUrl,
@@ -508,6 +548,19 @@ export function textTask(prompt) {
508
548
  }
509
549
  return texts.join('\n');
510
550
  }
551
+ /** Group the lines of one API call (consecutive lines sharing a message id).
552
+ * Lines without an id stay singleton units (the pre-grouping shape). */
553
+ export function groupClaudeTranscriptLines(lines) {
554
+ const groups = [];
555
+ for (const line of lines) {
556
+ const last = groups[groups.length - 1];
557
+ if (line.messageId !== undefined && last !== undefined && last[0]?.messageId === line.messageId)
558
+ last.push(line);
559
+ else
560
+ groups.push([line]);
561
+ }
562
+ return groups;
563
+ }
511
564
  /**
512
565
  * Fold a tool-call counter into the settled shape: `{count, byName}` with the
513
566
  * per-name values summing to `count`. An empty map yields undefined — a
@@ -637,13 +690,20 @@ function foldClaudeStreamLine(state, raw) {
637
690
  if (event.type !== 'assistant' && event.type !== 'user')
638
691
  return;
639
692
  const blocks = event.message?.content ?? [];
693
+ const linesBefore = state.lines.length;
694
+ const messageId = event.type === 'assistant' && typeof event.message?.id === 'string' ? event.message.id : undefined;
640
695
  for (const [blockIndex, block] of blocks.entries()) {
641
696
  if (typeof block !== 'object' || block === null)
642
697
  continue;
643
698
  const record = block;
644
- const identity = event.type === 'assistant' && typeof event.message?.id === 'string'
645
- ? { streamId: `${event.message.id}:${blockIndex}` } : {};
699
+ const identity = messageId !== undefined
700
+ ? { streamId: `${messageId}:${blockIndex}`, messageId } : {};
646
701
  const kind = record['type'];
702
+ // A user event's text is the CLI's own injection (a Skill's body, a slash
703
+ // command's output, an interrupt notice) — never the model's answer; the
704
+ // round's prompt is mirrored separately as the user message.
705
+ if (kind === 'text' && event.type === 'user')
706
+ continue;
647
707
  if (kind === 'text' && typeof record['text'] === 'string' && record['text'].trim() !== '') {
648
708
  state.lines.push({ kind: 'text', text: record['text'], ...identity });
649
709
  // The final assistant text block is the run output.
@@ -688,6 +748,7 @@ function foldClaudeStreamLine(state, raw) {
688
748
  id: id ?? `claude-tool-${state.lines.length}`,
689
749
  name: 'ApplyPatch',
690
750
  args: edit.args,
751
+ ...messageId === undefined ? {} : { messageId },
691
752
  ...edit.patch === undefined ? {} : { result: edit.patch },
692
753
  });
693
754
  }
@@ -699,6 +760,7 @@ function foldClaudeStreamLine(state, raw) {
699
760
  // The server-side tools surface under the local tools' card names
700
761
  // (web_search is the same activity the local WebSearch tool runs).
701
762
  name: kind === 'server_tool_use' ? SERVER_TOOL_CARD_NAMES[name] ?? name : name,
763
+ ...messageId === undefined ? {} : { messageId },
702
764
  ...detail === undefined ? {} : { args: detail },
703
765
  });
704
766
  }
@@ -718,6 +780,26 @@ function foldClaudeStreamLine(state, raw) {
718
780
  }
719
781
  }
720
782
  }
783
+ // Claude reports the call's own usage inline on every assistant event
784
+ // (cache buckets included). One API call arrives as one event PER BLOCK,
785
+ // each repeating the message's usage: stamp it on the call's LAST folded
786
+ // line and lift it off the line it previously rode, so a multi-block call
787
+ // still accounts exactly once. The terminal `result` event's round total
788
+ // stays on `state.usage` for the run accounting.
789
+ if (event.type === 'assistant' && event.message?.usage !== undefined && state.lines.length > linesBefore) {
790
+ const lastIndex = state.lines.length - 1;
791
+ const last = state.lines[lastIndex];
792
+ if (messageId !== undefined) {
793
+ const previous = state.usageStamped.get(messageId);
794
+ if (previous !== undefined && previous !== lastIndex) {
795
+ const { usage: _cleared, ...rest } = state.lines[previous];
796
+ state.lines[previous] = rest;
797
+ }
798
+ }
799
+ state.lines[lastIndex] = { ...last, usage: usageFromClaude(event.message.usage) };
800
+ if (messageId !== undefined)
801
+ state.usageStamped.set(messageId, lastIndex);
802
+ }
721
803
  }
722
804
  /**
723
805
  * Merge a tool result into its call line: pair by tool_use_id when the stream
@@ -759,6 +841,7 @@ export class ClaudeStreamParser {
759
841
  buffer = '';
760
842
  lines = [];
761
843
  callsById = new Map();
844
+ usageStamped = new Map();
762
845
  text;
763
846
  usage;
764
847
  sessionId;
@@ -796,6 +879,7 @@ export function parseClaudeStreamJson(output) {
796
879
  const state = {
797
880
  lines: [],
798
881
  callsById: new Map(),
882
+ usageStamped: new Map(),
799
883
  toolCalls: new Map(),
800
884
  text: undefined,
801
885
  usage: undefined,
@@ -966,26 +1050,43 @@ function serverToolResultText(record) {
966
1050
  }
967
1051
  return undefined;
968
1052
  }
969
- /** Flatten a tool_result content payload (string or block array) to text. */
1053
+ /**
1054
+ * Flatten a tool_result content payload (string or block array) to text.
1055
+ * Beyond `text` blocks, ToolSearch answers with `tool_reference` blocks — one
1056
+ * per discovered tool — which list as the found tool names; dropping them
1057
+ * stranded the call (no result recorded) and it settled as a phantom
1058
+ * interrupted failure.
1059
+ */
970
1060
  function toolResultText(content) {
971
1061
  if (typeof content === 'string')
972
1062
  return content;
973
1063
  if (!Array.isArray(content))
974
1064
  return undefined;
975
1065
  return content
976
- .filter((block) => typeof block === 'object' && block !== null && block.type === 'text'
977
- && typeof block.text === 'string')
978
- .map(block => block.text ?? '')
1066
+ .map((block) => {
1067
+ if (typeof block !== 'object' || block === null)
1068
+ return undefined;
1069
+ const record = block;
1070
+ if (record.type === 'text' && typeof record.text === 'string')
1071
+ return record.text;
1072
+ if (record.type === 'tool_reference' && typeof record.tool_name === 'string')
1073
+ return record.tool_name;
1074
+ return undefined;
1075
+ })
1076
+ .filter((text) => text !== undefined)
979
1077
  .join('\n');
980
1078
  }
981
1079
  /**
982
1080
  * Map claude's usage payload onto the shared usage contract. Claude's
983
1081
  * `input_tokens` is the uncached input; cache reads and cache creation are
984
1082
  * reported separately, so each maps to its own bucket with no subtraction.
1083
+ * When the payload carries ANY cache field, `cacheReadTokens` is always
1084
+ * written (0 when the read counter is absent or zero) — a reader can then
1085
+ * tell "no hits" from "not reported".
985
1086
  * @param usage - the raw claude usage object.
986
1087
  * @returns the shared usage record.
987
1088
  */
988
- function usageFromClaude(usage) {
1089
+ export function usageFromClaude(usage) {
989
1090
  const raw = usage;
990
1091
  if (raw === undefined || typeof raw !== 'object')
991
1092
  return { inputTokens: 0, outputTokens: 0 };
@@ -1001,8 +1102,8 @@ function usageFromClaude(usage) {
1001
1102
  inputTokens: input ?? 0,
1002
1103
  outputTokens: output ?? 0,
1003
1104
  };
1004
- if (cacheRead !== undefined && cacheRead > 0)
1005
- usageRecord.cacheReadTokens = cacheRead;
1105
+ if (cacheRead !== undefined || cacheWrite !== undefined)
1106
+ usageRecord.cacheReadTokens = cacheRead ?? 0;
1006
1107
  if (cacheWrite !== undefined && cacheWrite > 0)
1007
1108
  usageRecord.cacheWriteTokens = cacheWrite;
1008
1109
  return usageRecord;
@@ -1025,20 +1126,6 @@ export async function startClaudeCliRun(request, spec) {
1025
1126
  if (request.signal.aborted) {
1026
1127
  throw new Error('subagent-claude: request was aborted before the CLI started');
1027
1128
  }
1028
- // Credential reconcile before EVERY spawn: claude 2.1.236 reads
1029
- // .credentials.json at runtime while login/refresh write the keychain, so
1030
- // the login watch's sync alone leaves a rotated grant stale at spawn time
1031
- // (the live driver's spawnRuntime does the same). Best-effort: a missing
1032
- // grant fails the run with the CLI's own auth error, not here.
1033
- // NOTE the directory: on a container round this is still the HOST scoped
1034
- // home (the in-container path only replaces it in the `docker exec -e`
1035
- // forwarding), and the unit is about to read that same file through the
1036
- // bind mount — which is why the reconcile is newer-wins rather than a
1037
- // mirror. See `syncClaudeCredentialFile`.
1038
- const configDir = spec.env['CLAUDE_CONFIG_DIR'];
1039
- if (configDir !== undefined) {
1040
- await syncClaudeCredentialFile(configDir, message => { spec.ctx?.logger.warn(message); }).catch(() => false);
1041
- }
1042
1129
  const turn = spec.resume?.turn ?? 1;
1043
1130
  // The member bridge flags ride every argv variant (skip and normal
1044
1131
  // permission modes alike): --allowedTools is redundant under
@@ -1168,34 +1255,11 @@ export async function startClaudeCliRun(request, spec) {
1168
1255
  }).then((settled) => {
1169
1256
  // A completed round proves the scoped credential is live — the endpoint
1170
1257
  // answered. Reported here rather than from the mirror so it lands whether
1171
- // or not the round is session-backed.
1258
+ // or not the round is session-backed. The turn boundary is NOT appended
1259
+ // here: it lands after the post-exit settle mirror (below), so every
1260
+ // mirrored step precedes turn/end in the stored log.
1172
1261
  if (settled.stopReason === 'completed')
1173
1262
  spec.onAuthSuccess?.();
1174
- // Every terminal path closes the turn so the timing window never stays
1175
- // open on a failed or cancelled run. The turn/end timestamp is the real
1176
- // settle moment; an in-stream error result or a non-zero exit settles
1177
- // 'error', a locally cancelled run 'aborted'.
1178
- if (spec.childSession !== undefined) {
1179
- if (settled.stopReason === 'completed') {
1180
- spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
1181
- }
1182
- else if (settled.stopReason === 'aborted') {
1183
- spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
1184
- }
1185
- else {
1186
- const parsed = parseClaudeStreamJson(output);
1187
- spec.childSession.append('turn/end', {
1188
- turn,
1189
- reason: {
1190
- kind: 'error',
1191
- error: {
1192
- message: parsed.error ?? `claude -p exited with code ${String(exitCode)}`,
1193
- code: 'UNKNOWN',
1194
- },
1195
- },
1196
- });
1197
- }
1198
- }
1199
1263
  return settled;
1200
1264
  });
1201
1265
  // After the child EXITS — however it ended (completed, killed by dispose, or
@@ -1203,28 +1267,72 @@ export async function startClaudeCliRun(request, spec) {
1203
1267
  // subagent session, so a cancelled round still preserves its partial work
1204
1268
  // (thinking, tool calls with results, replies) and real token usage. The
1205
1269
  // session id is recorded even on abort so the partial session stays
1206
- // resumable. Waits for the settle chain first (so turn/end is already
1207
- // appended) AND for the process to actually exit (so stdout is drained).
1208
- void result.then(() => child.done).then(() => {
1270
+ // resumable. Waits for the settle chain first AND for the process to
1271
+ // actually exit (so stdout is drained). The turn closes AFTER the settle
1272
+ // mirror, still behind the mirror queue: every mirrored step must land
1273
+ // before turn/end — the released-log reader rejects a step outside an open
1274
+ // turn on every cold read. The run's `result` still settles at its own
1275
+ // moment (abort latency unchanged); only the boundary moved.
1276
+ void result.then(() => child.done).then(async () => {
1277
+ // The seam's collected buffers are complete at process exit; the
1278
+ // streamed variables can lag `done` by a tick.
1279
+ const drainedStdout = child.collected.stdout?.readFrom(0).text || output;
1209
1280
  // Auth detection runs post-exit: streams are drained by then, so a fast
1210
1281
  // failure's output is complete (a settle-time read could race the flush).
1211
1282
  if (spec.onAuthFailure !== undefined) {
1212
- // The seam's collected buffers are complete at process exit; the
1213
- // streamed variables can lag `done` by a tick.
1214
1283
  const drainedStderr = child.collected.stderr?.readFrom(0).text || stderr;
1215
- const drainedStdout = child.collected.stdout?.readFrom(0).text || output;
1216
1284
  const authDetail = `${parseClaudeStreamJson(drainedStdout).error ?? ''}\n${drainedStderr}`;
1217
1285
  if (CLAUDE_AUTH_FAILURE.test(authDetail)) {
1218
1286
  spec.onAuthFailure(authDetail.split('\n').find(line => line.trim() !== '') ?? 'auth failure');
1219
1287
  }
1220
1288
  }
1221
- return mirrorClaudeAfterExit(spec, task, turn, output, liveMirror);
1289
+ await mirrorClaudeAfterExit(spec, task, turn, drainedStdout, liveMirror);
1290
+ const closeTurn = async () => {
1291
+ if (spec.childSession === undefined)
1292
+ return;
1293
+ const childSession = spec.childSession;
1294
+ const settled = await result;
1295
+ // Every terminal path closes the turn so the timing window never stays
1296
+ // open on a failed or cancelled run. An in-stream error result or a
1297
+ // non-zero exit settles 'error', a locally cancelled run 'aborted'.
1298
+ if (settled.stopReason === 'completed') {
1299
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
1300
+ }
1301
+ else if (settled.stopReason === 'aborted') {
1302
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
1303
+ }
1304
+ else {
1305
+ childSession.append('turn/end', {
1306
+ turn,
1307
+ reason: {
1308
+ kind: 'error',
1309
+ error: {
1310
+ message: parseClaudeStreamJson(drainedStdout).error ?? `claude -p exited with code ${String(exitCode)}`,
1311
+ code: 'UNKNOWN',
1312
+ },
1313
+ },
1314
+ });
1315
+ }
1316
+ if (spec.ctx !== undefined) {
1317
+ await persistChildSession(spec.ctx, childSession).catch((error) => {
1318
+ spec.ctx?.logger.warn(`subagent-claude: turn/end persistence failed: ${thrown(error).message}`);
1319
+ });
1320
+ }
1321
+ };
1322
+ // Behind the live mirror's queue so a flush in flight cannot interleave
1323
+ // (mirrorClaudeAfterExit already enqueued its own pass there).
1324
+ if (liveMirror === undefined)
1325
+ await closeTurn();
1326
+ else
1327
+ await liveMirror.enqueue(closeTurn);
1222
1328
  }, () => { });
1223
1329
  return Promise.resolve(subprocessRunHandle({
1224
- // A session-backed run's id is the child session id (the seam's local-run
1225
- // contract), so the tool's resume self-description names the same handle
1226
- // the delegation registry records.
1227
- id: spec.childSession?.id ?? SessionId(randomUUID()),
1330
+ // Host 0.2.1: the published run id is a fresh PER-EXECUTION identity —
1331
+ // the activation manager rejects a run id naming a live Session (the
1332
+ // mirrored child session is exactly that), and resume rounds reuse the
1333
+ // child session id. The durable child identity the family tracks rides
1334
+ // the staged intent instead (spec.childSession.id == its childSessionId).
1335
+ id: SessionId(randomUUID()),
1228
1336
  result,
1229
1337
  signal: request.signal,
1230
1338
  onAbort,
@@ -1233,6 +1341,27 @@ export async function startClaudeCliRun(request, spec) {
1233
1341
  }));
1234
1342
  }
1235
1343
  /** One assistant-role message event, attributed to the claude route. */
1344
+ /**
1345
+ * Write the round's request/header when the config differs from the child
1346
+ * log's last one (the host logs changed snapshots, not per-call duplicates):
1347
+ * the first write is `initial`, a differing one `change`, an identical one
1348
+ * skipped. Context Lens reads the child log's config from these headers.
1349
+ */
1350
+ export function appendClaudeRequestHeader(childSession, config) {
1351
+ const serialized = JSON.stringify(config);
1352
+ let last;
1353
+ for (const event of childSession.snapshotEvents()) {
1354
+ if (event.type === 'request/header') {
1355
+ last = JSON.stringify(event.data.header?.config);
1356
+ }
1357
+ }
1358
+ if (last === serialized)
1359
+ return;
1360
+ childSession.append('request/header', {
1361
+ header: { config },
1362
+ reason: last === undefined ? 'initial' : 'change',
1363
+ });
1364
+ }
1236
1365
  export function assistantEvent(blocks) {
1237
1366
  return createAssistantMessage({
1238
1367
  content: blocks,
@@ -1247,31 +1376,49 @@ export function assistantEvent(blocks) {
1247
1376
  * the real-time view renders nothing (only a full rebuild, which mints step
1248
1377
  * drafts from the explicit coordinates, recovers it). Tool cards escape that
1249
1378
  * fate only because their definition does not consult the step location.
1379
+ *
1380
+ * A tool line folds the native lifecycle inside its one step: an assistant
1381
+ * message advertising the tool-call block, the `tool/call`, and its
1382
+ * `tool/result` — the released-log reader pairs call and result against the
1383
+ * advertisement and resolves every advertised call before the step closes, so
1384
+ * a line whose result the stream has not recorded yet must NOT reach this
1385
+ * fold mid-round (the callers hold there; the result merges into the call's
1386
+ * line in place, so a later pass sees it). A line still unresulted at settle
1387
+ * folds with the interrupted placeholder ({@link CLAUDE_INTERRUPTED_TOOL_RESULT}).
1250
1388
  */
1251
1389
  export function appendClaudeTranscriptLine(childSession, turn, step, line, usage) {
1390
+ // The line's own per-call usage (stamped by the fold from the assistant
1391
+ // event's inline report) wins; the caller's round total is the fallback for
1392
+ // a stream that named none.
1393
+ const lineUsage = line.usage ?? usage;
1252
1394
  childSession.append('step/start', { turn, step });
1253
1395
  if (line.kind === 'tool') {
1254
- // Native tool card: the call event now, the result event when the stream
1255
- // already carries it. Tool lines never carry the round's usage — the
1256
- // callers attach usage to the round's last non-tool line.
1396
+ // A tool-only call's usage rides the advertising assistant/message (the
1397
+ // tool/call and tool/result events themselves have no usage slot).
1398
+ const args = line.args ?? '';
1399
+ childSession.append('assistant/message', {
1400
+ turn,
1401
+ step,
1402
+ message: assistantEvent([{ type: 'tool-call', id: line.id, name: line.name, arguments: args }]),
1403
+ stream: [],
1404
+ ...lineUsage === undefined ? {} : { usage: lineUsage },
1405
+ }, { surfaceOp: 'append' });
1257
1406
  const call = childSession.append('tool/call', {
1258
1407
  turn,
1259
1408
  step,
1260
1409
  callId: line.id,
1261
1410
  name: line.name,
1262
- arguments: line.args ?? '',
1411
+ arguments: args,
1263
1412
  });
1264
- if (line.result !== undefined) {
1265
- childSession.append('tool/result', {
1266
- turn,
1267
- step,
1268
- message: createToolResultMessage({
1269
- callId: line.id,
1270
- content: [{ type: 'text', text: line.result }],
1271
- isError: false,
1272
- }),
1273
- }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
1274
- }
1413
+ childSession.append('tool/result', {
1414
+ turn,
1415
+ step,
1416
+ message: createToolResultMessage({
1417
+ callId: line.id,
1418
+ content: [{ type: 'text', text: line.result ?? CLAUDE_INTERRUPTED_TOOL_RESULT }],
1419
+ isError: line.result === undefined,
1420
+ }),
1421
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
1275
1422
  childSession.append('step/end', { turn, step });
1276
1423
  return;
1277
1424
  }
@@ -1283,24 +1430,64 @@ export function appendClaudeTranscriptLine(childSession, turn, step, line, usage
1283
1430
  step,
1284
1431
  message: assistantEvent(blocks),
1285
1432
  stream: [],
1286
- ...usage === undefined ? {} : { usage },
1433
+ ...lineUsage === undefined ? {} : { usage: lineUsage },
1287
1434
  }, { surfaceOp: 'append' });
1288
1435
  childSession.append('step/end', { turn, step });
1289
1436
  }
1290
- /**
1291
- * Fold one transcript line into the run's child session as one assistant step.
1292
- */
1293
- function appendClaudeLine(spec, turn, step, line, usage) {
1294
- if (spec.childSession === undefined)
1295
- return;
1296
- appendClaudeTranscriptLine(spec.childSession, turn, step, line, usage);
1297
- }
1298
1437
  /** The delta-progress text for one transcript line. */
1299
1438
  export function claudeLineText(line) {
1300
1439
  return line.kind === 'tool'
1301
1440
  ? `[工具 ${line.name}]${line.args !== undefined ? ` ${line.args}` : ''}${line.result !== undefined ? ` → ${line.result}` : ''}`
1302
1441
  : line.text;
1303
1442
  }
1443
+ /**
1444
+ * Fold one API call (a message-id group) into the child session as ONE
1445
+ * assistant step: a single assistant/message carrying the call's reasoning,
1446
+ * text and tool-call blocks (the host's own mixed-content shape), then each
1447
+ * call's native lifecycle, then step/end. The call's usage rides the message
1448
+ * once — the group's own per-call stamp wins over the round-total fallback.
1449
+ */
1450
+ export function appendClaudeTranscriptGroup(childSession, turn, step, group, usage) {
1451
+ const lineUsage = group.map(line => line.usage).find(value => value !== undefined) ?? usage;
1452
+ const blocks = [];
1453
+ for (const line of group) {
1454
+ if (line.kind === 'think')
1455
+ blocks.push({ type: 'reasoning', text: line.text });
1456
+ else if (line.kind === 'text')
1457
+ blocks.push({ type: 'text', text: line.text });
1458
+ else
1459
+ blocks.push({ type: 'tool-call', id: line.id, name: line.name, arguments: line.args ?? '' });
1460
+ }
1461
+ childSession.append('step/start', { turn, step });
1462
+ childSession.append('assistant/message', {
1463
+ turn,
1464
+ step,
1465
+ message: assistantEvent(blocks),
1466
+ stream: [],
1467
+ ...lineUsage === undefined ? {} : { usage: lineUsage },
1468
+ }, { surfaceOp: 'append' });
1469
+ for (const line of group) {
1470
+ if (line.kind !== 'tool')
1471
+ continue;
1472
+ const call = childSession.append('tool/call', {
1473
+ turn,
1474
+ step,
1475
+ callId: line.id,
1476
+ name: line.name,
1477
+ arguments: line.args ?? '',
1478
+ });
1479
+ childSession.append('tool/result', {
1480
+ turn,
1481
+ step,
1482
+ message: createToolResultMessage({
1483
+ callId: line.id,
1484
+ content: [{ type: 'text', text: line.result ?? CLAUDE_INTERRUPTED_TOOL_RESULT }],
1485
+ isError: line.result === undefined,
1486
+ }),
1487
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
1488
+ }
1489
+ childSession.append('step/end', { turn, step });
1490
+ }
1304
1491
  /** Create the per-run live mirror over the incremental parser. */
1305
1492
  function createClaudeLiveMirror(spec, task, turn) {
1306
1493
  const parser = new ClaudeStreamParser();
@@ -1308,6 +1495,7 @@ function createClaudeLiveMirror(spec, task, turn) {
1308
1495
  const ctx = spec.ctx;
1309
1496
  let mirrored = 0;
1310
1497
  let userMirrored = false;
1498
+ let headerWritten = false;
1311
1499
  let lastTodos;
1312
1500
  let todoSkewWarned = false;
1313
1501
  let queue = Promise.resolve();
@@ -1315,7 +1503,11 @@ function createClaudeLiveMirror(spec, task, turn) {
1315
1503
  get mirroredLines() { return mirrored; },
1316
1504
  get userMirrored() { return userMirrored; },
1317
1505
  enqueue(task) {
1318
- queue = queue.then(task);
1506
+ // A failed task must not poison the queue for the passes that still owe
1507
+ // writes (the settle mirror, the turn boundary): heal the tail before
1508
+ // chaining. The returned promise still carries THIS task's rejection to
1509
+ // its own caller.
1510
+ queue = queue.catch(() => { }).then(task);
1319
1511
  return queue;
1320
1512
  },
1321
1513
  push(chunk) {
@@ -1333,8 +1525,32 @@ function createClaudeLiveMirror(spec, task, turn) {
1333
1525
  todoSkewWarned = true;
1334
1526
  ctx.logger.warn('subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line');
1335
1527
  }
1336
- const upto = parser.completed ? parser.lines.length : parser.lines.length - 1;
1337
- if (upto <= mirrored)
1528
+ // The fold window: every GROUP but the volatile last one — plus a HOLD
1529
+ // at the first group with an unresulted tool line (the released-log
1530
+ // reader resolves every advertised call before step/end; the result
1531
+ // merges into the call's line in place, so the next push retries from
1532
+ // here and sees it). A completed parser never holds: a result that will
1533
+ // never arrive folds as the interrupted placeholder.
1534
+ const groups = groupClaudeTranscriptLines(parser.lines);
1535
+ const groupStarts = [];
1536
+ let totalLines = 0;
1537
+ for (const group of groups) {
1538
+ groupStarts.push(totalLines);
1539
+ totalLines += group.length;
1540
+ }
1541
+ let uptoGroups = parser.completed ? groups.length : groups.length - 1;
1542
+ if (!parser.completed) {
1543
+ for (let scan = 0; scan < uptoGroups; scan += 1) {
1544
+ if (groupStarts[scan] < mirrored)
1545
+ continue;
1546
+ if (groups[scan].some(line => line.kind === 'tool' && line.result === undefined)) {
1547
+ uptoGroups = scan;
1548
+ break;
1549
+ }
1550
+ }
1551
+ }
1552
+ const upto = groupStarts[uptoGroups] ?? totalLines;
1553
+ if (upto <= mirrored && userMirrored)
1338
1554
  return;
1339
1555
  void mirror.enqueue(async () => {
1340
1556
  try {
@@ -1347,26 +1563,43 @@ function createClaudeLiveMirror(spec, task, turn) {
1347
1563
  }), { surfaceOp: 'append' });
1348
1564
  localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1349
1565
  }
1350
- // The usage rides the last NON-tool line (tool events carry no
1351
- // usage slot); a stream ending on a tool line would otherwise drop
1352
- // the round's accounting. When that carrier was mirrored in an
1353
- // earlier flush (before the usage was knowable), the accounting is
1354
- // lost — host 0.1.5 has no usage-backfill event.
1566
+ if (!headerWritten) {
1567
+ // The launch facts plus the init event's observed model are the
1568
+ // child log's request config (claude's wire never carries
1569
+ // effort) — Context Lens reads request/header instead of showing
1570
+ // 未记录. The header contract requires a model: without one (a
1571
+ // stream that errored before init) nothing is recorded.
1572
+ const observedModel = parser.model ?? spec.model;
1573
+ if (observedModel !== undefined) {
1574
+ headerWritten = true;
1575
+ appendClaudeRequestHeader(childSession, {
1576
+ provider: 'claude-local',
1577
+ model: observedModel,
1578
+ ...spec.controlled ? { reasoningEffort: (spec.effort ?? 'auto') } : {},
1579
+ });
1580
+ }
1581
+ }
1582
+ // Fallback carrier only: when the fold stamped per-call usage onto
1583
+ // any line, the round total never joins the log (a sum would double
1584
+ // count); otherwise it rides the last group with a non-tool line as
1585
+ // before. A carrier mirrored in an earlier flush (before the usage
1586
+ // was knowable) loses the accounting — host 0.1.5 has no
1587
+ // usage-backfill event.
1355
1588
  let carrier = -1;
1356
- if (parser.completed && parser.usage !== undefined) {
1357
- for (let scan = 0; scan < parser.lines.length; scan += 1) {
1358
- if (parser.lines[scan]?.kind !== 'tool')
1589
+ if (parser.completed && parser.usage !== undefined && !parser.lines.some(line => line.usage !== undefined)) {
1590
+ for (let scan = 0; scan < groups.length; scan += 1) {
1591
+ if (groups[scan].some(line => line.kind !== 'tool'))
1359
1592
  carrier = scan;
1360
1593
  }
1361
1594
  }
1362
- for (let index = mirrored; index < upto; index += 1) {
1363
- const line = parser.lines[index];
1364
- if (line === undefined)
1595
+ for (let scan = 0; scan < uptoGroups; scan += 1) {
1596
+ if (groupStarts[scan] < mirrored)
1365
1597
  continue;
1366
- const usage = index === carrier ? parser.usage : undefined;
1367
- appendClaudeLine(spec, turn, index + 1, line, usage);
1368
- mirrored = index + 1;
1369
- localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
1598
+ const group = groups[scan];
1599
+ appendClaudeTranscriptGroup(childSession, turn, scan + 1, group, scan === carrier ? parser.usage : undefined);
1600
+ mirrored = groupStarts[scan] + group.length;
1601
+ for (const line of group)
1602
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
1370
1603
  }
1371
1604
  await persistChildSession(ctx, childSession);
1372
1605
  localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: mirrored });
@@ -1406,21 +1639,33 @@ async function appendClaudeResponse(spec, task, turn, parsed, fromLines = 0, use
1406
1639
  }), { surfaceOp: 'append' });
1407
1640
  localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1408
1641
  }
1409
- // The round's usage rides the last NON-tool line: tool activity folds to
1410
- // `tool/call`/`tool/result` events (which carry no usage slot), and a kill
1411
- // mid-tool ends the transcript with a tool line.
1412
- let usageIndex = -1;
1413
- for (let index = 0; index < parsed.lines.length; index += 1) {
1414
- if (parsed.lines[index]?.kind !== 'tool')
1415
- usageIndex = index;
1642
+ // Fallback carrier only: the round total rides the last group with a
1643
+ // non-tool line when the stream named no per-call usage; groups the fold
1644
+ // stamped with their call's inline usage keep their own.
1645
+ const groups = groupClaudeTranscriptLines(parsed.lines);
1646
+ const groupStarts = [];
1647
+ let totalLines = 0;
1648
+ for (const group of groups) {
1649
+ groupStarts.push(totalLines);
1650
+ totalLines += group.length;
1416
1651
  }
1417
- let step = fromLines + 1;
1418
- for (const line of parsed.lines.slice(fromLines)) {
1419
- appendClaudeLine(spec, turn, step, line, step - 1 === usageIndex ? parsed.usage : undefined);
1420
- localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
1421
- step += 1;
1652
+ let carrier = -1;
1653
+ if (!parsed.lines.some(line => line.usage !== undefined)) {
1654
+ for (let scan = 0; scan < groups.length; scan += 1) {
1655
+ if (groups[scan].some(line => line.kind !== 'tool'))
1656
+ carrier = scan;
1657
+ }
1658
+ }
1659
+ for (let scan = 0; scan < groups.length; scan += 1) {
1660
+ if (groupStarts[scan] < fromLines)
1661
+ continue;
1662
+ const group = groups[scan];
1663
+ appendClaudeTranscriptGroup(childSession, turn, scan + 1, group, scan === carrier ? parsed.usage : undefined);
1664
+ for (const line of group)
1665
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
1422
1666
  }
1423
- if (parsed.usage !== undefined && usageIndex !== -1 && usageIndex < fromLines) {
1667
+ const carrierLine = carrier === -1 ? -1 : (groupStarts[carrier] ?? -1);
1668
+ if (parsed.usage !== undefined && carrierLine !== -1 && carrierLine < fromLines) {
1424
1669
  // The carrier line went out through the live mirror before the usage was
1425
1670
  // knowable. Host 0.1.5 has no usage-backfill event (the per-chunk event is
1426
1671
  // gone and appended messages are immutable), so the round's accounting is