@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.
- package/README.en.md +120 -0
- package/README.i18n.yaml +6 -0
- package/README.md +120 -0
- package/cordis.patch.yml +42 -0
- package/lib/client.js +1065 -0
- package/lib/index.js +3771 -0
- package/lib/invariant.js +31 -0
- package/lib/types/claude-cli-provider.d.ts +389 -0
- package/lib/types/claude-cli-provider.js +1509 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +198 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/index.js +87 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/control-requests.d.ts +15 -0
- package/lib/types/control-requests.js +62 -0
- package/lib/types/index.d.ts +81 -0
- package/lib/types/index.js +300 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/live-driver.d.ts +172 -0
- package/lib/types/live-driver.js +1057 -0
- package/lib/types/live-switch.d.ts +67 -0
- package/lib/types/live-switch.js +85 -0
- package/lib/types/model-broker.d.ts +131 -0
- package/lib/types/model-broker.js +248 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +118 -0
- package/lib/types/provision.d.ts +63 -0
- package/lib/types/provision.js +132 -0
- package/lib/types/records.d.ts +120 -0
- package/lib/types/records.js +687 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- 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
|
+
}
|