@khorsheed/dsh-local-agent-claude-code 0.2.0-rc.3 → 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.
@@ -22,6 +22,14 @@ import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel
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
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
  /**
@@ -33,6 +41,11 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000;
33
41
  * provider-controlled error strings only.
34
42
  */
35
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.
47
+ */
48
+ export const MEMBER_TOOL_TIMEOUT_MS = String(30 * 60_000);
36
49
  /** Register one member run with the channel; see {@link ClaudeMemberRunHandle}. */
37
50
  export function registerClaudeMemberRun(ctx, childSessionId, parentSessionId) {
38
51
  const registry = ctx.localAgent;
@@ -349,6 +362,8 @@ export class ClaudeCliProvider {
349
362
  cwd,
350
363
  env: delegationEnv({
351
364
  CLAUDE_CONFIG_DIR: homeDir,
365
+ // A bridge call may wait for the human (see MEMBER_TOOL_TIMEOUT_MS).
366
+ ...member === undefined ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
352
367
  ...endpointAuthEnv(endpoint),
353
368
  }),
354
369
  ...exec === undefined ? {} : { exec },
@@ -462,6 +477,7 @@ export class ClaudeCliProvider {
462
477
  cwd,
463
478
  env: delegationEnv({
464
479
  CLAUDE_CONFIG_DIR: homeDir,
480
+ ...member === undefined ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
465
481
  ...endpointAuthEnv(endpoint),
466
482
  }),
467
483
  ...exec === undefined ? {} : { exec },
@@ -532,6 +548,19 @@ export function textTask(prompt) {
532
548
  }
533
549
  return texts.join('\n');
534
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
+ }
535
564
  /**
536
565
  * Fold a tool-call counter into the settled shape: `{count, byName}` with the
537
566
  * per-name values summing to `count`. An empty map yields undefined — a
@@ -661,13 +690,20 @@ function foldClaudeStreamLine(state, raw) {
661
690
  if (event.type !== 'assistant' && event.type !== 'user')
662
691
  return;
663
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;
664
695
  for (const [blockIndex, block] of blocks.entries()) {
665
696
  if (typeof block !== 'object' || block === null)
666
697
  continue;
667
698
  const record = block;
668
- const identity = event.type === 'assistant' && typeof event.message?.id === 'string'
669
- ? { streamId: `${event.message.id}:${blockIndex}` } : {};
699
+ const identity = messageId !== undefined
700
+ ? { streamId: `${messageId}:${blockIndex}`, messageId } : {};
670
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;
671
707
  if (kind === 'text' && typeof record['text'] === 'string' && record['text'].trim() !== '') {
672
708
  state.lines.push({ kind: 'text', text: record['text'], ...identity });
673
709
  // The final assistant text block is the run output.
@@ -712,6 +748,7 @@ function foldClaudeStreamLine(state, raw) {
712
748
  id: id ?? `claude-tool-${state.lines.length}`,
713
749
  name: 'ApplyPatch',
714
750
  args: edit.args,
751
+ ...messageId === undefined ? {} : { messageId },
715
752
  ...edit.patch === undefined ? {} : { result: edit.patch },
716
753
  });
717
754
  }
@@ -723,6 +760,7 @@ function foldClaudeStreamLine(state, raw) {
723
760
  // The server-side tools surface under the local tools' card names
724
761
  // (web_search is the same activity the local WebSearch tool runs).
725
762
  name: kind === 'server_tool_use' ? SERVER_TOOL_CARD_NAMES[name] ?? name : name,
763
+ ...messageId === undefined ? {} : { messageId },
726
764
  ...detail === undefined ? {} : { args: detail },
727
765
  });
728
766
  }
@@ -742,6 +780,26 @@ function foldClaudeStreamLine(state, raw) {
742
780
  }
743
781
  }
744
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
+ }
745
803
  }
746
804
  /**
747
805
  * Merge a tool result into its call line: pair by tool_use_id when the stream
@@ -783,6 +841,7 @@ export class ClaudeStreamParser {
783
841
  buffer = '';
784
842
  lines = [];
785
843
  callsById = new Map();
844
+ usageStamped = new Map();
786
845
  text;
787
846
  usage;
788
847
  sessionId;
@@ -820,6 +879,7 @@ export function parseClaudeStreamJson(output) {
820
879
  const state = {
821
880
  lines: [],
822
881
  callsById: new Map(),
882
+ usageStamped: new Map(),
823
883
  toolCalls: new Map(),
824
884
  text: undefined,
825
885
  usage: undefined,
@@ -990,26 +1050,43 @@ function serverToolResultText(record) {
990
1050
  }
991
1051
  return undefined;
992
1052
  }
993
- /** 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
+ */
994
1060
  function toolResultText(content) {
995
1061
  if (typeof content === 'string')
996
1062
  return content;
997
1063
  if (!Array.isArray(content))
998
1064
  return undefined;
999
1065
  return content
1000
- .filter((block) => typeof block === 'object' && block !== null && block.type === 'text'
1001
- && typeof block.text === 'string')
1002
- .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)
1003
1077
  .join('\n');
1004
1078
  }
1005
1079
  /**
1006
1080
  * Map claude's usage payload onto the shared usage contract. Claude's
1007
1081
  * `input_tokens` is the uncached input; cache reads and cache creation are
1008
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".
1009
1086
  * @param usage - the raw claude usage object.
1010
1087
  * @returns the shared usage record.
1011
1088
  */
1012
- function usageFromClaude(usage) {
1089
+ export function usageFromClaude(usage) {
1013
1090
  const raw = usage;
1014
1091
  if (raw === undefined || typeof raw !== 'object')
1015
1092
  return { inputTokens: 0, outputTokens: 0 };
@@ -1025,8 +1102,8 @@ function usageFromClaude(usage) {
1025
1102
  inputTokens: input ?? 0,
1026
1103
  outputTokens: output ?? 0,
1027
1104
  };
1028
- if (cacheRead !== undefined && cacheRead > 0)
1029
- usageRecord.cacheReadTokens = cacheRead;
1105
+ if (cacheRead !== undefined || cacheWrite !== undefined)
1106
+ usageRecord.cacheReadTokens = cacheRead ?? 0;
1030
1107
  if (cacheWrite !== undefined && cacheWrite > 0)
1031
1108
  usageRecord.cacheWriteTokens = cacheWrite;
1032
1109
  return usageRecord;
@@ -1178,34 +1255,11 @@ export async function startClaudeCliRun(request, spec) {
1178
1255
  }).then((settled) => {
1179
1256
  // A completed round proves the scoped credential is live — the endpoint
1180
1257
  // answered. Reported here rather than from the mirror so it lands whether
1181
- // 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.
1182
1261
  if (settled.stopReason === 'completed')
1183
1262
  spec.onAuthSuccess?.();
1184
- // Every terminal path closes the turn so the timing window never stays
1185
- // open on a failed or cancelled run. The turn/end timestamp is the real
1186
- // settle moment; an in-stream error result or a non-zero exit settles
1187
- // 'error', a locally cancelled run 'aborted'.
1188
- if (spec.childSession !== undefined) {
1189
- if (settled.stopReason === 'completed') {
1190
- spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
1191
- }
1192
- else if (settled.stopReason === 'aborted') {
1193
- spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
1194
- }
1195
- else {
1196
- const parsed = parseClaudeStreamJson(output);
1197
- spec.childSession.append('turn/end', {
1198
- turn,
1199
- reason: {
1200
- kind: 'error',
1201
- error: {
1202
- message: parsed.error ?? `claude -p exited with code ${String(exitCode)}`,
1203
- code: 'UNKNOWN',
1204
- },
1205
- },
1206
- });
1207
- }
1208
- }
1209
1263
  return settled;
1210
1264
  });
1211
1265
  // After the child EXITS — however it ended (completed, killed by dispose, or
@@ -1213,22 +1267,64 @@ export async function startClaudeCliRun(request, spec) {
1213
1267
  // subagent session, so a cancelled round still preserves its partial work
1214
1268
  // (thinking, tool calls with results, replies) and real token usage. The
1215
1269
  // session id is recorded even on abort so the partial session stays
1216
- // resumable. Waits for the settle chain first (so turn/end is already
1217
- // appended) AND for the process to actually exit (so stdout is drained).
1218
- 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;
1219
1280
  // Auth detection runs post-exit: streams are drained by then, so a fast
1220
1281
  // failure's output is complete (a settle-time read could race the flush).
1221
1282
  if (spec.onAuthFailure !== undefined) {
1222
- // The seam's collected buffers are complete at process exit; the
1223
- // streamed variables can lag `done` by a tick.
1224
1283
  const drainedStderr = child.collected.stderr?.readFrom(0).text || stderr;
1225
- const drainedStdout = child.collected.stdout?.readFrom(0).text || output;
1226
1284
  const authDetail = `${parseClaudeStreamJson(drainedStdout).error ?? ''}\n${drainedStderr}`;
1227
1285
  if (CLAUDE_AUTH_FAILURE.test(authDetail)) {
1228
1286
  spec.onAuthFailure(authDetail.split('\n').find(line => line.trim() !== '') ?? 'auth failure');
1229
1287
  }
1230
1288
  }
1231
- 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);
1232
1328
  }, () => { });
