@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.
- package/CHANGELOG.md +17 -1
- package/README.en.md +24 -112
- package/README.i18n.yaml +2 -2
- package/README.md +24 -108
- package/lib/client.js +63 -63
- package/lib/index.js +528 -184
- package/lib/types/claude-cli-provider.d.ts +73 -2
- package/lib/types/claude-cli-provider.js +330 -97
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/endpoint-auth.js +17 -1
- package/lib/types/live-driver.d.ts +0 -5
- package/lib/types/live-driver.js +259 -101
- package/lib/types/model-broker.js +44 -2
- package/package.json +2 -2
|
@@ -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 =
|
|
669
|
-
? { streamId: `${
|
|
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
|
-
/**
|
|
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
|
-
.
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
|
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
|
|
1217
|
-
//
|
|
1218
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1267
|
-
//
|
|
1268
|
-
|
|
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:
|
|
1411
|
+
arguments: args,
|
|
1275
1412
|
});
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
1349
|
-
|
|
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
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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 <
|
|
1370
|
-
if (
|
|
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
|
|
1375
|
-
|
|
1376
|
-
if (line === undefined)
|
|
1595
|
+
for (let scan = 0; scan < uptoGroups; scan += 1) {
|
|
1596
|
+
if (groupStarts[scan] < mirrored)
|
|
1377
1597
|
continue;
|
|
1378
|
-
const
|
|
1379
|
-
|
|
1380
|
-
mirrored =
|
|
1381
|
-
|
|
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
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
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
|
|
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.
|