@khorsheed/dsh-local-agent-claude-code 0.1.0-rc.6

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.
Files changed (38) hide show
  1. package/README.en.md +120 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +120 -0
  4. package/cordis.patch.yml +42 -0
  5. package/lib/client.js +1065 -0
  6. package/lib/index.js +3771 -0
  7. package/lib/invariant.js +31 -0
  8. package/lib/types/claude-cli-provider.d.ts +389 -0
  9. package/lib/types/claude-cli-provider.js +1509 -0
  10. package/lib/types/client/SettingsCard.d.ts +78 -0
  11. package/lib/types/client/SettingsCard.js +198 -0
  12. package/lib/types/client/icons.d.ts +18 -0
  13. package/lib/types/client/icons.js +2 -0
  14. package/lib/types/client/index.d.ts +36 -0
  15. package/lib/types/client/index.js +87 -0
  16. package/lib/types/client/locales.d.ts +64 -0
  17. package/lib/types/client/locales.js +55 -0
  18. package/lib/types/control-requests.d.ts +15 -0
  19. package/lib/types/control-requests.js +62 -0
  20. package/lib/types/index.d.ts +81 -0
  21. package/lib/types/index.js +300 -0
  22. package/lib/types/invariant.d.ts +16 -0
  23. package/lib/types/invariant.js +33 -0
  24. package/lib/types/live-driver.d.ts +172 -0
  25. package/lib/types/live-driver.js +1057 -0
  26. package/lib/types/live-switch.d.ts +67 -0
  27. package/lib/types/live-switch.js +85 -0
  28. package/lib/types/model-broker.d.ts +131 -0
  29. package/lib/types/model-broker.js +248 -0
  30. package/lib/types/model-catalog.d.ts +27 -0
  31. package/lib/types/model-catalog.js +118 -0
  32. package/lib/types/provision.d.ts +63 -0
  33. package/lib/types/provision.js +132 -0
  34. package/lib/types/records.d.ts +120 -0
  35. package/lib/types/records.js +687 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +106 -0