1233
1329
  return Promise.resolve(subprocessRunHandle({
1234
1330
  // Host 0.2.1: the published run id is a fresh PER-EXECUTION identity —
@@ -1245,6 +1341,27 @@ export async function startClaudeCliRun(request, spec) {
1245
1341
  }));
1246
1342
  }
1247
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
+ }
1248
1365
  export function assistantEvent(blocks) {
1249
1366
  return createAssistantMessage({
1250
1367
  content: blocks,
@@ -1259,31 +1376,49 @@ export function assistantEvent(blocks) {
1259
1376
  * the real-time view renders nothing (only a full rebuild, which mints step
1260
1377
  * drafts from the explicit coordinates, recovers it). Tool cards escape that
1261
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}).
1262
1388
  */
1263
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;
1264
1394
  childSession.append('step/start', { turn, step });
1265
1395
  if (line.kind === 'tool') {
1266
- // Native tool card: the call event now, the result event when the stream
1267
- // already carries it. Tool lines never carry the round's usage — the
1268
- // 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' });
1269
1406
  const call = childSession.append('tool/call', {
1270
1407
  turn,
1271
1408
  step,
1272
1409
  callId: line.id,
1273
1410
  name: line.name,
1274
- arguments: line.args ?? '',
1411
+ arguments: args,
1275
1412
  });
1276
- if (line.result !== undefined) {
1277
- childSession.append('tool/result', {
1278
- turn,
1279
- step,
1280
- message: createToolResultMessage({
1281
- callId: line.id,
1282
- content: [{ type: 'text', text: line.result }],
1283
- isError: false,
1284
- }),
1285
- }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
1286
- }
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] });
1287
1422
  childSession.append('step/end', { turn, step });
1288
1423
  return;
1289
1424
  }
