@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.
- package/CHANGELOG.md +27 -0
- package/README.en.md +24 -106
- package/README.i18n.yaml +2 -2
- package/README.md +24 -106
- package/lib/client.js +842 -315
- package/lib/index.js +1707 -1392
- package/lib/types/claude-cli-provider.d.ts +86 -6
- package/lib/types/claude-cli-provider.js +391 -146
- package/lib/types/client/SettingsCard.d.ts +75 -24
- package/lib/types/client/SettingsCard.js +106 -128
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/index.d.ts +12 -25
- package/lib/types/client/index.js +135 -50
- package/lib/types/client/locales.d.ts +36 -24
- package/lib/types/client/locales.js +40 -28
- package/lib/types/endpoint-auth.d.ts +181 -0
- package/lib/types/endpoint-auth.js +297 -0
- package/lib/types/index.d.ts +32 -16
- package/lib/types/index.js +128 -62
- package/lib/types/live-driver.d.ts +9 -6
- package/lib/types/live-driver.js +277 -119
- package/lib/types/live-switch.d.ts +4 -1
- package/lib/types/model-broker.d.ts +4 -2
- package/lib/types/model-broker.js +63 -10
- package/lib/types/model-catalog.d.ts +35 -6
- package/lib/types/model-catalog.js +58 -68
- package/lib/types/provision.d.ts +34 -27
- package/lib/types/provision.js +49 -31
- package/lib/types/records.d.ts +4 -62
- package/lib/types/records.js +5 -382
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +20 -21
- package/lib/invariant.js +0 -31
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -33
|
@@ -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 {
|
|
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
|
|
30
|
-
*
|
|
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
|
|
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
|
-
|
|
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',
|
|
162
|
+
constructor(ctx, permissionMode = 'skip', endpoint, live, model) {
|
|
142
163
|
this.ctx = ctx;
|
|
143
164
|
this.permissionMode = permissionMode;
|
|
144
|
-
this.
|
|
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
|
-
//
|
|
172
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
272
|
+
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
273
|
+
mode: 'one-shot',
|
|
274
|
+
provider: this.name,
|
|
248
275
|
label,
|
|
249
276
|
});
|
|
250
|
-
// The
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
const effectiveBaseUrl =
|
|
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
|
|
307
|
-
// it
|
|
308
|
-
...
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
364
|
-
|
|
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
|
|
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
|
-
...
|
|
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 =
|
|
645
|
-
? { streamId: `${
|
|
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
|
-
/**
|
|
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
|
-
.
|
|
977
|
-
|
|
978
|
-
|
|
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
|
|
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
|
|
1207
|
-
//
|
|
1208
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
id
|
|
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
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
|
|
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:
|
|
1411
|
+
arguments: args,
|
|
1263
1412
|
});
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
1337
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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 <
|
|
1358
|
-
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'))
|
|
1359
1592
|
carrier = scan;
|
|
1360
1593
|
}
|
|
1361
1594
|
}
|
|
1362
|
-
for (let
|
|
1363
|
-
|
|
1364
|
-
if (line === undefined)
|
|
1595
|
+
for (let scan = 0; scan < uptoGroups; scan += 1) {
|
|
1596
|
+
if (groupStarts[scan] < mirrored)
|
|
1365
1597
|
continue;
|
|
1366
|
-
const
|
|
1367
|
-
|
|
1368
|
-
mirrored =
|
|
1369
|
-
|
|
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
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
|
|
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
|