@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,1057 @@
1
+ import { claudeDirectory } from './model-catalog.js';
2
+ /**
3
+ * The claude provider's live driver: one resident Claude Code stream-json
4
+ * process per member (child session), driven over the vendor's
5
+ * `--input-format stream-json --output-format stream-json` realtime mode
6
+ * (protocol-probed against claude 2.1.236; the Agent SDK's streaming-input
7
+ * mode is this same channel, driven here directly so no SDK dependency
8
+ * lands). A delegation round is one `user` message on the resident process's
9
+ * stdin instead of a fresh `claude -p` — which buys what the process boundary
10
+ * could not: runtime-level graceful interrupt (`control_request
11
+ * {subtype:'interrupt'}`; the process survives and the session stays
12
+ * continuable) and the same-shape push stream folded live per turn.
13
+ *
14
+ * Auth discipline (a hard requirement): the resident process runs under the
15
+ * family's scoped `CLAUDE_CONFIG_DIR` and nothing else — the OAuth marker in
16
+ * the scoped `.claude.json` and the keychain entry hashed to that path are
17
+ * exactly what the exec mode's one-shot processes already use. The driver
18
+ * never touches the user's global `~/.claude`, never runs any `auth` verb,
19
+ * and only adds `ANTHROPIC_BASE_URL` when the plugin config supplies it (the
20
+ * exec path's exact env rule).
21
+ *
22
+ * Protocol facts (all probed): the first stdin `user` message triggers
23
+ * `system/init` carrying the server-assigned session id (EVERY turn emits a
24
+ * fresh init — the delegation records the first one); each turn closes with
25
+ * a `result` event (`is_error`, usage, session id); the interrupt control
26
+ * request answers with a `control_response` success; a new process with
27
+ * `--resume <session_id>` reattaches the on-disk session in the same mode
28
+ * (crash recovery); stdin EOF quiesces the process (the reclaim ladder).
29
+ *
30
+ * Fold: the resident stream speaks the exec path's exact event vocabulary
31
+ * (`system`/`assistant`/`user`/`result`), so each turn folds through the
32
+ * shared `ClaudeStreamParser` with the exec live mirror's hold-back rule (the
33
+ * volatile last line waits for `result`, which carries the round's usage).
34
+ * Every line folds 1:1. The always-enabled
35
+ * `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
36
+ * stream (native message id + block index, with ordered legacy fallback),
37
+ * which reserves a (turn, step) at its first delta and
38
+ * appends throttled snapshot `assistant/message`s there — the host folds
39
+ * repeated settles at one coordinate into one live-updating chat node, the
40
+ * only streaming channel left after 0.1.5 retired the durable per-chunk
41
+ * event. A streamed line's completion folds at its stream's reserved step and
42
+ * finalizes it (no duplicated content); a stream whose completion never
43
+ * arrives is force-finalized at settle (interrupted on a non-completed round,
44
+ * so a cancelled turn reads 已停止 legitimately), the last one carrying the
45
+ * round's usage when no folded line did.
46
+ *
47
+ * Lifecycle mirrors M1–M3's discipline: lazy spawn, one in-flight spawn per
48
+ * member, idle-timeout reclaim (stdin EOF → grace → SIGTERM ladder), crash
49
+ * re-spawn with `--resume`, a channel breaker with cooldown, `disposeAll` on
50
+ * unload, and cancellation honored in every window (abort listener before
51
+ * any await; the init wait races the abort signal).
52
+ *
53
+ * Two member-aware additions on top: the spawn binds the member's EFFECTIVE
54
+ * model (session-level override, then the round's delegation model, then the
55
+ * settings key — resolved by the config callback per spawn, scratched into
56
+ * the scoped settings.json because a `--resume` respawn honors the file over
57
+ * the flag), and a runtime whose bound model no longer matches is retired so
58
+ * the round respawns onto the right one; and server→client control requests
59
+ * (a `permissionMode: 'normal'` spawn's `can_use_tool` approval surface) are
60
+ * auto-answered so an unattended runtime never hangs on a permission prompt.
61
+ * @module @khorsheed/dsh-local-agent-claude-code/live-driver
62
+ */
63
+ import { ClaudeControlRequests } from './control-requests.js';
64
+ import { StringDecoder } from 'node:string_decoder';
65
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
66
+ import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
67
+ import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
68
+ import { appendClaudeTranscriptLine, assistantEvent, claudeLineText, ClaudeStreamParser, claudeVersionFromInit, DEFAULT_DISPOSE_GRACE_MS, registerClaudeMemberRun, textTask, toolCallsOf, } from './claude-cli-provider.js';
69
+ import { syncClaudeCredentialFile } from './records.js';
70
+ /** Default idle lifetime of an unused resident runtime before reclaim. */
71
+ export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
72
+ /** Default timeout for the turn's system/init ack (a cold boot can be slow). */
73
+ export const DEFAULT_LIVE_INIT_TIMEOUT_MS = 60_000;
74
+ /** Bounded wait for an interrupted turn to converge while a run disposes. */
75
+ export const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5_000;
76
+ /** How long a tripped channel breaker stays on the exec fallback. */
77
+ export const DEFAULT_LIVE_CHANNEL_RETRY_MS = 5 * 60_000;
78
+ /** Grace between stdin EOF and SIGTERM when reclaiming a stream-json process. */
79
+ const RECLAIM_EOF_GRACE_MS = 1_000;
80
+ /** Default minimum interval between one streaming kind's snapshot messages. */
81
+ export const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
82
+ /** Default minimum text growth between one streaming kind's snapshot messages. */
83
+ export const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
84
+ /**
85
+ * The stream-json channel could not come up (spawn failure or the first
86
+ * turn's init never arrived). The provider catches exactly this and falls
87
+ * back to the exec one-shot; any other error is a real round failure.
88
+ */
89
+ export class LiveChannelUnavailableError extends Error {
90
+ constructor(message) {
91
+ super(message);
92
+ this.name = 'LiveChannelUnavailableError';
93
+ }
94
+ }
95
+ const DEFAULT_TIMEOUTS = {
96
+ initMs: DEFAULT_LIVE_INIT_TIMEOUT_MS,
97
+ convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
98
+ channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS,
99
+ };
100
+ function thrown(value) {
101
+ return value instanceof Error ? value : new Error(String(value));
102
+ }
103
+ function delay(ms) {
104
+ return new Promise(resolve => setTimeout(resolve, ms));
105
+ }
106
+ /**
107
+ * One resident stream-json process: the NDJSON frame pump, the session id it
108
+ * carries, the per-session turn chain (one turn at a time — claude queues
109
+ * stdin user messages, but the cancelled turn's result must land before the
110
+ * next turn's message goes out, keeping the stop-then-rephrase gesture's
111
+ * streams unambiguous), and the reclaim ladder (stdin EOF → grace → SIGTERM).
112
+ */
113
+ class ClaudeLiveRuntime {
114
+ child;
115
+ warn;
116
+ dead = false;
117
+ reclaimed = false;
118
+ /** The member's claude session id (first turn's system/init). */
119
+ sessionId;
120
+ /** The model this runtime's process was spawned with (undefined = no flag). */
121
+ configurationKey;
122
+ model;
123
+ /** Serializes turns per member (converge-before-next-message). */
124
+ turnChain = Promise.resolve();
125
+ /** The active round's event sink; installed per round, cleared at settle. */
126
+ onEvent;
127
+ controls = new ClaudeControlRequests(message => this.send(message));
128
+ onDead;
129
+ buffer = '';
130
+ decoder = new StringDecoder('utf8');
131
+ stderrTail = '';
132
+ stderrDecoder = new StringDecoder('utf8');
133
+ constructor(child, warn) {
134
+ this.child = child;
135
+ this.warn = warn;
136
+ child.stdout?.on('data', (chunk) => { this.feed(this.decoder.write(chunk)); });
137
+ child.stderr?.on('data', (chunk) => {
138
+ this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
139
+ });
140
+ void child.done.then(() => { this.markDead(); }, () => { this.markDead(); });
141
+ }
142
+ get diagnostics() {
143
+ return this.stderrTail.trim();
144
+ }
145
+ feed(text) {
146
+ this.buffer += text;
147
+ let index = this.buffer.indexOf('\n');
148
+ while (index >= 0) {
149
+ const line = this.buffer.slice(0, index);
150
+ this.buffer = this.buffer.slice(index + 1);
151
+ index = this.buffer.indexOf('\n');
152
+ if (line.trim() === '')
153
+ continue;
154
+ let event;
155
+ try {
156
+ event = JSON.parse(line);
157
+ }
158
+ catch {
159
+ this.warn('subagent-claude live: ignored a malformed stream line');
160
+ continue;
161
+ }
162
+ try {
163
+ this.dispatchEvent(event);
164
+ }
165
+ catch (error) {
166
+ this.warn(`subagent-claude live: stream handler failed: ${thrown(error).message}`);
167
+ }
168
+ }
169
+ }
170
+ dispatchEvent(event) {
171
+ if (this.controls.accept(event))
172
+ return;
173
+ if (event['type'] === 'control_request') {
174
+ this.answerControlRequest(event);
175
+ return;
176
+ }
177
+ this.onEvent?.(event);
178
+ }
179
+ /**
180
+ * Answer a server→client control request so the runtime never hangs on
181
+ * one. A `permissionMode: 'normal'` spawn omits
182
+ * `--dangerously-skip-permissions`, so the CLI routes its approval surface
183
+ * (`can_use_tool`) here — and a subagent has no human to approve. The
184
+ * auto-allow mirrors the exec path's effective behavior (its one-shot
185
+ * spawns pre-allow the member bridge tool and otherwise run the same
186
+ * unattended policy) and the kimi live driver's auto-allow of
187
+ * `session/request_permission`. Any other subtype answers with an error so
188
+ * the CLI's own fallback path runs instead of waiting forever.
189
+ */
190
+ answerControlRequest(event) {
191
+ const requestId = event['request_id'];
192
+ if (typeof requestId !== 'string')
193
+ return;
194
+ const request = event['request'];
195
+ const subtype = request?.['subtype'];
196
+ if (subtype === 'can_use_tool') {
197
+ const input = request?.['input'];
198
+ this.send({
199
+ type: 'control_response',
200
+ response: {
201
+ subtype: 'success',
202
+ request_id: requestId,
203
+ response: {
204
+ behavior: 'allow',
205
+ updatedInput: typeof input === 'object' && input !== null ? input : {},
206
+ },
207
+ },
208
+ });
209
+ return;
210
+ }
211
+ this.send({
212
+ type: 'control_response',
213
+ response: {
214
+ subtype: 'error',
215
+ request_id: requestId,
216
+ error: `subagent-claude live: unsupported control request ${String(subtype)}`,
217
+ },
218
+ });
219
+ }
220
+ /** Write one stdin frame. */
221
+ send(message) {
222
+ this.child.stdin?.write(JSON.stringify(message) + '\n');
223
+ }
224
+ /** Native initialization and model binding precede any user prompt. */
225
+ async configure(configuration, signal) {
226
+ const initialized = await this.controls.request({ subtype: 'initialize', hooks: {} }, signal);
227
+ const directory = claudeDirectory(initialized);
228
+ const entry = directory.entries.find(entry => entry.value === configuration.model || entry.resolvedModel === configuration.model);
229
+ if (configuration.effort !== undefined && !entry?.reasoning?.options.some(option => option.value === configuration.effort)) {
230
+ throw new Error('The running Claude CLI does not advertise the requested model/effort combination');
231
+ }
232
+ if (configuration.model !== undefined)
233
+ await this.controls.request({ subtype: 'set_model', model: configuration.model }, signal);
234
+ }
235
+ /** The graceful runtime interrupt, bounded and checked for an actual success ack. */
236
+ async interrupt() {
237
+ if (this.dead)
238
+ return;
239
+ await this.controls.request({ subtype: 'interrupt' });
240
+ }
241
+ markDead() {
242
+ if (this.dead)
243
+ return;
244
+ this.dead = true;
245
+ this.controls.close();
246
+ this.onDead?.();
247
+ }
248
+ /** Graceful teardown: EOF stdin, grace, then the terminate ladder. */
249
+ async reclaim() {
250
+ if (this.reclaimed)
251
+ return;
252
+ this.reclaimed = true;
253
+ if (!this.dead) {
254
+ this.child.stdin?.end();
255
+ await Promise.race([this.child.done.catch(() => { }), delay(RECLAIM_EOF_GRACE_MS)]);
256
+ }
257
+ if (!this.dead) {
258
+ // Host 0.1.5 hides the child pid; terminate() is an idempotent no-op
259
+ // once the managed range is gone.
260
+ this.child.terminate();
261
+ await this.child.waitForExit();
262
+ }
263
+ await this.child.done.catch(() => { });
264
+ this.markDead();
265
+ }
266
+ }
267
+ /**
268
+ * The claude live driver owns every resident stream-json runtime of this
269
+ * provider generation. Created only when `live: true`; disposed with the
270
+ * plugin.
271
+ */
272
+ export class ClaudeLiveDriver {
273
+ ctx;
274
+ config;
275
+ timeouts;
276
+ runtimes = new Map();
277
+ ensuring = new Map();
278
+ idleTimers = new Map();
279
+ /** Per-member round serialization (the resume lock covers resume-vs-resume only). */
280
+ roundChains = new Map();
281
+ channelBrokenAt;
282
+ disposed = false;
283
+ /**
284
+ * Set by drain() (a settings-driven generation handoff): new rounds are
285
+ * refused so the provider falls back to exec, while in-flight rounds finish
286
+ * on their runtime undisturbed. Unlike `disposed`, the driver still serves
287
+ * what it already accepted.
288
+ */
289
+ draining = false;
290
+ disposeController = new AbortController();
291
+ constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
292
+ this.ctx = ctx;
293
+ this.config = config;
294
+ this.timeouts = timeouts;
295
+ }
296
+ /** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
297
+ get disabled() {
298
+ return this.channelBrokenAt !== undefined
299
+ && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? DEFAULT_LIVE_CHANNEL_RETRY_MS);
300
+ }
301
+ /** Count of resident runtimes currently registered (zombie accounting in tests). */
302
+ get liveCount() {
303
+ return this.runtimes.size;
304
+ }
305
+ /**
306
+ * True while the member's runtime exists or is being spawned. The settings
307
+ * controller gates a new generation on this: a fresh driver must not serve
308
+ * a member whose retiring generation still hosts the (same) claude session.
309
+ */
310
+ hasRuntime(key) {
311
+ return this.runtimes.has(key) || this.ensuring.has(key);
312
+ }
313
+ /** @deprecated Compatibility no-op: live output is always incremental. */
314
+ setLiveMirrorGranularity(_granularity) { }
315
+ /**
316
+ * Drain for a settings-driven generation handoff: refuse new rounds (the
317
+ * provider's catch falls back to exec), let every in-flight round finish on
318
+ * its runtime, then reclaim. Resolves when no runtime or spawn remains.
319
+ * Unlike disposeAll, in-flight work is never interrupted.
320
+ */
321
+ async drain() {
322
+ this.draining = true;
323
+ // Snapshot: rounds chain synchronously at startRound, so every accepted
324
+ // round is already in roundChains; anything later is refused.
325
+ const keys = new Set([...this.runtimes.keys(), ...this.ensuring.keys(), ...this.roundChains.keys()]);
326
+ await Promise.all([...keys].map(async (key) => {
327
+ await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => undefined);
328
+ await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => undefined);
329
+ await this.reclaim(key);
330
+ }));
331
+ }
332
+ clearIdleTimer(key) {
333
+ const timer = this.idleTimers.get(key);
334
+ if (timer !== undefined)
335
+ clearTimeout(timer);
336
+ this.idleTimers.delete(key);
337
+ }
338
+ armIdleTimer(key) {
339
+ this.clearIdleTimer(key);
340
+ const idleMs = this.config.liveIdleMs ?? DEFAULT_LIVE_IDLE_MS;
341
+ const timer = setTimeout(() => { void this.reclaim(key); }, idleMs);
342
+ timer.unref();
343
+ this.idleTimers.set(key, timer);
344
+ }
345
+ async reclaim(key) {
346
+ const runtime = this.runtimes.get(key);
347
+ this.clearIdleTimer(key);
348
+ if (runtime === undefined)
349
+ return;
350
+ this.runtimes.delete(key);
351
+ await runtime.reclaim();
352
+ }
353
+ /** Reclaim every runtime (plugin unload); no process survives the profile. */
354
+ async disposeAll() {
355
+ this.disposed = true;
356
+ this.disposeController.abort();
357
+ await Promise.all([...this.ensuring.values()].map(pending => pending.catch(() => undefined)));
358
+ for (const key of [...this.runtimes.keys()]) {
359
+ await this.reclaim(key);
360
+ }
361
+ }
362
+ ensureRuntime(spec, signal) {
363
+ const key = String(spec.childSession.id);
364
+ const existing = this.runtimes.get(key);
365
+ if (existing !== undefined && !existing.dead && existing.model === this.spawnModel(key, spec)
366
+ && existing.configurationKey === (spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration))) {
367
+ this.clearIdleTimer(key);
368
+ return Promise.resolve(existing);
369
+ }
370
+ if (existing !== undefined)
371
+ this.runtimes.delete(key);
372
+ const pending = this.ensuring.get(key);
373
+ if (pending !== undefined)
374
+ return pending;
375
+ // A live runtime bound to a DIFFERENT model than this round would spawn
376
+ // with (a composer switch the broker did not retire eagerly, or a
377
+ // delegation naming its own model) is retired first — the respawn resumes
378
+ // the same CLI session from disk, so the conversation carries over.
379
+ const retire = existing !== undefined && !existing.dead ? existing.reclaim() : Promise.resolve();
380
+ const spawn = retire
381
+ .then(() => this.spawnRuntime(spec, signal))
382
+ .finally(() => { this.ensuring.delete(key); });
383
+ this.ensuring.set(key, spawn);
384
+ return spawn;
385
+ }
386
+ /** The model a spawn for this round would bind (the argv `--model` value). */
387
+ spawnModel(key, spec) {
388
+ if (spec.configuration !== undefined)
389
+ return spec.configuration.model;
390
+ const model = this.config.model?.(key, spec.model)?.trim();
391
+ return model === undefined || model === '' ? undefined : model;
392
+ }
393
+ /**
394
+ * The model the member's resident runtime was spawned with, or undefined
395
+ * when the member has no live runtime (or it binds none). The model broker
396
+ * compares this against a switch's new effective model.
397
+ */
398
+ runtimeModel(childSessionId) {
399
+ return this.runtimes.get(childSessionId)?.model;
400
+ }
401
+ /**
402
+ * Retire the member's resident runtime so the next round respawns with its
403
+ * new effective model (the model broker's switch path). The caller refuses
404
+ * switches while a round is in flight, so the reclaim never kills a run;
405
+ * the CLI session survives on disk and the respawn resumes it.
406
+ */
407
+ async retireRuntime(childSessionId) {
408
+ await (this.ensuring.get(childSessionId) ?? Promise.resolve()).catch(() => undefined);
409
+ await this.reclaim(childSessionId);
410
+ }
411
+ /**
412
+ * Spawn the member's resident stream-json process. There is no handshake
413
+ * message — the channel proves itself with the first turn's system/init —
414
+ * so spawn failure is the only breaker trip here; the init wait lives in
415
+ * the round's accept window.
416
+ */
417
+ async spawnRuntime(spec, signal) {
418
+ const key = String(spec.childSession.id);
419
+ if (this.disposed) {
420
+ throw new LiveChannelUnavailableError('the live driver is disposed');
421
+ }
422
+ // Keychain→file sync before EVERY spawn: claude 2.1.236 reads
423
+ // <home>/.credentials.json at runtime while login and the process's own
424
+ // refresh write the keychain — the file goes stale on every rotation
425
+ // (single-rotation refresh tokens make a consumed copy poison for the
426
+ // next spawn). The login watch's sync alone cannot keep a resident
427
+ // runtime authenticatable across rotations. Best-effort: a missing grant
428
+ // fails the turn with the CLI's own "Not logged in", not here.
429
+ await syncClaudeCredentialFile(spec.homeDir, message => { this.ctx.logger.warn(message); }).catch(() => false);
430
+ // The member bridge rides the spawn argv (`--mcp-config` + `--allowedTools`),
431
+ // exactly as in the exec path; the resident process serves one member, so
432
+ // its token lives with the process.
433
+ const member = registerClaudeMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
434
+ // The resident process serves one member, so the model resolved here binds
435
+ // that member's runtime; a later change reaches it when the runtime is next
436
+ // respawned (idle reclaim, crash, a broker-initiated retire, or the
437
+ // model-aware retire in ensureRuntime above).
438
+ const model = this.spawnModel(key, spec);
439
+ // The settings.json scratch: a --resume respawn restores the session's
440
+ // stored model over the --model flag, so the effective model also goes
441
+ // into the scoped file (best-effort — the argv flag still applies).
442
+ if (spec.configuration === undefined && this.config.provisionModel !== undefined) {
443
+ await this.config.provisionModel(spec.homeDir, model).catch(() => undefined);
444
+ }
445
+ const argv = [
446
+ 'claude', '-p', '--verbose',
447
+ ...model === undefined ? [] : ['--model', model],
448
+ '--input-format', 'stream-json',
449
+ '--output-format', 'stream-json',
450
+ '--include-partial-messages',
451
+ ...(this.config.permissionMode ?? 'skip') === 'skip' ? ['--dangerously-skip-permissions'] : [],
452
+ ...spec.resume === undefined ? [] : ['--resume', spec.resume.cliSessionId],
453
+ // `--allowedTools` is variadic: without the `--` terminator it swallows
454
+ // every following flag (the exec path's e191e27 lesson, same shape here).
455
+ ...member === undefined ? [] : ['--mcp-config', member.mcpConfig, '--allowedTools', member.allowedTool, '--'],
456
+ ];
457
+ const spawnSpec = {
458
+ argv,
459
+ cwd: spec.cwd,
460
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
461
+ graceMs: DEFAULT_DISPOSE_GRACE_MS,
462
+ // The auth discipline: exactly the exec path's env — the scoped config
463
+ // dir, plus the configured base URL override only.
464
+ env: delegationEnv({
465
+ CLAUDE_CONFIG_DIR: spec.homeDir,
466
+ ...spec.configuration === undefined ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? 'auto' },
467
+ ...this.config.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.config.baseUrl },
468
+ }),
469
+ };
470
+ let child;
471
+ try {
472
+ child = this.ctx.subprocess.spawn(spawnSpec);
473
+ }
474
+ catch (error) {
475
+ member?.release();
476
+ this.markChannelBroken();
477
+ throw new LiveChannelUnavailableError(`the stream-json process failed to spawn: ${thrown(error).message}`);
478
+ }
479
+ const runtime = new ClaudeLiveRuntime(child, message => { this.ctx.logger.warn(message); });
480
+ runtime.model = model;
481
+ runtime.configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
482
+ runtime.onDead = () => {
483
+ // Delete only OUR registration (crash-then-respawn interleave safety).
484
+ if (this.runtimes.get(key) === runtime)
485
+ this.runtimes.delete(key);
486
+ this.clearIdleTimer(key);
487
+ member?.release();
488
+ };
489
+ if (signal.aborted || this.disposeController.signal.aborted) {
490
+ await runtime.reclaim();
491
+ if (signal.aborted)
492
+ throw new Error('subagent-claude: run cancelled locally');
493
+ throw new LiveChannelUnavailableError('the live driver was disposed during spawn');
494
+ }
495
+ if (spec.configuration !== undefined) {
496
+ try {
497
+ await runtime.configure(spec.configuration, signal);
498
+ }
499
+ catch (error) {
500
+ await runtime.reclaim();
501
+ throw error;
502
+ }
503
+ }
504
+ this.channelBrokenAt = undefined;
505
+ this.runtimes.set(key, runtime);
506
+ return runtime;
507
+ }
508
+ markChannelBroken() {
509
+ this.channelBrokenAt = Date.now();
510
+ }
511
+ /**
512
+ * Drive one delegation round on the member's resident stream-json process.
513
+ * Mirrors the exec run's settlement contract exactly (settleRunResult +
514
+ * turn/end bookkeeping); cancel is the interrupt control request and the
515
+ * process survives.
516
+ */
517
+ /** Initialize native controls without sending a user prompt. Native history starts on its first real turn. */
518
+ async prepare(spec, signal) {
519
+ signal.throwIfAborted();
520
+ if (this.draining || this.disabled)
521
+ throw new LiveChannelUnavailableError('the live driver is unavailable for preparation');
522
+ try {
523
+ await this.ensureRuntime(spec, signal);
524
+ signal.throwIfAborted();
525
+ this.armIdleTimer(String(spec.childSession.id));
526
+ }
527
+ catch (error) {
528
+ await this.reclaim(String(spec.childSession.id));
529
+ throw error;
530
+ }
531
+ }
532
+ async startRound(request, spec) {
533
+ // A draining generation refuses new rounds BEFORE chaining so the
534
+ // provider's exec fallback does not queue behind an in-flight round.
535
+ if (this.draining) {
536
+ throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
537
+ }
538
+ const key = String(spec.childSession.id);
539
+ const previous = this.roundChains.get(key) ?? Promise.resolve();
540
+ const round = previous.catch(() => { }).then(() => this.startRoundLocked(request, spec));
541
+ this.roundChains.set(key, round.then(handle => handle.result.catch(() => ({})), () => ({})));
542
+ return round;
543
+ }
544
+ /** The serialized round body. */
545
+ async startRoundLocked(request, spec) {
546
+ const task = textTask(request.prompt);
547
+ if (request.signal.aborted) {
548
+ throw new Error('subagent-claude: request was aborted before the run started');
549
+ }
550
+ // The drain race: the round chained before drain() but dequeued after it.
551
+ // Refuse so the provider falls back to exec instead of reusing a runtime
552
+ // the handoff is about to reclaim.
553
+ if (this.draining) {
554
+ throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
555
+ }
556
+ if (this.disposed) {
557
+ throw new Error('subagent-claude: the live driver is disposed');
558
+ }
559
+ const turn = spec.resume?.turn ?? 1;
560
+ const childSession = spec.childSession;
561
+ const localAgent = this.ctx.get('localAgent');
562
+ const runAbort = new AbortController();
563
+ let roundSettled = false;
564
+ let runtime;
565
+ /** True while this round's turn is in flight (interrupt targets it). */
566
+ let turnInFlight = false;
567
+ /** True from the message going out until its result lands — the sink must
568
+ * survive settlement in that window so the chain can converge. */
569
+ let awaitingResult = false;
570
+ let turnOpened = false;
571
+ /** This turn's fold (the exec live mirror's exact parser). */
572
+ const parser = new ClaudeStreamParser();
573
+ let mirrored = 0;
574
+ /**
575
+ * The model and CLI build this turn's system/init named — the live
576
+ * drive's half of the exec settle parse's read-back, reported as the
577
+ * round's settled observation. EVERY turn re-emits its init, so a round
578
+ * always observes its own.
579
+ */
580
+ let roundModel;
581
+ let roundCliVersion;
582
+ /** Whether a folded line already carries the round's usage (no backfill event exists). */
583
+ let usageCarried = false;
584
+ /**
585
+ * The step ledger: `reservedSteps` holds every step a stream reservation
586
+ * consumed (in increasing order — permanently, whether the stream later
587
+ * completes or stays an orphan snapshot), and a sequential line folds at
588
+ * its index shifted past every reservation before it. A tool completed
589
+ * mid-stream therefore folds ABOVE the stream's step and the projection
590
+ * renders the round's chronological order.
591
+ */
592
+ const reservedSteps = [];
593
+ /**
594
+ * The streaming kinds seen this round by stream key:
595
+ * deltas accumulate into throttled snapshot assistant/messages appended
596
+ * at the kind's reserved (turn, step) — the host folds repeated settles
597
+ * at one coordinate into one live-updating chat node, which is the only
598
+ * streaming channel left after 0.1.5 retired the durable per-chunk event.
599
+ * The kind's completed line folds at the same step and finalizes it; an
600
+ * entry whose completion never arrives is force-finalized at settle.
601
+ */
602
+ const streams = new Map();
603
+ /** The stream deltas currently accumulate into (kinds stream sequentially). */
604
+ let activeStream;
605
+ let persistQueue = Promise.resolve();
606
+ const persist = () => {
607
+ // Live sessions sync through the core's cached write handle, standalone
608
+ // ones through a one-shot handle — the suffix append is idempotent on
609
+ // both paths (the kimi session-mirror root cause).
610
+ persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
611
+ this.ctx.logger.warn(`subagent-claude: live persistence failed: ${thrown(error).message}`);
612
+ });
613
+ };
614
+ const requestCancel = () => {
615
+ if (roundSettled || runAbort.signal.aborted)
616
+ return;
617
+ runAbort.abort(new Error('subagent-claude: run cancelled locally'));
618
+ // The live driver's core win: a graceful runtime interrupt instead of a
619
+ // process kill. Best-effort; local settlement does not wait for it.
620
+ if (turnInFlight && runtime !== undefined && !runtime.dead) {
621
+ void runtime.interrupt().catch(() => { });
622
+ }
623
+ };
624
+ const onAbort = () => { requestCancel(); };
625
+ request.signal.addEventListener('abort', onAbort, { once: true });
626
+ const abortBranch = new Promise((_, reject) => {
627
+ runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-claude: run cancelled locally')), { once: true });
628
+ });
629
+ const collectOutput = () => {
630
+ const text = parser.text?.trim();
631
+ return text === undefined || text === '' ? [] : [{ type: 'text', text: parser.text }];
632
+ };
633
+ // Native message identity plus content-block index separates same-kind
634
+ // blocks. Older partials without those fields use a message-generation
635
+ // key, paired with their completion in arrival order.
636
+ let messageKey;
637
+ let legacyMessage = 0;
638
+ const streamKey = (kind, index) => `${messageKey ?? `legacy-${turn}-${legacyMessage}`}:${typeof index === 'number' ? index : kind}`;
639
+ /**
640
+ * Append one snapshot of a streaming kind at its reserved (turn, step).
641
+ * Batched per stream on a bounded deadline unless `force`; the forced
642
+ * final snapshot carries `interrupted` (a cancelled turn reads 已停止
643
+ * legitimately) and, when `withUsage` and no folded line carried it, the
644
+ * round's usage.
645
+ */
646
+ const streamPublisher = localAgent?.liveStreams === undefined ? undefined
647
+ : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
648
+ const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
649
+ const pendingStreamArrival = new Map();
650
+ const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
651
+ if (stream.text.trim() === '')
652
+ return;
653
+ const now = Date.now();
654
+ if (!force) {
655
+ if (!pendingStreamArrival.has(stream.step))
656
+ pendingStreamArrival.set(stream.step, now);
657
+ liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
658
+ return;
659
+ }
660
+ liveFlush.cancel(stream.step);
661
+ const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
662
+ pendingStreamArrival.delete(stream.step);
663
+ if (streamPublisher !== undefined && !final) {
664
+ if (!stream.opened) {
665
+ childSession.append('step/start', { turn, step: stream.step });
666
+ stream.opened = true;
667
+ }
668
+ streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
669
+ stream.lastSnapshotAt = now;
670
+ stream.lastSnapshotLen = stream.text.length;
671
+ return;
672
+ }
673
+ if (!stream.opened) {
674
+ childSession.append('step/start', { turn, step: stream.step });
675
+ stream.opened = true;
676
+ }
677
+ const usage = parser.usage;
678
+ childSession.append('assistant/message', {
679
+ turn,
680
+ step: stream.step,
681
+ message: assistantEvent([
682
+ stream.kind === 'think'
683
+ ? { type: 'reasoning', text: stream.text }
684
+ : { type: 'text', text: stream.text },
685
+ ]),
686
+ stream: [],
687
+ ...withUsage && !usageCarried && usage !== undefined ? { usage } : {},
688
+ ...interrupted ? { interrupted: true } : {},
689
+ }, { surfaceOp: 'append' });
690
+ if (withUsage && !usageCarried && usage !== undefined)
691
+ usageCarried = true;
692
+ stream.lastSnapshotAt = now;
693
+ stream.lastSnapshotLen = stream.text.length;
694
+ persist();
695
+ };
696
+ /**
697
+ * Reserve the step one streaming kind will occupy — past every completed
698
+ * line (folded or held back) and every earlier reservation. A different
699
+ * kind's deltas force one final snapshot of the current stream (its
700
+ * completion still folds at its own reserved step when it lands).
701
+ */
702
+ const reserveStream = (itemId, kind) => {
703
+ if (activeStream !== undefined && activeStream !== itemId) {
704
+ const previous = streams.get(activeStream);
705
+ // A freshness snapshot on the kind switch, skipped when the text has
706
+ // not grown since the last one (the settle force always lands).
707
+ if (previous !== undefined && previous.text.length !== previous.lastSnapshotLen) {
708
+ appendStreamSnapshot(previous, true, false);
709
+ }
710
+ activeStream = undefined;
711
+ }
712
+ let stream = streams.get(itemId);
713
+ if (stream === undefined) {
714
+ const step = parser.lines.length + reservedSteps.length + 1;
715
+ reservedSteps.push(step);
716
+ stream = { itemId, kind, step, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
717
+ streams.set(itemId, stream);
718
+ }
719
+ activeStream = itemId;
720
+ };
721
+ /** The sequential fold step for `parser.lines[index]`: its position shifted past every reservation before it. */
722
+ const foldStep = (index) => {
723
+ let step = index + 1;
724
+ for (const reserved of reservedSteps) {
725
+ if (reserved <= step)
726
+ step += 1;
727
+ else
728
+ break;
729
+ }
730
+ return step;
731
+ };
732
+ /** Mirror folded lines [mirrored, upto); the last line is held back until `result`. */
733
+ const mirrorUpTo = (upto, withUsage) => {
734
+ // The usage rides the last NON-tool line (tool events carry no usage
735
+ // slot, and a kill mid-command ends the transcript with a tool line).
736
+ // A carrier mirrored in an earlier flush (before the usage was knowable)
737
+ // loses the accounting to the settle's final snapshot — host 0.1.5 has
738
+ // no usage-backfill event.
739
+ let usageIndex = -1;
740
+ if (withUsage) {
741
+ for (let index = 0; index < parser.lines.length; index += 1) {
742
+ if (parser.lines[index]?.kind !== 'tool')
743
+ usageIndex = index;
744
+ }
745
+ }
746
+ for (let index = mirrored; index < upto; index += 1) {
747
+ const line = parser.lines[index];
748
+ if (line === undefined)
749
+ continue;
750
+ const lineUsage = withUsage && index === usageIndex ? parser.usage : undefined;
751
+ if (lineUsage !== undefined)
752
+ usageCarried = true;
753
+ const stream = line.kind === 'tool' ? undefined
754
+ : (line.streamId === undefined ? undefined : streams.get(line.streamId))
755
+ ?? [...streams.values()].find(candidate => candidate.kind === line.kind && (line.streamId === undefined || candidate.itemId.startsWith('legacy-')));
756
+ const itemId = stream?.itemId;
757
+ if (itemId !== undefined && stream !== undefined) {
758
+ // A streamed line folds at its reserved step, finalizing the
759
+ // snapshots: the step opens only if no snapshot ever landed (a
760
+ // fast stream that stayed under the throttle), and closes here.
761
+ liveFlush.cancel(stream.step);
762
+ streams.delete(itemId);
763
+ if (activeStream === itemId)
764
+ activeStream = undefined;
765
+ if (!stream.opened)
766
+ childSession.append('step/start', { turn, step: stream.step });
767
+ const blocks = stream.kind === 'think'
768
+ ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
769
+ : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
770
+ childSession.append('assistant/message', {
771
+ turn,
772
+ step: stream.step,
773
+ message: assistantEvent(blocks),
774
+ stream: [],
775
+ ...lineUsage === undefined ? {} : { usage: lineUsage },
776
+ }, { surfaceOp: 'append' });
777
+ streamPublisher?.finish(stream.step);
778
+ childSession.append('step/end', { turn, step: stream.step });
779
+ }
780
+ else {
781
+ appendClaudeTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
782
+ }
783
+ mirrored = index + 1;
784
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
785
+ }
786
+ if (upto > 0)
787
+ persist();
788
+ };
789
+ let resolveResult;
790
+ const resultEvent = new Promise((resolve) => { resolveResult = resolve; });
791
+ let resolveInit;
792
+ let rejectInit;
793
+ const initArrived = new Promise((resolve, reject) => { resolveInit = resolve; rejectInit = reject; });
794
+ /** The round's event sink (installed on the runtime while it runs). */
795
+ const onEvent = (event) => {
796
+ const type = event['type'];
797
+ if (type === 'system' && typeof event['session_id'] === 'string') {
798
+ // EVERY turn emits an init; the first one mints the delegation id.
799
+ // Its model/build fields are this round's settled observation (the
800
+ // exec settle parse reads the same fields off the same event).
801
+ if (runtime !== undefined && runtime.sessionId === undefined) {
802
+ runtime.sessionId = event['session_id'];
803
+ resolveInit(runtime.sessionId);
804
+ }
805
+ if (typeof event['model'] === 'string' && event['model'] !== '')
806
+ roundModel = event['model'];
807
+ roundCliVersion = claudeVersionFromInit(event['claude_code_version']) ?? roundCliVersion;
808
+ return;
809
+ }
810
+ if (type === 'result') {
811
+ // The turn closes: fold the result event itself (it carries the
812
+ // round's usage — the fold's usage carrier rule needs it in both
813
+ // granularities), then flush the held-back last line WITH the usage.
814
+ awaitingResult = false;
815
+ parser.push(JSON.stringify(event) + '\n');
816
+ mirrorUpTo(parser.lines.length, true);
817
+ resolveResult(event);
818
+ return;
819
+ }
820
+ if (type === 'stream_event') {
821
+ const inner = event['event'];
822
+ if (inner?.['type'] === 'message_start') {
823
+ const message = inner['message'];
824
+ messageKey = typeof message?.['id'] === 'string' ? message['id'] : `legacy-${turn}-${++legacyMessage}`;
825
+ return;
826
+ }
827
+ const delta = inner?.['delta'];
828
+ const deltaType = delta?.['type'];
829
+ const text = delta?.['text'] ?? delta?.['thinking'];
830
+ if ((deltaType === 'text_delta' || deltaType === 'thinking_delta') && typeof text === 'string' && text !== '') {
831
+ const kind = deltaType === 'thinking_delta' ? 'think' : 'text';
832
+ reserveStream(streamKey(kind, inner?.['index']), kind);
833
+ const stream = activeStream === undefined ? undefined : streams.get(activeStream);
834
+ if (stream !== undefined) {
835
+ stream.text += text;
836
+ appendStreamSnapshot(stream, false, false);
837
+ }
838
+ // The delta still rides the run-progress channel live.
839
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
840
+ }
841
+ return;
842
+ }
843
+ if (type !== 'assistant' && type !== 'user')
844
+ return;
845
+ // Feed the exec path's exact parser: one line per event, as the exec
846
+ // stdout carried them.
847
+ parser.push(JSON.stringify(event) + '\n');
848
+ mirrorUpTo(parser.lines.length - 1, false);
849
+ if (type === 'assistant') {
850
+ messageKey = undefined;
851
+ legacyMessage++;
852
+ }
853
+ };
854
+ const sendAndInit = async () => {
855
+ if (runAbort.signal.aborted)
856
+ throw new Error('subagent-claude: run cancelled locally');
857
+ const rt = runtime;
858
+ // The sink installs only when this turn actually starts: an earlier
859
+ // turn's result must land in ITS OWN round's sink, never in this one
860
+ // (the chain below guarantees the previous result already landed).
861
+ rt.onEvent = onEvent;
862
+ turnInFlight = true;
863
+ awaitingResult = true;
864
+ rt.send({
865
+ type: 'user',
866
+ message: { role: 'user', content: [{ type: 'text', text: task }] },
867
+ });
868
+ if (rt.sessionId === undefined) {
869
+ const initWatchdog = setTimeout(() => {
870
+ // The channel's only proof: without an init the runtime cannot
871
+ // serve — trip the breaker and reclaim it.
872
+ void (async () => {
873
+ this.markChannelBroken();
874
+ await this.reclaim(String(childSession.id));
875
+ rejectInit(new LiveChannelUnavailableError('the stream-json init never arrived'));
876
+ })();
877
+ }, this.timeouts.initMs);
878
+ initWatchdog.unref();
879
+ try {
880
+ const cancelled = new Promise((_, reject) => {
881
+ runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-claude: run cancelled locally')), { once: true });
882
+ });
883
+ const sessionId = await Promise.race([initArrived, cancelled]).catch(async (error) => {
884
+ // A cancel inside the init window reclaims the never-proven
885
+ // runtime (the turn was sent but never acknowledged).
886
+ if (runAbort.signal.aborted && !(error instanceof LiveChannelUnavailableError)) {
887
+ await this.reclaim(String(childSession.id));
888
+ }
889
+ throw error;
890
+ });
891
+ spec.onSessionId?.(sessionId);
892
+ }
893
+ finally {
894
+ clearTimeout(initWatchdog);
895
+ }
896
+ }
897
+ };
898
+ const accepted = (async () => {
899
+ const rt = await this.ensureRuntime(spec, request.signal);
900
+ runtime = rt;
901
+ if (runAbort.signal.aborted) {
902
+ await this.reclaim(String(childSession.id));
903
+ throw new Error('subagent-claude: run cancelled locally');
904
+ }
905
+ // The turn boundary opens before the message goes out (exec parity: the
906
+ // exec path opens at spawn).
907
+ childSession.append('turn/start', { turn });
908
+ childSession.append('user/message', createUserMessage({
909
+ content: [{ type: 'text', text: task }],
910
+ source: { kind: 'user' },
911
+ }), { surfaceOp: 'append' });
912
+ turnOpened = true;
913
+ persist();
914
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
915
+ // One turn at a time per member: this message goes out only after the
916
+ // previous turn's result landed (claude would queue the stdin frame,
917
+ // but its result must never land in this round's sink).
918
+ rt.turnChain = rt.turnChain.catch(() => { }).then(sendAndInit);
919
+ await rt.turnChain;
920
+ })();
921
+ const attempt = accepted.then(async () => {
922
+ if (runAbort.signal.aborted)
923
+ throw new Error('subagent-claude: run cancelled locally');
924
+ const rt = runtime;
925
+ const processFailure = rt.child.done.then(outcome => Promise.reject(new Error('subagent-claude: the live runtime exited mid-round '
926
+ + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
927
+ processFailure.catch(() => { });
928
+ const resultPromise = Promise.race([resultEvent, processFailure]);
929
+ // Hold the chain until THIS turn's result lands, so the next round's
930
+ // message never starts mid-turn.
931
+ rt.turnChain = resultPromise;
932
+ const event = await resultPromise;
933
+ if (event['is_error'] === true) {
934
+ const message = typeof event['error'] === 'string' ? event['error'] : 'claude reported an error';
935
+ throw new Error(`subagent-claude live: ${message}`);
936
+ }
937
+ const output = collectOutput();
938
+ if (output.length === 0) {
939
+ throw new Error('subagent-claude live: the turn completed but produced no answer');
940
+ }
941
+ return { output, stopReason: 'completed' };
942
+ });
943
+ const result = settleRunResult({
944
+ attempt: () => Promise.race([attempt, abortBranch]),
945
+ collectOutput,
946
+ cancelled: () => runAbort.signal.aborted,
947
+ onError: (error, stopReason) => {
948
+ const diagnostics = runtime?.diagnostics ?? '';
949
+ const suffix = diagnostics === '' ? '' : `; ${diagnostics}`;
950
+ this.ctx.logger.warn(`subagent-claude: live round failed (${stopReason}): ${error.message}${suffix}`);
951
+ },
952
+ signal: request.signal,
953
+ onAbort,
954
+ }).then(async (settled) => {
955
+ roundSettled = true;
956
+ liveFlush.dispose();
957
+ turnInFlight = false;
958
+ if (turnOpened) {
959
+ // An aborted or failed turn never sees the result event, so its
960
+ // held-back line (and the usage already observed) would be lost —
961
+ // flush it now (the exec settle-mirror's partial-work contract).
962
+ if (mirrored < parser.lines.length)
963
+ mirrorUpTo(parser.lines.length, true);
964
+ // Streams whose completion never arrived (abort, or the turn ended
965
+ // mid-message) still finalize INSIDE the turn window: one forced
966
+ // snapshot each — interrupted on a non-completed round, the LAST one
967
+ // carrying the usage when no folded line did — then step/end.
968
+ if (streams.size > 0) {
969
+ const remaining = [...streams.values()];
970
+ for (const [position, stream] of remaining.entries()) {
971
+ appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
972
+ streamPublisher?.finish(stream.step);
973
+ if (stream.opened)
974
+ childSession.append('step/end', { turn, step: stream.step });
975
+ }
976
+ streams.clear();
977
+ activeStream = undefined;
978
+ }
979
+ if (settled.stopReason === 'completed') {
980
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
981
+ }
982
+ else if (settled.stopReason === 'aborted') {
983
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
984
+ }
985
+ else {
986
+ childSession.append('turn/end', {
987
+ turn,
988
+ reason: { kind: 'error', error: { message: 'the live round did not complete', code: 'UNKNOWN' } },
989
+ });
990
+ }
991
+ }
992
+ // Settlement clears the round's event sink — by identity, and only once
993
+ // the turn's result landed: an in-flight turn's late result must still
994
+ // reach this round's resolver so the chain can converge.
995
+ if (runtime !== undefined && runtime.onEvent === onEvent && !awaitingResult) {
996
+ runtime.onEvent = undefined;
997
+ }
998
+ streamPublisher?.dispose();
999
+ // A completed answer is not a durable turn until its final boundary
1000
+ // and any closing stream checkpoints have reached the child store.
1001
+ if (turnOpened) {
1002
+ persist();
1003
+ await persistQueue;
1004
+ }
1005
+ return settled;
1006
+ });
1007
+ // Final mirror report + reaper re-arm, mirroring the exec settle pass's
1008
+ // progress contract. A round that never opened owns no span — skip it.
1009
+ void result.then(async () => {
1010
+ try {
1011
+ if (turnOpened) {
1012
+ await persistQueue.catch(() => { });
1013
+ localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: mirrored });
1014
+ }
1015
+ }
1016
+ finally {
1017
+ // Every settled round reports its observation through the registry's
1018
+ // channel (record merge + `settled` event) — the live drive's half of
1019
+ // the exec path's onRoundSettled: the init event's model and build,
1020
+ // the result event's usage, and the round's tool-call accounting, each
1021
+ // absent when the stream never yielded it. Degrades silently on a
1022
+ // core predating recordRoundSettled (the round still settles).
1023
+ if (turnOpened) {
1024
+ const toolCalls = toolCallsOf(parser.toolCalls);
1025
+ const round = {
1026
+ ...roundModel === undefined ? {} : { observedModel: roundModel },
1027
+ ...roundCliVersion === undefined ? {} : { cliVersion: roundCliVersion },
1028
+ ...parser.usage === undefined ? {} : { usage: parser.usage },
1029
+ ...toolCalls === undefined ? {} : { toolCalls },
1030
+ };
1031
+ const registry = localAgent;
1032
+ registry?.recordRoundSettled?.(childSession.id, round);
1033
+ }
1034
+ this.armIdleTimer(String(childSession.id));
1035
+ }
1036
+ });
1037
+ // Publication gate: hold start() until the session is known and the turn
1038
+ // is in flight (channel errors still throw for the exec fallback), but
1039
+ // never hold it hostage — a cancel returns immediately.
1040
+ await Promise.race([accepted, abortBranch.catch(() => undefined)]);
1041
+ return subprocessRunHandle({
1042
+ id: childSession.id,
1043
+ result,
1044
+ signal: request.signal,
1045
+ onAbort,
1046
+ requestCancel,
1047
+ // The live teardown contract: NEVER kill the runtime here. The graceful
1048
+ // interrupt already went out; wait (bounded) for the turn's result to
1049
+ // land so the next round starts clean.
1050
+ teardown: async () => {
1051
+ if (roundSettled)
1052
+ return;
1053
+ await Promise.race([resultEvent.catch(() => ({})), delay(this.timeouts.convergeMs)]);
1054
+ },
1055
+ });
1056
+ }
1057
+ }