@@ -1295,24 +1430,64 @@ export function appendClaudeTranscriptLine(childSession, turn, step, line, usage
1295
1430
  step,
1296
1431
  message: assistantEvent(blocks),
1297
1432
  stream: [],
1298
- ...usage === undefined ? {} : { usage },
1433
+ ...lineUsage === undefined ? {} : { usage: lineUsage },
1299
1434
  }, { surfaceOp: 'append' });
1300
1435
  childSession.append('step/end', { turn, step });
1301
1436
  }
1302
- /**
1303
- * Fold one transcript line into the run's child session as one assistant step.
1304
- */
1305
- function appendClaudeLine(spec, turn, step, line, usage) {
1306
- if (spec.childSession === undefined)
1307
- return;
1308
- appendClaudeTranscriptLine(spec.childSession, turn, step, line, usage);
1309
- }
1310
1437
  /** The delta-progress text for one transcript line. */
1311
1438
  export function claudeLineText(line) {
1312
1439
  return line.kind === 'tool'
1313
1440
  ? `[工具 ${line.name}]${line.args !== undefined ? ` ${line.args}` : ''}${line.result !== undefined ? ` → ${line.result}` : ''}`
1314
1441
  : line.text;
1315
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
+ }
1316
1491
  /** Create the per-run live mirror over the incremental parser. */