@@ -0,0 +1,1509 @@
1
+ /**
2
+ * One-shot Claude Code CLI subagent lifecycle: spawn `claude -p "<task>"`
3
+ * through the subprocess seam under the harness's scoped home, capture its
4
+ * JSON result as the run output, and dispose to whole-tree quiescence.
5
+ * Mirrors the kimi/codex providers: every accepted run is a fresh process
6
+ * and a fresh claude session; there is no continuation across runs.
7
+ *
8
+ * The provider name `claude-local` avoids colliding with the official
9
+ * `subagent-claude-code` package's provider name `claude-code` — a
10
+ * composition that mounts both would fail loud with DUPLICATE_PROVIDER. The
11
+ * tool row in this bundle's patch takes the official model-facing name
12
+ * `subagent_claude_code` instead: the official preset row ships disabled,
13
+ * and the patch disables it too (a deliberate user re-enable conflicts
14
+ * loud, by design).
15
+ * @module @khorsheed/dsh-local-agent-claude-code/claude-cli-provider
16
+ */
17
+ import { randomUUID } from 'node:crypto';
18
+ import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
19
+ import { SessionId } from '@deepseek-ai/dsh-session';
20
+ import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
21
+ import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, persistChildSession, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
22
+ import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
23
+ import { LiveChannelUnavailableError } from './live-driver.js';
24
+ import { syncClaudeCredentialFile } from './records.js';
25
+ /** Default POSIX grace between subprocess termination tiers. */
26
+ export const DEFAULT_DISPOSE_GRACE_MS = 3_000;
27
+ /**
28
+ * Auth-shaped failure signatures in claude's result error or stderr: the
29
+ * endpoint rejected the credential (401/403, revoked token). Narrow on
30
+ * purpose — matched against provider-controlled error strings only.
31
+ */
32
+ export const CLAUDE_AUTH_FAILURE = /failed to authenticate|authentication_failed|oauth access token/i;
33
+ /** Register one member run with the channel; see {@link ClaudeMemberRunHandle}. */
34
+ export function registerClaudeMemberRun(ctx, childSessionId, parentSessionId) {
35
+ const registry = ctx.localAgent;
36
+ if (typeof registry.registerMemberRun !== 'function'
37
+ || typeof registry.memberBridgeSocketPath !== 'function'
38
+ || typeof registry.memberBridgeCommand !== 'function')
39
+ return undefined;
40
+ const token = registry.registerMemberRun({ childSessionId, parentSessionId, provider: 'claude-local' });
41
+ const serverName = `dsh-member-${token.slice(0, 8)}`;
42
+ const bridge = registry.memberBridgeCommand();
43
+ const mcpConfig = JSON.stringify({
44
+ mcpServers: {
45
+ [serverName]: {
46
+ command: bridge.command,
47
+ args: bridge.args,
48
+ env: {
49
+ [MEMBER_BRIDGE_SOCKET_ENV]: registry.memberBridgeSocketPath(),
50
+ [MEMBER_BRIDGE_TOKEN_ENV]: token,
51
+ },
52
+ },
53
+ },
54
+ });
55
+ let released = false;
56
+ return {
57
+ token,
58
+ mcpConfig,
59
+ // The one tool the bridge exposes, pre-allowed so the child (whose
60
+ // non-interactive mode auto-denies permission prompts) can call it.
61
+ allowedTool: `mcp__${serverName}__member_message`,
62
+ // Token-only auth: host 0.1.5 removed the child pid the parentage
63
+ // cross-check used; the per-run token is the sole credential (see the
64
+ // package README's threat model).
65
+ release: () => {
66
+ if (released)
67
+ return;
68
+ released = true;
69
+ registry.unregisterMemberRun(token);
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * One-shot and resumable Claude Code CLI subagent provider: every accepted
75
+ * FRESH run starts a `claude -p` process in the delegating Session's
76
+ * workspace, under the harness scoped home; a resume round (the family tool's
77
+ * staged resume intent) continues the SAME session with `claude -p --resume
78
+ * <session_id>` inside the SAME dsh child session. With the live driver
79
+ * configured (`live: true`), rounds instead go to the resident stream-json
80
+ * process (see live-driver.ts); the exec path below stays the fallback.
81
+ */
82
+ /**
83
+ * Mark the harness credential verified after a completed round, degrading
84
+ * silently on a core that predates the mark (the family's companion-pair
85
+ * rule: a provider paired with an older core loses the grade, never the run).
86
+ * @param ctx - host context carrying the family registry.
87
+ */
88
+ function markCredentialVerified(ctx, scope) {
89
+ const registry = ctx.get('localAgent');
90
+ if (registry === undefined || typeof registry.reportAuthSuccess !== 'function')
91
+ return;
92
+ // The grade belongs to the scope the round ran against: each scoped home
93
+ // holds its own account.
94
+ registry.reportAuthSuccess('claude-code', scope);
95
+ }
96
+ /**
97
+ * The version `claude --version` reports, probed through the shared subprocess
98
+ * seam against the SCOPED config directory (never the user's own
99
+ * installation) and cached by the family probe against the executable's
100
+ * identity. The effective-settings snapshot uses it; a settled round prefers
101
+ * the version its own stream named.
102
+ * @param ctx - host context carrying the subprocess seam.
103
+ * @param homeDir - the harness's scoped home.
104
+ * @returns the version, or undefined when the CLI cannot be asked.
105
+ */
106
+ export function claudeCliVersion(ctx, homeDir) {
107
+ // Degrade, don't explode: a composition without the subprocess seam simply
108
+ // reports no version, exactly as an unaskable CLI does.
109
+ const subprocess = ctx.get('subprocess');
110
+ if (subprocess === undefined)
111
+ return Promise.resolve(undefined);
112
+ return probeCliVersion({
113
+ argv: ['claude', '--version'],
114
+ cwd: homeDir,
115
+ spawn: spec => subprocess.spawn(spec),
116
+ env: delegationEnv({ CLAUDE_CONFIG_DIR: homeDir }),
117
+ });
118
+ }
119
+ export class ClaudeCliProvider {
120
+ ctx;
121
+ permissionMode;
122
+ baseUrl;
123
+ live;
124
+ model;
125
+ name = 'claude-local';
126
+ capabilities = NO_START_CAPABILITIES;
127
+ inheritsParentContext = false;
128
+ /**
129
+ * @param live - the live driver, or a resolver returning the current
130
+ * generation's driver per member (the settings toggle swaps generations;
131
+ * a resolver may return undefined to steer one member's round to exec
132
+ * while a retiring generation still hosts it).
133
+ * @param model - resolver for the member's effective model, read PER ROUND
134
+ * so a settings-card write or a composer switch takes effect on the next
135
+ * delegation without a reload. Receives the member (the child session id)
136
+ * and the round's delegation-layer model, and answers in the family's
137
+ * order — session-level override, delegation, settings. Absent (the
138
+ * resolver not passed), the delegation's own model still rides, exactly
139
+ * as before the key existed.
140
+ */
141
+ constructor(ctx, permissionMode = 'skip', baseUrl, live, model) {
142
+ this.ctx = ctx;
143
+ this.permissionMode = permissionMode;
144
+ this.baseUrl = baseUrl;
145
+ this.live = live;
146
+ this.model = model;
147
+ }
148
+ /** Resolve the live driver for one round's member, if live is on for it. */
149
+ liveDriver(childSessionId) {
150
+ const live = this.live;
151
+ if (live === undefined)
152
+ return undefined;
153
+ return typeof live === 'function' ? live(childSessionId) : live;
154
+ }
155
+ /** The round's model fragment for the exec spawn spec (see {@link resolveRoundModel}). */
156
+ roundModel(childSessionId, requestedModel) {
157
+ return resolveRoundModel(this.model === undefined ? requestedModel : this.model(childSessionId, requestedModel));
158
+ }
159
+ /** Per-round member-channel registration for the exec path (see {@link registerClaudeMemberRun}). */
160
+ memberRun(childSessionId, parentSessionId) {
161
+ return registerClaudeMemberRun(this.ctx, childSessionId, parentSessionId);
162
+ }
163
+ async start(request) {
164
+ // The family tool stages exactly one intent per delegation call; the
165
+ // provider consumes exactly one per start. A resume intent continues the
166
+ // recorded session inside the existing child session.
167
+ const intent = this.ctx.localAgent.takeDelegationIntent(request.parent.session.id, this.name);
168
+ // The scoped home this round runs against: the staged intent's scope
169
+ // names a sibling directory under the homes root, absent means the
170
+ // default one — the directory every round used before scopes existed.
171
+ // claude's keychain item is keyed by the config directory's path, so a
172
+ // named scope gets its own credential item for free.
173
+ const scope = intent?.scope;
174
+ const homeDir = this.ctx.localAgent.homeDir('claude-code', scope);
175
+ // The effective cwd: the caller's override (the staged intent's `cwd`,
176
+ // riding DelegationCallOptions.cwd) when present, else the parent
177
+ // session's workspace — the behavior before overrides existed.
178
+ const cwd = resolveChildCwd(request.parent.session.header.cwd, intent?.cwd);
179
+ if (cwd === undefined) {
180
+ throw new Error('subagent-claude: the parent session has no working directory to run the CLI in');
181
+ }
182
+ // Container exec target: the same round, spawned through `docker exec`
183
+ // inside a unit the caller acquired. Validated here so a target missing
184
+ // the in-container scoped home fails before any session record is made.
185
+ // On Linux claude READS its credentials from the default home and only
186
+ // WRITES the scoped dir (upstream #47661), so the caller normally points
187
+ // CLAUDE_CONFIG_DIR at the same in-container path it bind-mounted the
188
+ // host scoped home onto — one directory, read and write.
189
+ const exec = intent?.exec;
190
+ if (exec !== undefined)
191
+ containerScopedHome(exec, 'CLAUDE_CONFIG_DIR', 'subagent-claude');
192
+ if (intent !== undefined && intent.kind === 'resume') {
193
+ // A CLI session continues in the directory its first round ran in; a
194
+ // round resolving elsewhere is rejected before any process spawns.
195
+ const record = this.ctx.localAgent.getDelegation(intent.childSessionId);
196
+ assertResumeCwdUnchanged(record, cwd, 'subagent-claude');
197
+ // …and in the scoped home its first round ran in.
198
+ assertResumeScopeUnchanged(record, scope, 'subagent-claude');
199
+ // A resume re-requests the model the FIRST round recorded — the caller
200
+ // cannot name one (the facade refuses it), and a record without one is
201
+ // a delegation that named none, which this round repeats.
202
+ if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function')
203
+ return this.startClaudeResume(request, intent, cwd, homeDir, exec, scope, record?.model);
204
+ return this.ctx.localAgent.withMemberConfigurationRound({
205
+ childSessionId: intent.childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
206
+ ...scope === undefined ? {} : { scope },
207
+ ...record?.model === undefined ? {} : { model: record.model },
208
+ ...record?.effort === undefined ? {} : { effort: record.effort },
209
+ ...record?.configurationLock === undefined ? {} : { configurationLock: record.configurationLock },
210
+ }, configuration => this.startClaudeResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
211
+ }
212
+ const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
213
+ if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function') {
214
+ if (intent?.effort !== undefined)
215
+ throw new Error('Claude effort requires the configuration admission core');
216
+ return this.startClaudeFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
217
+ }
218
+ return this.ctx.localAgent.withMemberConfigurationRound({
219
+ childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
220
+ ...scope === undefined ? {} : { scope },
221
+ ...intent?.model === undefined ? {} : { model: intent.model },
222
+ ...intent?.effort === undefined ? {} : { effort: intent.effort },
223
+ ...intent?.configurationLock === undefined ? {} : { configurationLock: intent.configurationLock },
224
+ }, configuration => this.startClaudeFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId, configuration), request.signal, intent?.onAdmitted);
225
+ }
226
+ /** Fresh round: record the child session, spawn `claude -p`, append after settle. */
227
+ async startClaudeFresh(request, cwd, homeDir, exec, scope,
228
+ /** The model this DELEGATION requested, when the caller named one. */
229
+ requestedModel, runId, configuration) {
230
+ let childSession;
231
+ try {
232
+ const sessions = this.ctx.get('sessions');
233
+ if (sessions === undefined) {
234
+ throw new Error('the sessions service is not mounted');
235
+ }
236
+ childSession = sessions.get(runId) ?? sessions.create(runId, {
237
+ meta: {
238
+ cwd,
239
+ parentSession: request.parent.session.id,
240
+ origin: 'subagent',
241
+ delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1,
242
+ },
243
+ });
244
+ const harness = this.ctx.localAgent.get('claude-code');
245
+ const label = subagentDelegationLabel(harness?.displayName ?? 'Claude Code', request.descriptor.label);
246
+ childSession.append('subagent/descriptor', {
247
+ ...request.descriptor,
248
+ label,
249
+ });
250
+ // The official runtime appends the parent-side subagent/catalog
251
+ // discovery row only for in-process children (run.localAgent); this run
252
+ // is remote, so the provider appends it here — once per child, where
253
+ // the descriptor lands. A failure degrades to the warn below; a missing
254
+ // row never blocks the delegation.
255
+ establishSubagentCatalogChild(request.parent.session, childSession.header, label);
256
+ // No persistence create here: the stored session is materialized by the
257
+ // first persistChildSession sync through the CORE's cached write handle.
258
+ // A fire-and-forget create from the provider used to hold (and leak) a
259
+ // second write handle, which blocks the core's claim with
260
+ // SessionAlreadyOwnedError.
261
+ }
262
+ catch (error) {
263
+ this.ctx.logger.warn(`subagent-claude: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
264
+ }
265
+ // Resolve the effective endpoint once per run: the cordis Config field
266
+ // wins, else the host environment's ANTHROPIC_BASE_URL (a STARTUP-time
267
+ // snapshot — a long-running dsh process does not see later shell exports),
268
+ // else the CLI's own default. Log it (without the key) so a failing
269
+ // delegation reports which endpoint it actually used.
270
+ const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
271
+ this.ctx.logger.info(`subagent-claude: delegating via ${effectiveBaseUrl ?? 'claude default endpoint'}`);
272
+ // Live driver: the round goes to the member's resident stream-json
273
+ // process. A channel that fails at spawn falls through to the exec
274
+ // one-shot below — and stays there until the breaker cools down.
275
+ // A container target always takes the exec one-shot: the live driver runs
276
+ // a resident stream-json process on the HOST, which is the transport the
277
+ // target exists to replace.
278
+ const live = exec === undefined ? this.liveDriver(runId) : undefined;
279
+ if (live !== undefined && childSession !== undefined && !live.disabled) {
280
+ // The member-bound live driver receives this exact scoped home.
281
+ try {
282
+ return await live.startRound(request, {
283
+ cwd,
284
+ homeDir,
285
+ childSession,
286
+ ...configuration === undefined ? {} : { configuration: configuration.resolved },
287
+ parentSessionId: request.parent.session.id,
288
+ // A fresh delegation naming a model is NOT exec-only: the model
289
+ // binds as the member's start model at the runtime's spawn (a
290
+ // runtime already bound to a different model is retired first, and
291
+ // the same CLI session resumes on the respawn).
292
+ ...requestedModel === undefined ? {} : { model: requestedModel },
293
+ // The stream-json session id arrives with the turn's system/init
294
+ // (server-assigned), far earlier than the exec path's settle parse.
295
+ onSessionId: (sessionId) => {
296
+ this.ctx.localAgent.recordDelegation({
297
+ childSessionId: runId,
298
+ provider: this.name,
299
+ parentSessionId: request.parent.session.id,
300
+ cliSessionId: sessionId,
301
+ // The round's resolved working directory anchors the
302
+ // resume-consistency check.
303
+ cwd,
304
+ // …and its scoped home anchors the resume-scope check.
305
+ ...scope === undefined ? {} : { scope },
306
+ // The model this delegation asked for: every resume round reads
307
+ // it back from here instead of the caller restating it.
308
+ ...requestedModel === undefined ? {} : { model: requestedModel },
309
+ });
310
+ },
311
+ });
312
+ }
313
+ catch (error) {
314
+ if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
315
+ throw error;
316
+ this.ctx.logger.warn(`subagent-claude: live driver unavailable, using the exec one-shot: ${error.message}`);
317
+ }
318
+ }
319
+ // Member channel: register this run and carry the bridge declaration on
320
+ // the spawn argv, so the CLI session starts with member_message available.
321
+ // The member bridge is a host unix socket the container cannot reach, and
322
+ // its MCP declaration names a host node path — a containerized round
323
+ // therefore runs WITHOUT the member channel rather than with a broken one.
324
+ const member = exec === undefined ? this.memberRun(runId, request.parent.session.id) : undefined;
325
+ try {
326
+ const run = await startClaudeCliRun(request, {
327
+ cwd,
328
+ env: delegationEnv({
329
+ CLAUDE_CONFIG_DIR: homeDir,
330
+ ...this.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.baseUrl },
331
+ }),
332
+ ...exec === undefined ? {} : { exec },
333
+ endpointLabel: effectiveBaseUrl,
334
+ permissionMode: this.permissionMode,
335
+ ...(configuration?.resolved ?? this.roundModel(runId, requestedModel)),
336
+ ...configuration === undefined ? {} : { controlled: true },
337
+ disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
338
+ spawn: spec => this.ctx.subprocess.spawn(spec),
339
+ onError: (error, stopReason) => {
340
+ this.ctx.logger.warn(`subagent-claude: child run failed (${stopReason}) via ${effectiveBaseUrl ?? 'claude default endpoint'}: ${error instanceof Error ? error.message : String(error)}`);
341
+ },
342
+ onAuthFailure: (detail) => { this.ctx.localAgent.reportAuthFailure('claude-code', detail, scope); },
343
+ onAuthSuccess: () => { markCredentialVerified(this.ctx, scope); },
344
+ cliVersion: () => claudeCliVersion(this.ctx, homeDir),
345
+ ...member === undefined ? {} : { member: { mcpConfig: member.mcpConfig, allowedTool: member.allowedTool } },
346
+ childSession,
347
+ ctx: this.ctx,
348
+ // The first round records the claude session id so a later resume round
349
+ // can continue it.
350
+ onSessionId: (sessionId) => {
351
+ if (sessionId === undefined)
352
+ return;
353
+ this.ctx.localAgent.recordDelegation({
354
+ childSessionId: runId,
355
+ provider: this.name,
356
+ parentSessionId: request.parent.session.id,
357
+ cliSessionId: sessionId,
358
+ // The round's resolved working directory anchors the
359
+ // resume-consistency check.
360
+ cwd,
361
+ // …and its scoped home anchors the resume-scope check.
362
+ ...scope === undefined ? {} : { scope },
363
+ // The model this delegation asked for (see the live branch).
364
+ ...requestedModel === undefined ? {} : { model: requestedModel },
365
+ });
366
+ },
367
+ // Every settled round reports its observed model and usage through the
368
+ // registry's observation channel (record merge + `settled` event).
369
+ onRoundSettled: (round) => {
370
+ this.ctx.localAgent.recordRoundSettled(runId, round);
371
+ },
372
+ });
373
+ // The member-channel token dies with the run, whatever its stop reason.
374
+ if (member !== undefined)
375
+ void run.result.then(member.release, member.release);
376
+ return run;
377
+ }
378
+ catch (error) {
379
+ member?.release();
380
+ throw error;
381
+ }
382
+ }
383
+ /** Resume round: continue the recorded claude session inside the existing child session. */
384
+ async startClaudeResume(request, intent, cwd, homeDir, exec, scope,
385
+ /** The model the delegation's FIRST round recorded, re-requested here. */
386
+ requestedModel, configuration) {
387
+ // One in-flight resume per child session: a second resume of the same
388
+ // child fails loud instead of racing the first process. The lock releases
389
+ // on every settle path (the run.result handler below) and on the error
390
+ // path, so a deadlock never strands a later resume.
391
+ if (!this.ctx.localAgent.acquireResumeLock(intent.childSessionId)) {
392
+ throw new Error(`subagent-claude: 该子会话有进行中的委派,等其完成后再追问 (child session ${intent.childSessionId})`);
393
+ }
394
+ try {
395
+ const sessions = this.ctx.get('sessions');
396
+ const childSession = sessions?.get(SessionId(intent.childSessionId));
397
+ if (childSession === undefined) {
398
+ throw new Error(`subagent-claude: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
399
+ }
400
+ const nextTurn = childSession.snapshotEvents().filter(event => event.type === 'turn/start').length + 1;
401
+ // Live driver: continue the member's resident stream-json session.
402
+ // Channel failure falls through to the exec one-shot below.
403
+ // See the fresh path: a container target is exec-only.
404
+ const live = exec === undefined ? this.liveDriver(intent.childSessionId) : undefined;
405
+ if (live !== undefined && !live.disabled) {
406
+ // Resume retains the recorded scope and its native session.
407
+ try {
408
+ const liveRun = await live.startRound(request, {
409
+ cwd,
410
+ homeDir,
411
+ childSession,
412
+ ...configuration === undefined ? {} : { configuration: configuration.resolved },
413
+ parentSessionId: request.parent.session.id,
414
+ resume: { cliSessionId: intent.cliSessionId, turn: nextTurn },
415
+ // The delegation's recorded model re-requests through the spawn
416
+ // binding too (see the fresh path): a runtime bound to a
417
+ // different model retires, and the session resumes on the respawn.
418
+ ...requestedModel === undefined ? {} : { model: requestedModel },
419
+ });
420
+ void liveRun.result.then(() => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); }, () => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); });
421
+ return liveRun;
422
+ }
423
+ catch (error) {
424
+ if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
425
+ throw error;
426
+ this.ctx.logger.warn(`subagent-claude: live driver unavailable, using the exec one-shot: ${error.message}`);
427
+ }
428
+ }
429
+ const effectiveBaseUrl = this.baseUrl ?? process.env.ANTHROPIC_BASE_URL;
430
+ this.ctx.logger.info(`subagent-claude: resuming via ${effectiveBaseUrl ?? 'claude default endpoint'}`);
431
+ // Member channel: register the resume round (same child session, fresh
432
+ // per-run token) before the spawn.
433
+ // See the fresh path: no member channel across the container boundary.
434
+ const member = exec === undefined ? this.memberRun(intent.childSessionId, request.parent.session.id) : undefined;
435
+ let run;
436
+ try {
437
+ run = await startClaudeCliRun(request, {
438
+ cwd,
439
+ env: delegationEnv({
440
+ CLAUDE_CONFIG_DIR: homeDir,
441
+ ...this.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.baseUrl },
442
+ }),
443
+ ...exec === undefined ? {} : { exec },
444
+ endpointLabel: effectiveBaseUrl,
445
+ permissionMode: this.permissionMode,
446
+ ...(configuration?.resolved ?? this.roundModel(intent.childSessionId, requestedModel)),
447
+ ...configuration === undefined ? {} : { controlled: true },
448
+ disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
449
+ spawn: spec => this.ctx.subprocess.spawn(spec),
450
+ onError: (error, stopReason) => {
451
+ this.ctx.logger.warn(`subagent-claude: child run failed (${stopReason}) via ${effectiveBaseUrl ?? 'claude default endpoint'}: ${error instanceof Error ? error.message : String(error)}`);
452
+ },
453
+ onAuthFailure: (detail) => { this.ctx.localAgent.reportAuthFailure('claude-code', detail, scope); },
454
+ onAuthSuccess: () => { markCredentialVerified(this.ctx, scope); },
455
+ cliVersion: () => claudeCliVersion(this.ctx, homeDir),
456
+ ...member === undefined ? {} : { member: { mcpConfig: member.mcpConfig, allowedTool: member.allowedTool } },
457
+ childSession,
458
+ ctx: this.ctx,
459
+ resume: { cliSessionId: intent.cliSessionId, turn: nextTurn },
460
+ // Every settled round reports its observed model and usage through
461
+ // the registry's observation channel.
462
+ onRoundSettled: (round) => {
463
+ this.ctx.localAgent.recordRoundSettled(intent.childSessionId, round);
464
+ },
465
+ });
466
+ }
467
+ catch (error) {
468
+ member?.release();
469
+ throw error;
470
+ }
471
+ void run.result.then(() => {
472
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
473
+ member?.release();
474
+ }, () => {
475
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
476
+ member?.release();
477
+ });
478
+ return run;
479
+ }
480
+ catch (error) {
481
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
482
+ throw error;
483
+ }
484
+ }
485
+ }
486
+ function thrown(value) {
487
+ /* v8 ignore next -- typed subprocess failures reject with Error. */
488
+ return value instanceof Error ? value : new Error(String(value));
489
+ }
490
+ /**
491
+ * Validate and join the one-shot task before crossing the process boundary.
492
+ * @param prompt - task content accepted from the shared subagent service.
493
+ * @returns the joined non-empty text.
494
+ */
495
+ export function textTask(prompt) {
496
+ if (prompt.length === 0) {
497
+ throw new Error('subagent-claude: the one-shot task must contain only text blocks');
498
+ }
499
+ const texts = [];
500
+ for (const block of prompt) {
501
+ if (block.type !== 'text') {
502
+ throw new Error('subagent-claude: the one-shot task must contain only text blocks');
503
+ }
504
+ texts.push(block.text);
505
+ }
506
+ if (texts.every(text => text.trim().length === 0)) {
507
+ throw new Error('subagent-claude: the one-shot task must not be empty');
508
+ }
509
+ return texts.join('\n');
510
+ }
511
+ /**
512
+ * Fold a tool-call counter into the settled shape: `{count, byName}` with the
513
+ * per-name values summing to `count`. An empty map yields undefined — a
514
+ * stream that named no tool call reports nothing rather than a zero it did
515
+ * not observe.
516
+ * @param counts - the fold's per-name tally.
517
+ * @returns the settled accounting, or undefined when nothing was counted.
518
+ */
519
+ export function toolCallsOf(counts) {
520
+ if (counts.size === 0)
521
+ return undefined;
522
+ let count = 0;
523
+ const byName = {};
524
+ for (const [name, value] of counts) {
525
+ count += value;
526
+ byName[name] = value;
527
+ }
528
+ return { count, byName };
529
+ }
530
+ /**
531
+ * Translate one TodoWrite tool input into the dsh whole-list snapshot.
532
+ * Claude's documented schema is `{todos: [{content, status, activeForm}]}`
533
+ * with status `pending | in_progress | completed` — dsh's TodoItem vocabulary
534
+ * minus `activeForm` (display-only). Unknown statuses map to `pending`; a
535
+ * shape-skewed input returns undefined and the caller degrades to the plain
536
+ * text fold — shape skew must never throw into the mirror.
537
+ * @param input - the tool_use block's `input` payload.
538
+ * @returns the dsh todo list, or undefined on shape skew.
539
+ */
540
+ export function todosFromTodoWrite(input) {
541
+ if (typeof input !== 'object' || input === null)
542
+ return undefined;
543
+ const todos = input.todos;
544
+ if (!Array.isArray(todos))
545
+ return undefined;
546
+ const out = [];
547
+ for (const item of todos) {
548
+ if (typeof item !== 'object' || item === null)
549
+ return undefined;
550
+ const content = item.content;
551
+ if (typeof content !== 'string' || content.trim() === '')
552
+ return undefined;
553
+ const status = item.status;
554
+ out.push({ content, status: status === 'in_progress' || status === 'completed' ? status : 'pending' });
555
+ }
556
+ return out;
557
+ }
558
+ /**
559
+ * Append the todo/write snapshot unless the child session's last snapshot is
560
+ * identical — the M2 dsh mirror's JSON-comparison idempotency: repeated passes
561
+ * over the same state never duplicate it. The comparison base is the whole
562
+ * log, not the current round: a standing whole-list snapshot has no round
563
+ * scope, so a resume round with an unchanged list re-appends nothing and the
564
+ * earlier round's list keeps standing.
565
+ * @param childSession - the parent-side dsh subagent session.
566
+ * @param todos - the translated whole list.
567
+ * @returns whether a snapshot was appended.
568
+ */
569
+ function appendTodosIfChanged(childSession, todos) {
570
+ const last = childSession.snapshotEvents().filter(event => event.type === 'todo/write').at(-1);
571
+ if (last !== undefined && JSON.stringify(last.data) === JSON.stringify({ todos }))
572
+ return false;
573
+ // todo/write's append takes no surface options (log-only UI state).
574
+ childSession.append('todo/write', { todos });
575
+ return true;
576
+ }
577
+ /**
578
+ * The CLI version named by a `system` init event's `claude_code_version`.
579
+ * claude 2.1.x serializes its whole build-info module there
580
+ * (`{ VERSION, BUILD_TIME, GIT_SHA, … }`), so the version is a field of it; a
581
+ * future release that emits a bare string is accepted unchanged. Anything
582
+ * else yields undefined and the probe answers instead.
583
+ * @param value - the event's `claude_code_version` field.
584
+ * @returns the version string, or undefined.
585
+ */
586
+ export function claudeVersionFromInit(value) {
587
+ if (typeof value === 'string' && value !== '')
588
+ return value;
589
+ if (typeof value !== 'object' || value === null)
590
+ return undefined;
591
+ const version = value.VERSION;
592
+ return typeof version === 'string' && version !== '' ? version : undefined;
593
+ }
594
+ /**
595
+ * Fold one NDJSON line into the stream state. Shared by
596
+ * {@link parseClaudeStreamJson} (settle-time whole-stream parse) and
597
+ * {@link ClaudeStreamParser} (live incremental parse) so the two paths cannot
598
+ * drift.
599
+ */
600
+ function foldClaudeStreamLine(state, raw) {
601
+ const line = raw.trim();
602
+ if (line === '')
603
+ return;
604
+ let event;
605
+ try {
606
+ event = JSON.parse(line);
607
+ }
608
+ catch {
609
+ return;
610
+ }
611
+ if (event.type === 'system' && typeof event.session_id === 'string') {
612
+ state.sessionId = event.session_id;
613
+ if (typeof event.model === 'string' && event.model !== '')
614
+ state.model = event.model;
615
+ const version = claudeVersionFromInit(event.claude_code_version);
616
+ if (version !== undefined)
617
+ state.cliVersion = version;
618
+ return;
619
+ }
620
+ if (event.type === 'result') {
621
+ state.completed = true;
622
+ if (event.is_error === true) {
623
+ // The detail text arrives in `result` on real auth failures (e.g. the
624
+ // 401's "Failed to authenticate. API Error: ..."), in `error` on others.
625
+ state.error = typeof event.result === 'string' ? event.result
626
+ : typeof event.error === 'string' ? event.error
627
+ : 'claude -p reported an error';
628
+ }
629
+ if (typeof event.session_id === 'string')
630
+ state.sessionId = event.session_id;
631
+ if (typeof event.model === 'string' && event.model !== '')
632
+ state.model = event.model;
633
+ if (event.usage !== undefined)
634
+ state.usage = usageFromClaude(event.usage);
635
+ return;
636
+ }
637
+ if (event.type !== 'assistant' && event.type !== 'user')
638
+ return;
639
+ const blocks = event.message?.content ?? [];
640
+ for (const [blockIndex, block] of blocks.entries()) {
641
+ if (typeof block !== 'object' || block === null)
642
+ continue;
643
+ const record = block;
644
+ const identity = event.type === 'assistant' && typeof event.message?.id === 'string'
645
+ ? { streamId: `${event.message.id}:${blockIndex}` } : {};
646
+ const kind = record['type'];
647
+ if (kind === 'text' && typeof record['text'] === 'string' && record['text'].trim() !== '') {
648
+ state.lines.push({ kind: 'text', text: record['text'], ...identity });
649
+ // The final assistant text block is the run output.
650
+ if (event.type === 'assistant')
651
+ state.text = record['text'];
652
+ }
653
+ else if (kind === 'thinking' && typeof record['thinking'] === 'string' && record['thinking'].trim() !== '') {
654
+ state.lines.push({ kind: 'think', text: record['thinking'], ...identity });
655
+ }
656
+ else if (kind === 'redacted_thinking') {
657
+ // The model reasoned, but the content is opaque by design (encrypted on
658
+ // the server): a visible placeholder line says so instead of dropping
659
+ // the block and misreporting the round as reasoning-free.
660
+ state.lines.push({ kind: 'think', text: REDACTED_THINKING_TEXT, ...identity });
661
+ }
662
+ else if (kind === 'tool_use' || kind === 'server_tool_use') {
663
+ const name = typeof record['name'] === 'string' ? record['name'] : 'tool';
664
+ // Counted here, ahead of the TodoWrite intercept below: that intercept
665
+ // diverts the block out of the transcript, but the CLI still called the
666
+ // tool, and an accounting that skipped it would under-report the round.
667
+ state.toolCalls.set(name, (state.toolCalls.get(name) ?? 0) + 1);
668
+ if (name === 'TodoWrite') {
669
+ const todos = todosFromTodoWrite(record['input']);
670
+ if (todos !== undefined) {
671
+ state.todos = todos;
672
+ continue; // intercepted before the text fold
673
+ }
674
+ // Shape skew: degrade to the plain text fold; the mirror paths warn.
675
+ state.todoSkew = true;
676
+ }
677
+ const id = typeof record['id'] === 'string' ? record['id'] : undefined;
678
+ if (id !== undefined)
679
+ state.callsById.set(id, state.lines.length);
680
+ const edit = kind === 'tool_use' ? editToolDetail(name, record['input']) : undefined;
681
+ if (edit !== undefined) {
682
+ // The apply-patch-style card (codex's fileChange idiom): the path as
683
+ // the args, the actual edit — old/new strings or the file body — as
684
+ // the card body. Without this an implementation round's edits were
685
+ // reduced to a bare path.
686
+ state.lines.push({
687
+ kind: 'tool',
688
+ id: id ?? `claude-tool-${state.lines.length}`,
689
+ name: 'ApplyPatch',
690
+ args: edit.args,
691
+ ...edit.patch === undefined ? {} : { result: edit.patch },
692
+ });
693
+ }
694
+ else {
695
+ const detail = inputDetail(record['input']);
696
+ state.lines.push({
697
+ kind: 'tool',
698
+ id: id ?? `claude-tool-${state.lines.length}`,
699
+ // The server-side tools surface under the local tools' card names
700
+ // (web_search is the same activity the local WebSearch tool runs).
701
+ name: kind === 'server_tool_use' ? SERVER_TOOL_CARD_NAMES[name] ?? name : name,
702
+ ...detail === undefined ? {} : { args: detail },
703
+ });
704
+ }
705
+ }
706
+ else if (kind === 'tool_result') {
707
+ const resultText = toolResultText(record['content']);
708
+ if (resultText !== undefined && resultText.trim() !== '') {
709
+ attachToolResult(state, record['tool_use_id'], resultText, record['is_error'] === true);
710
+ }
711
+ }
712
+ else if (kind === 'web_search_tool_result' || kind === 'web_fetch_tool_result') {
713
+ // The result half of a server_tool_use: pairs to its call by id like a
714
+ // local tool_result, so server-side web activity stays visible.
715
+ const resultText = serverToolResultText(record);
716
+ if (resultText !== undefined && resultText.trim() !== '') {
717
+ attachToolResult(state, record['tool_use_id'], resultText, false);
718
+ }
719
+ }
720
+ }
721
+ }
722
+ /**
723
+ * Merge a tool result into its call line: pair by tool_use_id when the stream
724
+ * carries it, else the most recent tool line so an id-less stream still lands
725
+ * the result on its call. A line already carrying a body (an edit tool's
726
+ * patch) keeps it — the status text adds nothing to a successful edit — but
727
+ * an error result always appends, since a failed edit's message is the point.
728
+ */
729
+ function attachToolResult(state, useId, resultText, isError) {
730
+ let target = typeof useId !== 'string' ? undefined : state.callsById.get(useId);
731
+ if (target === undefined) {
732
+ for (let index = state.lines.length - 1; index >= 0; index -= 1) {
733
+ if (state.lines[index]?.kind === 'tool') {
734
+ target = index;
735
+ break;
736
+ }
737
+ }
738
+ }
739
+ if (target === undefined)
740
+ return;
741
+ const last = state.lines[target];
742
+ if (last === undefined || last.kind !== 'tool')
743
+ return;
744
+ if (last.result === undefined) {
745
+ state.lines[target] = { ...last, result: resultText };
746
+ }
747
+ else if (isError) {
748
+ state.lines[target] = { ...last, result: `${last.result}\n${resultText}` };
749
+ }
750
+ }
751
+ /**
752
+ * Incremental `claude --output-format stream-json` parser: feed stdout chunks
753
+ * as they arrive and the folded transcript accumulates line by line. The last
754
+ * line is volatile until the terminal `result` event — a `tool_result` merges
755
+ * into a pending tool line — so live consumers hold it back (see the run's
756
+ * live mirror).
757
+ */
758
+ export class ClaudeStreamParser {
759
+ buffer = '';
760
+ lines = [];
761
+ callsById = new Map();
762
+ text;
763
+ usage;
764
+ sessionId;
765
+ model;
766
+ cliVersion;
767
+ error;
768
+ completed = false;
769
+ todos;
770
+ todoSkew = false;
771
+ toolCalls = new Map();
772
+ /** Fold every complete NDJSON line in the chunk; the tail stays buffered. */
773
+ push(chunk) {
774
+ this.buffer += chunk;
775
+ const parts = this.buffer.split('\n');
776
+ this.buffer = parts.pop() ?? '';
777
+ for (const raw of parts)
778
+ foldClaudeStreamLine(this, raw);
779
+ }
780
+ }
781
+ /**
782
+ * Parse a `claude -p --verbose --output-format stream-json` NDJSON stream.
783
+ * Each line is one event: `system` init (carries the session id and the
784
+ * model), `assistant` (content blocks: `text`, `tool_use`, `thinking`), `user`
785
+ * (a `tool_result` block), and a terminal `result` (final usage, error flag,
786
+ * session id). Content blocks fold into an ordered transcript (thinking,
787
+ * reply text, tool calls with results), the last assistant `text` becomes the
788
+ * run output, and the `result` event's usage/session id ride the parse. The
789
+ * model identifier comes from the init (or result) event's `model` field. A
790
+ * malformed stream or an `is_error` result yields the error instead.
791
+ * @param output - the collected stdout NDJSON text.
792
+ * @returns the ordered transcript, final answer text, usage, session id, the
793
+ * observed model, and the error when the run reported one.
794
+ */
795
+ export function parseClaudeStreamJson(output) {
796
+ const state = {
797
+ lines: [],
798
+ callsById: new Map(),
799
+ toolCalls: new Map(),
800
+ text: undefined,
801
+ usage: undefined,
802
+ sessionId: undefined,
803
+ model: undefined,
804
+ cliVersion: undefined,
805
+ error: undefined,
806
+ completed: false,
807
+ todos: undefined,
808
+ todoSkew: false,
809
+ };
810
+ for (const raw of output.split('\n'))
811
+ foldClaudeStreamLine(state, raw);
812
+ const toolCalls = toolCallsOf(state.toolCalls);
813
+ return {
814
+ lines: state.lines,
815
+ ...state.text === undefined ? {} : { text: state.text },
816
+ ...state.usage === undefined ? {} : { usage: state.usage },
817
+ ...state.error === undefined ? {} : { error: state.error },
818
+ ...state.sessionId === undefined ? {} : { sessionId: state.sessionId },
819
+ ...state.cliVersion === undefined ? {} : { cliVersion: state.cliVersion },
820
+ ...state.model === undefined ? {} : { model: state.model },
821
+ ...state.todos === undefined ? {} : { todos: state.todos },
822
+ ...state.todoSkew === false ? {} : { todoSkew: true },
823
+ ...toolCalls === undefined ? {} : { toolCalls },
824
+ };
825
+ }
826
+ /** The placeholder think line for a `redacted_thinking` block (content is server-encrypted by design). */
827
+ export const REDACTED_THINKING_TEXT = '(思考内容已由服务端隐藏:redacted_thinking,内容按设计不可见,此行仅标示它存在)';
828
+ /** Server-side tool_use names map to the card names of their local-tool twins. */
829
+ const SERVER_TOOL_CARD_NAMES = {
830
+ web_search: 'WebSearch',
831
+ web_fetch: 'WebFetch',
832
+ };
833
+ /** Render a tool_use input payload as a compact command/query line. */
834
+ function inputDetail(input) {
835
+ if (input === undefined)
836
+ return undefined;
837
+ if (typeof input === 'string')
838
+ return input.trim() === '' ? undefined : input.trim();
839
+ if (typeof input !== 'object' || input === null || Array.isArray(input))
840
+ return undefined;
841
+ const record = input;
842
+ for (const key of ['command', 'query', 'url', 'path']) {
843
+ const value = record[key];
844
+ if (typeof value === 'string' && value.trim() !== '')
845
+ return value.trim();
846
+ }
847
+ // No single scalar names the call: a compact JSON summary beats dropping
848
+ // the input entirely (the pre-fallback behavior hid e.g. a Task prompt).
849
+ const json = JSON.stringify(record);
850
+ if (json === '{}')
851
+ return undefined;
852
+ return json.length <= 160 ? json : `${json.slice(0, 160)}…`;
853
+ }
854
+ /**
855
+ * The actual edit behind an Edit/Write/MultiEdit/NotebookEdit tool_use, for
856
+ * the apply-patch-style card (codex's fileChange idiom: `kind: path` args,
857
+ * the patch as the body). Edit and MultiEdit synthesize `-`-/`+`-prefixed
858
+ * old/new lines; Write and NotebookEdit carry the whole new body verbatim.
859
+ * Undefined for any other tool or a shape-skewed input (the caller then
860
+ * keeps the one-scalar summary).
861
+ * @param name - the tool_use block's `name`.
862
+ * @param input - the tool_use block's `input` payload.
863
+ * @returns the card args and patch body, or undefined.
864
+ */
865
+ function editToolDetail(name, input) {
866
+ if (typeof input !== 'object' || input === null || Array.isArray(input))
867
+ return undefined;
868
+ const record = input;
869
+ const pathOf = (key) => {
870
+ const value = record[key];
871
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
872
+ };
873
+ const hunk = (oldString, newString) => {
874
+ if (typeof oldString !== 'string' || typeof newString !== 'string')
875
+ return undefined;
876
+ return [
877
+ ...oldString.split('\n').map(line => `- ${line}`),
878
+ ...newString.split('\n').map(line => `+ ${line}`),
879
+ ].join('\n');
880
+ };
881
+ if (name === 'Edit') {
882
+ const path = pathOf('file_path');
883
+ if (path === undefined)
884
+ return undefined;
885
+ const patch = hunk(record['old_string'], record['new_string']);
886
+ return { args: `update: ${path}`, ...patch === undefined ? {} : { patch } };
887
+ }
888
+ if (name === 'MultiEdit') {
889
+ const path = pathOf('file_path');
890
+ if (path === undefined)
891
+ return undefined;
892
+ const edits = Array.isArray(record['edits']) ? record['edits'] : [];
893
+ const patch = edits
894
+ .map(edit => hunk(edit['old_string'], edit['new_string']))
895
+ .filter((entry) => entry !== undefined)
896
+ .join('\n\n');
897
+ return { args: `update: ${path}`, ...patch === '' ? {} : { patch } };
898
+ }
899
+ if (name === 'Write') {
900
+ const path = pathOf('file_path');
901
+ if (path === undefined)
902
+ return undefined;
903
+ const content = record['content'];
904
+ return {
905
+ args: `update: ${path}`,
906
+ ...typeof content === 'string' && content !== '' ? { patch: content } : {},
907
+ };
908
+ }
909
+ if (name === 'NotebookEdit') {
910
+ const path = pathOf('notebook_path');
911
+ if (path === undefined)
912
+ return undefined;
913
+ const source = record['new_source'];
914
+ return {
915
+ args: `update: ${path}`,
916
+ ...typeof source === 'string' && source !== '' ? { patch: source } : {},
917
+ };
918
+ }
919
+ return undefined;
920
+ }
921
+ /** Cap for a fetched page's text on a WebFetch card — the log stays a log. */
922
+ const WEB_FETCH_TEXT_LIMIT = 4_000;
923
+ /**
924
+ * Flatten a server-side web tool result block to card text. A search folds
925
+ * its hits as `title — url` lines (an error block as `error: <code>`); a
926
+ * fetch folds the page text capped at {@link WEB_FETCH_TEXT_LIMIT}. Undefined
927
+ * when the block carries nothing readable — the pending call line then simply
928
+ * stays result-less, never dropped.
929
+ * @param record - the `web_search_tool_result` / `web_fetch_tool_result` block.
930
+ * @returns the card text, or undefined.
931
+ */
932
+ function serverToolResultText(record) {
933
+ const content = record['content'];
934
+ if (Array.isArray(content)) {
935
+ // web_search_tool_result: a hit list, or one error block.
936
+ const lines = content.map((item) => {
937
+ if (typeof item !== 'object' || item === null)
938
+ return undefined;
939
+ const entry = item;
940
+ if (entry['type'] === 'web_search_tool_result_error') {
941
+ return `error: ${typeof entry['error_code'] === 'string' ? entry['error_code'] : 'unknown'}`;
942
+ }
943
+ if (entry['type'] === 'web_search_result') {
944
+ const title = typeof entry['title'] === 'string' ? entry['title'] : '';
945
+ const url = typeof entry['url'] === 'string' ? entry['url'] : '';
946
+ if (title === '' && url === '')
947
+ return undefined;
948
+ return title === '' ? url : `${title} — ${url}`;
949
+ }
950
+ return undefined;
951
+ }).filter((line) => line !== undefined);
952
+ return lines.length === 0 ? undefined : lines.join('\n');
953
+ }
954
+ if (typeof content === 'object' && content !== null) {
955
+ // web_fetch_tool_result: one result object, or one error object.
956
+ const entry = content;
957
+ if (entry['type'] === 'web_fetch_tool_result_error') {
958
+ return `error: ${typeof entry['error_code'] === 'string' ? entry['error_code'] : 'unknown'}`;
959
+ }
960
+ const url = typeof entry['url'] === 'string' ? entry['url'] : undefined;
961
+ const body = typeof entry['content'] === 'string' ? entry['content'] : undefined;
962
+ const text = body === undefined
963
+ ? undefined
964
+ : body.length <= WEB_FETCH_TEXT_LIMIT ? body : `${body.slice(0, WEB_FETCH_TEXT_LIMIT)}\n…`;
965
+ return [url, text].filter((part) => part !== undefined && part !== '').join('\n') || undefined;
966
+ }
967
+ return undefined;
968
+ }
969
+ /** Flatten a tool_result content payload (string or block array) to text. */
970
+ function toolResultText(content) {
971
+ if (typeof content === 'string')
972
+ return content;
973
+ if (!Array.isArray(content))
974
+ return undefined;
975
+ return content
976
+ .filter((block) => typeof block === 'object' && block !== null && block.type === 'text'
977
+ && typeof block.text === 'string')
978
+ .map(block => block.text ?? '')
979
+ .join('\n');
980
+ }
981
+ /**
982
+ * Map claude's usage payload onto the shared usage contract. Claude's
983
+ * `input_tokens` is the uncached input; cache reads and cache creation are
984
+ * reported separately, so each maps to its own bucket with no subtraction.
985
+ * @param usage - the raw claude usage object.
986
+ * @returns the shared usage record.
987
+ */
988
+ function usageFromClaude(usage) {
989
+ const raw = usage;
990
+ if (raw === undefined || typeof raw !== 'object')
991
+ return { inputTokens: 0, outputTokens: 0 };
992
+ const num = (value) => {
993
+ const n = Number(value);
994
+ return Number.isFinite(n) ? n : undefined;
995
+ };
996
+ const input = num(raw.input_tokens);
997
+ const output = num(raw.output_tokens);
998
+ const cacheRead = num(raw.cache_read_input_tokens);
999
+ const cacheWrite = num(raw.cache_creation_input_tokens);
1000
+ const usageRecord = {
1001
+ inputTokens: input ?? 0,
1002
+ outputTokens: output ?? 0,
1003
+ };
1004
+ if (cacheRead !== undefined && cacheRead > 0)
1005
+ usageRecord.cacheReadTokens = cacheRead;
1006
+ if (cacheWrite !== undefined && cacheWrite > 0)
1007
+ usageRecord.cacheWriteTokens = cacheWrite;
1008
+ return usageRecord;
1009
+ }
1010
+ /**
1011
+ * Start the real `claude -p` child and publish its run. The claude
1012
+ * result arrives as an NDJSON event stream on stdout (`--verbose
1013
+ * --output-format stream-json`): assistant content blocks (text, tool_use,
1014
+ * thinking), user tool_result blocks, and a terminal result event carrying
1015
+ * usage and session id. stderr is piped for diagnostics and never folded
1016
+ * into the run output. A resume round spawns `claude -p --resume
1017
+ * <session_id>` instead, continuing the same session. The run id is the
1018
+ * child session id for a session-backed run.
1019
+ * @param request - resolved shared subagent request.
1020
+ * @param spec - workspace, environment, process service, and diagnostic policy.
1021
+ * @returns the published run after the child starts.
1022
+ */
1023
+ export async function startClaudeCliRun(request, spec) {
1024
+ const task = textTask(request.prompt);
1025
+ if (request.signal.aborted) {
1026
+ throw new Error('subagent-claude: request was aborted before the CLI started');
1027
+ }
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
+ const turn = spec.resume?.turn ?? 1;
1043
+ // The member bridge flags ride every argv variant (skip and normal
1044
+ // permission modes alike): --allowedTools is redundant under
1045
+ // --dangerously-skip-permissions but keeps the injection uniform.
1046
+ const memberArgv = spec.member === undefined
1047
+ ? []
1048
+ // `--allowedTools` is variadic and greedily consumes following argv
1049
+ // entries — without the `--` separator it swallows the task itself and
1050
+ // the CLI exits 1 with "Input must be provided … as a prompt argument".
1051
+ : ['--mcp-config', spec.member.mcpConfig, '--allowedTools', spec.member.allowedTool, '--'];
1052
+ // The configured model rides `--model`, which claude accepts on every one of
1053
+ // the four argv variants below. It goes BEFORE the member flags because
1054
+ // `--allowedTools` is variadic and its `--` terminator closes the flag
1055
+ // section. Nothing configured appends nothing: each variant is then
1056
+ // byte-for-byte the shape that shipped before the key existed.
1057
+ const modelArgv = spec.model === undefined ? [] : ['--model', spec.model];
1058
+ // --verbose is required by the CLI when --print and stream-json combine.
1059
+ const argv = spec.resume === undefined
1060
+ ? spec.permissionMode === 'skip'
1061
+ ? ['claude', '-p', '--dangerously-skip-permissions', '--verbose', ...modelArgv, '--output-format', 'stream-json', ...memberArgv, task]
1062
+ : ['claude', '-p', '--verbose', ...modelArgv, '--output-format', 'stream-json', ...memberArgv, task]
1063
+ : spec.permissionMode === 'skip'
1064
+ ? ['claude', '-p', '--dangerously-skip-permissions', '--verbose', ...modelArgv, '--resume', spec.resume.cliSessionId, '--output-format', 'stream-json', ...memberArgv, task]
1065
+ : ['claude', '-p', '--verbose', ...modelArgv, '--resume', spec.resume.cliSessionId, '--output-format', 'stream-json', ...memberArgv, task];
1066
+ // Container target: the same argv, wrapped in `docker exec`. The host cwd
1067
+ // still applies — it is the docker CLIENT's working directory now, while
1068
+ // the CLI's own is the target's in-container workdir.
1069
+ const env = spec.controlled ? { ...spec.env, CLAUDE_CODE_EFFORT_LEVEL: spec.effort ?? 'auto' } : spec.env;
1070
+ const launch = spec.exec === undefined
1071
+ ? { argv, env }
1072
+ : containerExecSpawn(spec.exec, { argv, env }, 'subagent-claude');
1073
+ const child = spec.spawn({
1074
+ argv: launch.argv,
1075
+ cwd: spec.cwd,
1076
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
1077
+ graceMs: spec.disposeGraceMs,
1078
+ env: launch.env,
1079
+ });
1080
+ // The turn opens at the real spawn moment so the timing projection
1081
+ // measures actual CLI runtime, not the post-hoc append time.
1082
+ spec.childSession?.append('turn/start', { turn });
1083
+ // Live mirror: fold the stream-json as chunks arrive so the child session
1084
+ // shows the run's progress before settle; the settle mirror below resumes
1085
+ // from the live counters and stays a no-op when live mirroring kept up.
1086
+ const liveMirror = spec.childSession !== undefined && spec.ctx !== undefined
1087
+ ? createClaudeLiveMirror(spec, task, turn)
1088
+ : undefined;
1089
+ let output = '';
1090
+ child.stdout?.on('data', (chunk) => {
1091
+ const text = chunk.toString();
1092
+ output += text;
1093
+ liveMirror?.push(text);
1094
+ });
1095
+ let stderr = '';
1096
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
1097
+ const disposeProcess = async () => {
1098
+ // Host 0.1.5 hides the child pid (the managed range owns termination):
1099
+ // terminate() is an idempotent no-op once the range is gone, so a failed
1100
+ // spawn needs no guard; done rejects there, which dispose must swallow.
1101
+ child.terminate();
1102
+ await child.waitForExit().catch(() => false);
1103
+ await child.done.catch(() => { });
1104
+ };
1105
+ const runAbort = new AbortController();
1106
+ const requestCancel = () => {
1107
+ if (runAbort.signal.aborted)
1108
+ return;
1109
+ runAbort.abort(new Error('subagent-claude: run cancelled locally'));
1110
+ };
1111
+ const onAbort = () => { requestCancel(); };
1112
+ request.signal.addEventListener('abort', onAbort, { once: true });
1113
+ // Abort branch: the moment the run is cancelled locally, the attempt settles
1114
+ // immediately — settleRunResult observes `cancelled()` and resolves the
1115
+ // result with 'aborted' WITHOUT waiting for the child to exit. The actual
1116
+ // process kill is dispose's job (SIGTERM → grace → SIGKILL teardown ladder),
1117
+ // per the subprocessRunHandle contract: requestCancel settles the result,
1118
+ // teardown reaps the process. Without this branch the result would only
1119
+ // settle after the child exits, and dispose (which runs after the result)
1120
+ // would never fire — a dead wait on a long-running CLI.
1121
+ const abortBranch = new Promise((_, reject) => {
1122
+ runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-claude: run cancelled locally')), { once: true });
1123
+ });
1124
+ const processFailure = child.done.then(outcome => Promise.reject(new Error('subagent-claude: CLI exited before the run settled '
1125
+ + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
1126
+ // A normal post-result dispose also closes the process; keep the expected
1127
+ // late rejection observed after the result race has already settled.
1128
+ processFailure.catch(() => { });
1129
+ const collectOutput = () => {
1130
+ // Parse fresh at call time: stdout 'data' events may still be flushing
1131
+ // when the settle callback computes its first output, and the consumer
1132
+ // may poll output again later. Post-exit the seam's collected buffer is
1133
+ // authoritative — a fast-exiting process can settle `done` before the
1134
+ // streamed data events land.
1135
+ const drained = child.collected.stdout?.readFrom(0).text;
1136
+ const text = parseClaudeStreamJson(drained !== undefined && drained !== '' ? drained : output).text?.trim();
1137
+ return text === undefined || text === '' ? [] : [{ type: 'text', text }];
1138
+ };
1139
+ let exitCode = null;
1140
+ const result = settleRunResult({
1141
+ attempt: () => Promise.race([
1142
+ child.done.then((outcome) => {
1143
+ if (outcome.exitCode !== 0) {
1144
+ const via = spec.endpointLabel ?? 'claude default endpoint';
1145
+ exitCode = outcome.exitCode;
1146
+ throw new Error(`subagent-claude: claude -p exited with code ${String(outcome.exitCode)} via ${via}`);
1147
+ }
1148
+ // A zero exit with no parsed answer is a silent failure, not a
1149
+ // success: the stream-json yielded no reply text. Throwing here
1150
+ // settles 'error' through the seam instead of reporting an empty
1151
+ // 'completed' (which the upstream settleRunResult does not re-check).
1152
+ // The abort path is untouched: it settles 'aborted' through
1153
+ // abortBranch before this branch is ever reached.
1154
+ const output = collectOutput();
1155
+ if (output.length === 0) {
1156
+ throw new Error('subagent-claude: claude -p exited 0 but produced no answer');
1157
+ }
1158
+ return { output, stopReason: 'completed' };
1159
+ }),
1160
+ processFailure,
1161
+ abortBranch,
1162
+ ]),
1163
+ collectOutput,
1164
+ cancelled: () => runAbort.signal.aborted,
1165
+ onError: spec.onError,
1166
+ signal: request.signal,
1167
+ onAbort,
1168
+ }).then((settled) => {
1169
+ // A completed round proves the scoped credential is live — the endpoint
1170
+ // answered. Reported here rather than from the mirror so it lands whether
1171
+ // or not the round is session-backed.
1172
+ if (settled.stopReason === 'completed')
1173
+ 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
+ return settled;
1200
+ });
1201
+ // After the child EXITS — however it ended (completed, killed by dispose, or
1202
+ // crashed) — mirror whatever the stream-json already produced into the dsh
1203
+ // subagent session, so a cancelled round still preserves its partial work
1204
+ // (thinking, tool calls with results, replies) and real token usage. The
1205
+ // session id is recorded even on abort so the partial session stays
1206
+ // resumable. Waits for the settle chain first (so turn/end is already
1207
+ // appended) AND for the process to actually exit (so stdout is drained).
1208
+ void result.then(() => child.done).then(() => {
1209
+ // Auth detection runs post-exit: streams are drained by then, so a fast
1210
+ // failure's output is complete (a settle-time read could race the flush).
1211
+ 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
+ const drainedStderr = child.collected.stderr?.readFrom(0).text || stderr;
1215
+ const drainedStdout = child.collected.stdout?.readFrom(0).text || output;
1216
+ const authDetail = `${parseClaudeStreamJson(drainedStdout).error ?? ''}\n${drainedStderr}`;
1217
+ if (CLAUDE_AUTH_FAILURE.test(authDetail)) {
1218
+ spec.onAuthFailure(authDetail.split('\n').find(line => line.trim() !== '') ?? 'auth failure');
1219
+ }
1220
+ }
1221
+ return mirrorClaudeAfterExit(spec, task, turn, output, liveMirror);
1222
+ }, () => { });
1223
+ return Promise.resolve(subprocessRunHandle({
1224
+ // A session-backed run's id is the child session id (the seam's local-run
1225
+ // contract), so the tool's resume self-description names the same handle
1226
+ // the delegation registry records.
1227
+ id: spec.childSession?.id ?? SessionId(randomUUID()),
1228
+ result,
1229
+ signal: request.signal,
1230
+ onAbort,
1231
+ requestCancel,
1232
+ teardown: disposeProcess,
1233
+ }));
1234
+ }
1235
+ /** One assistant-role message event, attributed to the claude route. */
1236
+ export function assistantEvent(blocks) {
1237
+ return createAssistantMessage({
1238
+ content: blocks,
1239
+ source: { provider: 'claude-local', model: 'claude' },
1240
+ });
1241
+ }
1242
+ /**
1243
+ * Fold one transcript line into the child session as one assistant step,
1244
+ * wrapped in the step/start–step/end boundary pair the live conversation
1245
+ * assembler requires: without a boundary the step never enters the location
1246
+ * index, the line's assistant message resolves to a turn-level location, and
1247
+ * the real-time view renders nothing (only a full rebuild, which mints step
1248
+ * drafts from the explicit coordinates, recovers it). Tool cards escape that
1249
+ * fate only because their definition does not consult the step location.
1250
+ */
1251
+ export function appendClaudeTranscriptLine(childSession, turn, step, line, usage) {
1252
+ childSession.append('step/start', { turn, step });
1253
+ if (line.kind === 'tool') {
1254
+ // Native tool card: the call event now, the result event when the stream
1255
+ // already carries it. Tool lines never carry the round's usage — the
1256
+ // callers attach usage to the round's last non-tool line.
1257
+ const call = childSession.append('tool/call', {
1258
+ turn,
1259
+ step,
1260
+ callId: line.id,
1261
+ name: line.name,
1262
+ arguments: line.args ?? '',
1263
+ });
1264
+ if (line.result !== undefined) {
1265
+ childSession.append('tool/result', {
1266
+ turn,
1267
+ step,
1268
+ message: createToolResultMessage({
1269
+ callId: line.id,
1270
+ content: [{ type: 'text', text: line.result }],
1271
+ isError: false,
1272
+ }),
1273
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
1274
+ }
1275
+ childSession.append('step/end', { turn, step });
1276
+ return;
1277
+ }
1278
+ const blocks = line.kind === 'think'
1279
+ ? [{ type: 'reasoning', text: line.text }]
1280
+ : [{ type: 'text', text: line.text }];
1281
+ childSession.append('assistant/message', {
1282
+ turn,
1283
+ step,
1284
+ message: assistantEvent(blocks),
1285
+ stream: [],
1286
+ ...usage === undefined ? {} : { usage },
1287
+ }, { surfaceOp: 'append' });
1288
+ childSession.append('step/end', { turn, step });
1289
+ }
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
+ /** The delta-progress text for one transcript line. */
1299
+ export function claudeLineText(line) {
1300
+ return line.kind === 'tool'
1301
+ ? `[工具 ${line.name}]${line.args !== undefined ? ` ${line.args}` : ''}${line.result !== undefined ? ` → ${line.result}` : ''}`
1302
+ : line.text;
1303
+ }
1304
+ /** Create the per-run live mirror over the incremental parser. */
1305
+ function createClaudeLiveMirror(spec, task, turn) {
1306
+ const parser = new ClaudeStreamParser();
1307
+ const childSession = spec.childSession;
1308
+ const ctx = spec.ctx;
1309
+ let mirrored = 0;
1310
+ let userMirrored = false;
1311
+ let lastTodos;
1312
+ let todoSkewWarned = false;
1313
+ let queue = Promise.resolve();
1314
+ const mirror = {
1315
+ get mirroredLines() { return mirrored; },
1316
+ get userMirrored() { return userMirrored; },
1317
+ enqueue(task) {
1318
+ queue = queue.then(task);
1319
+ return queue;
1320
+ },
1321
+ push(chunk) {
1322
+ parser.push(chunk);
1323
+ // TodoWrite translations cross immediately — they are not text lines, so
1324
+ // the mirrored/upto accounting does not see them. Reference-change is the
1325
+ // per-push trigger; content identity vs the child log is
1326
+ // appendTodosIfChanged's job (repeat passes over the same state do not
1327
+ // duplicate the snapshot).
1328
+ if (parser.todos !== undefined && parser.todos !== lastTodos) {
1329
+ lastTodos = parser.todos;
1330
+ appendTodosIfChanged(childSession, parser.todos);
1331
+ }
1332
+ if (parser.todoSkew && !todoSkewWarned) {
1333
+ todoSkewWarned = true;
1334
+ ctx.logger.warn('subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line');
1335
+ }
1336
+ const upto = parser.completed ? parser.lines.length : parser.lines.length - 1;
1337
+ if (upto <= mirrored)
1338
+ return;
1339
+ void mirror.enqueue(async () => {
1340
+ try {
1341
+ const localAgent = ctx.get('localAgent');
1342
+ if (!userMirrored) {
1343
+ userMirrored = true;
1344
+ childSession.append('user/message', createUserMessage({
1345
+ content: [{ type: 'text', text: task }],
1346
+ source: { kind: 'user' },
1347
+ }), { surfaceOp: 'append' });
1348
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1349
+ }
1350
+ // The usage rides the last NON-tool line (tool events carry no
1351
+ // usage slot); a stream ending on a tool line would otherwise drop
1352
+ // the round's accounting. When that carrier was mirrored in an
1353
+ // earlier flush (before the usage was knowable), the accounting is
1354
+ // lost — host 0.1.5 has no usage-backfill event.
1355
+ let carrier = -1;
1356
+ if (parser.completed && parser.usage !== undefined) {
1357
+ for (let scan = 0; scan < parser.lines.length; scan += 1) {
1358
+ if (parser.lines[scan]?.kind !== 'tool')
1359
+ carrier = scan;
1360
+ }
1361
+ }
1362
+ for (let index = mirrored; index < upto; index += 1) {
1363
+ const line = parser.lines[index];
1364
+ if (line === undefined)
1365
+ continue;
1366
+ const usage = index === carrier ? parser.usage : undefined;
1367
+ appendClaudeLine(spec, turn, index + 1, line, usage);
1368
+ mirrored = index + 1;
1369
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
1370
+ }
1371
+ await persistChildSession(ctx, childSession);
1372
+ localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: mirrored });
1373
+ }
1374
+ catch (error) {
1375
+ ctx.logger.warn(`subagent-claude: live mirror failed: ${thrown(error).message}`);
1376
+ }
1377
+ });
1378
+ },
1379
+ };
1380
+ return mirror;
1381
+ }
1382
+ /**
1383
+ * Mirror the delegation's user prompt and the claude stream transcript into
1384
+ * the dsh subagent session, then persist. Runs detached from the settle race —
1385
+ * the run result settles with the child exit, and a failure here is
1386
+ * diagnostic-only. The transcript's thinking folds to `reasoning` blocks,
1387
+ * reply text and tool activity (call with command plus result) to text
1388
+ * blocks, and the final assistant message carries the round's usage when
1389
+ * claude reported it, so the tokenUsage projection counts the delegation.
1390
+ * @param spec - the run spec carrying the child session and host context.
1391
+ * @param task - the one-shot task text (the user prompt).
1392
+ * @param turn - the round's turn number (1 for a fresh round, incremented on resume).
1393
+ * @param parsed - the parsed stream transcript, final output, and usage.
1394
+ * @param fromLines - transcript lines the live mirror already appended.
1395
+ * @param userMirrored - whether the live mirror already appended the prompt.
1396
+ */
1397
+ async function appendClaudeResponse(spec, task, turn, parsed, fromLines = 0, userMirrored = false) {
1398
+ if (spec.childSession === undefined || spec.ctx === undefined)
1399
+ return;
1400
+ const childSession = spec.childSession;
1401
+ const localAgent = spec.ctx.get('localAgent');
1402
+ if (!userMirrored) {
1403
+ childSession.append('user/message', createUserMessage({
1404
+ content: [{ type: 'text', text: task }],
1405
+ source: { kind: 'user' },
1406
+ }), { surfaceOp: 'append' });
1407
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
1408
+ }
1409
+ // The round's usage rides the last NON-tool line: tool activity folds to
1410
+ // `tool/call`/`tool/result` events (which carry no usage slot), and a kill
1411
+ // mid-tool ends the transcript with a tool line.
1412
+ let usageIndex = -1;
1413
+ for (let index = 0; index < parsed.lines.length; index += 1) {
1414
+ if (parsed.lines[index]?.kind !== 'tool')
1415
+ usageIndex = index;
1416
+ }
1417
+ let step = fromLines + 1;
1418
+ for (const line of parsed.lines.slice(fromLines)) {
1419
+ appendClaudeLine(spec, turn, step, line, step - 1 === usageIndex ? parsed.usage : undefined);
1420
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
1421
+ step += 1;
1422
+ }
1423
+ if (parsed.usage !== undefined && usageIndex !== -1 && usageIndex < fromLines) {
1424
+ // The carrier line went out through the live mirror before the usage was
1425
+ // knowable. Host 0.1.5 has no usage-backfill event (the per-chunk event is
1426
+ // gone and appended messages are immutable), so the round's accounting is
1427
+ // lost in this race — log it instead of dropping it silently.
1428
+ spec.ctx.logger.warn(`subagent-claude: round ${turn} usage arrived after its carrier line was mirrored; the accounting is dropped`);
1429
+ }
1430
+ await persistChildSession(spec.ctx, childSession);
1431
+ localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: parsed.lines.length });
1432
+ }
1433
+ /**
1434
+ * Persist the child session's mirrored events. Thin wrapper over the core's
1435
+ * {@link persistChildSession}: a live session syncs through the core's cached
1436
+ * per-child write handle (host 0.1.5's live write-behind never checkpoints a
1437
+ * mirror session — it has no agent loop — so production child logs stayed
1438
+ * header-only until the explicit sync), a standalone one through a one-shot
1439
+ * handle flow. Kept under the historical name for the live driver. Root cause
1440
+ * and fix identical to codex's codex-cli-provider.ts persistIfStandalone.
1441
+ */
1442
+ export async function persistIfStandalone(ctx, childSession) {
1443
+ await persistChildSession(ctx, childSession);
1444
+ }
1445
+ /**
1446
+ * Mirror the claude stream-json into the child session AFTER the CLI process
1447
+ * has exited, whatever its stop reason. A cancelled or failed round still
1448
+ * preserves the events the stream already emitted (thinking, tool calls with
1449
+ * results, replies, usage) instead of leaving the child blank — the run result
1450
+ * settles 'aborted'/'error' at the cancel moment, but claude may have produced
1451
+ * content before the kill landed. Also records the session id (fresh rounds)
1452
+ * so a later resume can continue the partial session.
1453
+ * @param spec - the run spec carrying the child session and host context.
1454
+ * @param task - the one-shot task text (the user prompt).
1455
+ * @param turn - the round's turn number.
1456
+ * @param output - the collected stream-json stdout.
1457
+ */
1458
+ async function mirrorClaudeAfterExit(spec, task, turn, output, live) {
1459
+ if (spec.childSession === undefined || spec.ctx === undefined)
1460
+ return;
1461
+ const childSession = spec.childSession;
1462
+ const work = async () => {
1463
+ const parsed = parseClaudeStreamJson(output);
1464
+ if (spec.resume === undefined)
1465
+ spec.onSessionId?.(parsed.sessionId);
1466
+ // TodoWrite translations that only the final transcript carries still
1467
+ // cross; idempotency vs the live mirror's appends is appendTodosIfChanged's.
1468
+ if (parsed.todoSkew === true) {
1469
+ spec.ctx?.logger.warn('subagent-claude: a TodoWrite call missed the documented input shape; folded as a plain tool line');
1470
+ }
1471
+ if (parsed.todos !== undefined)
1472
+ appendTodosIfChanged(childSession, parsed.todos);
1473
+ const fromLines = live?.mirroredLines ?? 0;
1474
+ const userMirrored = live?.userMirrored ?? false;
1475
+ // The round's settled observation rides out even when nothing streamed:
1476
+ // an empty stream is still a settled round. The model and the CLI version
1477
+ // both come from the stream's own init/result events — claude names both
1478
+ // on the wire, so nothing else is consulted unless the stream was
1479
+ // truncated before its init event; then the `--version` probe answers.
1480
+ const cliVersion = parsed.cliVersion ?? await spec.cliVersion?.();
1481
+ spec.onRoundSettled?.({
1482
+ ...parsed.model === undefined ? {} : { observedModel: parsed.model },
1483
+ ...cliVersion === undefined ? {} : { cliVersion },
1484
+ ...parsed.usage === undefined ? {} : { usage: parsed.usage },
1485
+ ...parsed.toolCalls === undefined ? {} : { toolCalls: parsed.toolCalls },
1486
+ });
1487
+ // Nothing streamed at all (e.g. the CLI died before the first event):
1488
+ // keep the pre-live-mirror behavior of recording nothing. A todos-only
1489
+ // stream still mirrors its snapshot (handled above).
1490
+ if (parsed.lines.length === 0 && !userMirrored)
1491
+ return;
1492
+ const trimmed = parsed.text?.trim();
1493
+ await appendClaudeResponse(spec, task, turn, {
1494
+ lines: parsed.lines,
1495
+ output: trimmed === undefined || trimmed === '' ? [] : [{ type: 'text', text: trimmed }],
1496
+ ...parsed.usage === undefined ? {} : { usage: parsed.usage },
1497
+ }, fromLines, userMirrored);
1498
+ };
1499
+ try {
1500
+ // Behind the live mirror's queue so a flush in flight cannot interleave.
1501
+ if (live === undefined)
1502
+ await work();
1503
+ else
1504
+ await live.enqueue(work);
1505
+ }
1506
+ catch (error) {
1507
+ spec.onError?.(thrown(error), 'error');
1508
+ }
1509
+ }