1317
1492
  function createClaudeLiveMirror(spec, task, turn) {
1318
1493
  const parser = new ClaudeStreamParser();
@@ -1320,6 +1495,7 @@ function createClaudeLiveMirror(spec, task, turn) {
1320
1495
  const ctx = spec.ctx;
1321
1496
  let mirrored = 0;
1322
1497
  let userMirrored = false;
1498
+ let headerWritten = false;
1323
1499
  let lastTodos;
1324
1500
  let todoSkewWarned = false;
1325
1501
  let queue = Promise.resolve();
@@ -1327,7 +1503,11 @@ function createClaudeLiveMirror(spec, task, turn) {
1327
1503
  get mirroredLines() { return mirrored; },
1328
1504
  get userMirrored() { return userMirrored; },
1329
1505
  enqueue(task) {
1330
- 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);
1331
1511
  return queue;
1332
1512
  },
1333
1513
  push(chunk) {
@@ -1345,8 +1525,32 @@ function createClaudeLiveMirror(spec, task, turn) {
1345
1525
  todoSkewWarned = true;
1346
1526
  ctx.logger.warn('subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line');
1347
1527
  }
1348
- const upto = parser.completed ? parser.lines.length : parser.lines.length - 1;
1349
- 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)
1350
1554
  return;
1351
1555
  void mirror.enqueue(async () => {
1352
1556
  try {
@@ -1359,26 +1563,43 @@ function createClaudeLiveMirror(spec, task, turn) {
1359
1563
  }), { surfaceOp: 'append' });
1360
1564
  localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1361
1565
  }
1362
- // The usage rides the last NON-tool line (tool events carry no
1363
- // usage slot); a stream ending on a tool line would otherwise drop
1364
- // the round's accounting. When that carrier was mirrored in an
1365
- // earlier flush (before the usage was knowable), the accounting is
1366
- // 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.
1367
1588
  let carrier = -1;
1368
- if (parser.completed && parser.usage !== undefined) {
1369
- for (let scan = 0; scan < parser.lines.length; scan += 1) {
1370
- 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'))
1371
1592
  carrier = scan;
1372
1593
  }
1373
1594
  }
1374
- for (let index = mirrored; index < upto; index += 1) {
1375
- const line = parser.lines[index];
1376
- if (line === undefined)
1595
+ for (let scan = 0; scan < uptoGroups; scan += 1) {
1596
+ if (groupStarts[scan] < mirrored)
1377
1597
  continue;
1378
- const usage = index === carrier ? parser.usage : undefined;
1379
- appendClaudeLine(spec, turn, index + 1, line, usage);
1380
- mirrored = index + 1;
1381
- 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) });
1382
1603
  }
1383
1604
  await persistChildSession(ctx, childSession);
1384
1605
  localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: mirrored });
@@ -1418,21 +1639,33 @@ async function appendClaudeResponse(spec, task, turn, parsed, fromLines = 0, use
1418
1639
  }), { surfaceOp: 'append' });
1419
1640
  localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1420
1641
  }
1421
- // The round's usage rides the last NON-tool line: tool activity folds to
1422
- // `tool/call`/`tool/result` events (which carry no usage slot), and a kill
1423
- // mid-tool ends the transcript with a tool line.
1424
- let usageIndex = -1;
1425
- for (let index = 0; index < parsed.lines.length; index += 1) {
1426
- if (parsed.lines[index]?.kind !== 'tool')
1427
- 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;
1428
1651
  }
1429
- let step = fromLines + 1;
1430
- for (const line of parsed.lines.slice(fromLines)) {
1431
- appendClaudeLine(spec, turn, step, line, step - 1 === usageIndex ? parsed.usage : undefined);
1432
- localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
1433
- 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) });
1434
1666
  }
1435
- 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) {
1436
1669
  // The carrier line went out through the live mirror before the usage was
1437
1670
  // knowable. Host 0.1.5 has no usage-backfill event (the per-chunk event is
1438
1671
  // gone and appended messages are immutable), so the round's accounting is
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Generated by scripts/sync-icon-artwork.mts from @deepseek-ai/dsh-client-ui-primitives
3
- * (v0.1.7-rc.1, harness 46a7f68b09) — do not edit by hand. The 0.1.5 and rc.1 host lines
3
+ * (v0.2.1-rc.1, harness 1600d8a1e1) — do not edit by hand. The 0.1.5 and rc.1 host lines
4
4
  * share no icon export name, so the rc.1 artwork is inlined here: pure SVG,
5
5
  * no host runtime identity, identical on every line. Re-run the generator
6
6
  * after adding an icon import or bumping the harness checkout.