@khorsheed/dsh-local-agent-dsh 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 (45) hide show
  1. package/README.en.md +150 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +151 -0
  4. package/cordis.patch.yml +24 -0
  5. package/lib/client.js +1115 -0
  6. package/lib/dsh-cli-provider-UhWvivte.js +1701 -0
  7. package/lib/dsh-cli-provider.js +3 -0
  8. package/lib/index.js +624 -0
  9. package/lib/invariant.js +33 -0
  10. package/lib/provision.js +602 -0
  11. package/lib/records.js +109 -0
  12. package/lib/session-log-DOsSz56-.js +88 -0
  13. package/lib/types/client/SettingsCard.d.ts +78 -0
  14. package/lib/types/client/SettingsCard.js +200 -0
  15. package/lib/types/client/icons.d.ts +18 -0
  16. package/lib/types/client/icons.js +2 -0
  17. package/lib/types/client/index.d.ts +45 -0
  18. package/lib/types/client/index.js +88 -0
  19. package/lib/types/client/locales.d.ts +76 -0
  20. package/lib/types/client/locales.js +67 -0
  21. package/lib/types/dsh-cli-provider.d.ts +213 -0
  22. package/lib/types/dsh-cli-provider.js +733 -0
  23. package/lib/types/index.d.ts +124 -0
  24. package/lib/types/index.js +347 -0
  25. package/lib/types/invariant.d.ts +16 -0
  26. package/lib/types/invariant.js +37 -0
  27. package/lib/types/live-driver.d.ts +180 -0
  28. package/lib/types/live-driver.js +887 -0
  29. package/lib/types/live-switch.d.ts +76 -0
  30. package/lib/types/live-switch.js +100 -0
  31. package/lib/types/model-broker.d.ts +86 -0
  32. package/lib/types/model-broker.js +149 -0
  33. package/lib/types/model-catalog.d.ts +27 -0
  34. package/lib/types/model-catalog.js +71 -0
  35. package/lib/types/provision.d.ts +382 -0
  36. package/lib/types/provision.js +656 -0
  37. package/lib/types/records.d.ts +41 -0
  38. package/lib/types/records.js +125 -0
  39. package/lib/types/session-log.d.ts +58 -0
  40. package/lib/types/session-log.js +83 -0
  41. package/lib/types/session-mirror.d.ts +155 -0
  42. package/lib/types/session-mirror.js +451 -0
  43. package/locale/en.json +6 -0
  44. package/locale/zh.json +6 -0
  45. package/package.json +121 -0
@@ -0,0 +1,887 @@
1
+ /**
2
+ * The dsh provider's live driver: one resident sub-dsh serve process per
3
+ * member (child session), driven over the family-internal wire
4
+ * (`@khorsheed/dsh-local-agent-dsh-headless/wire`). A delegation round is a
5
+ * `turn/start` request to the member's resident runtime instead of a fresh
6
+ * one-shot process, which buys what the process boundary could not:
7
+ * runtime-level graceful interrupt (`turn/interrupt` → in-process
8
+ * `Agent.cancel`; the process survives and the session stays continuable)
9
+ * and push-mode mirroring (session events arrive as `session/event`
10
+ * notifications and are mirrored event-by-event through the same fold the
11
+ * file mirror owns, with the file-based `mirrorDshSession` as the settle
12
+ * reconciliation pass).
13
+ *
14
+ * Lifecycle (the mode's main cost): the runtime spawns lazily on the member's
15
+ * first live round, is reclaimed after an idle timeout or on plugin unload,
16
+ * and is re-spawned — resuming the on-disk session — after a crash. Every
17
+ * process is registered in {@link DshLiveDriver.runtimes} until reclaimed, so
18
+ * no zombies survive a profile restart.
19
+ * @module @khorsheed/dsh-local-agent-dsh/live-driver
20
+ */
21
+ import { StringDecoder } from 'node:string_decoder';
22
+ import { BlockAssembler, createAssistantMessage } from '@deepseek-ai/dsh-llm';
23
+ import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
24
+ import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush } from '@khorsheed/dsh-local-agent';
25
+ import { LIVE_SERVER_NAME, LIVE_WIRE_PROTOCOL_VERSION, } from '@khorsheed/dsh-local-agent-dsh-headless/wire';
26
+ import { DEFAULT_DISPOSE_GRACE_MS, dshLaunchArgv, dshTextTask, registerMemberRun, resolveApiKey, } from './dsh-cli-provider.js';
27
+ import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from './provision.js';
28
+ import { mirrorDshLiveEvent, mirrorDshSession } from './session-mirror.js';
29
+ /** Default idle lifetime of an unused resident runtime before reclaim. */
30
+ export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
31
+ /** Default timeout for a wire request acknowledgement. */
32
+ export const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 30_000;
33
+ /** Default timeout for the serve handshake (a cold sub-dsh boot can be slow). */
34
+ export const DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS = 60_000;
35
+ /** Bounded wait for an interrupted turn to converge while a run disposes. */
36
+ export const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5_000;
37
+ /** Bounded wait for a reclaimed runtime's own shutdown before SIGTERM. */
38
+ const RECLAIM_SHUTDOWN_GRACE_MS = 1_000;
39
+ /** How long the channel stays broken before a round retries live. */
40
+ export const DEFAULT_LIVE_CHANNEL_RETRY_MS = 5 * 60_000;
41
+ /**
42
+ * The serve channel could not come up (spawn failure or handshake
43
+ * timeout/mismatch). The provider catches exactly this and falls back to the
44
+ * exec one-shot; any other error is a real round failure.
45
+ */
46
+ export class LiveChannelUnavailableError extends Error {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = 'LiveChannelUnavailableError';
50
+ }
51
+ }
52
+ const DEFAULT_TIMEOUTS = {
53
+ initializeMs: DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS,
54
+ requestMs: DEFAULT_LIVE_REQUEST_TIMEOUT_MS,
55
+ convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
56
+ channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS,
57
+ };
58
+ function delay(ms) {
59
+ return new Promise(resolve => setTimeout(resolve, ms));
60
+ }
61
+ function thrown(value) {
62
+ return value instanceof Error ? value : new Error(String(value));
63
+ }
64
+ /**
65
+ * One resident serve process: the wire client, the process registry entry,
66
+ * and the reclaim ladder (wire shutdown → grace → SIGTERM → grace → SIGKILL
67
+ * via the subprocess handle's own terminate).
68
+ */
69
+ class LiveRuntime {
70
+ child;
71
+ timeouts;
72
+ warn;
73
+ /** Set once the process exited or was reclaimed; a dead runtime never serves again. */
74
+ dead = false;
75
+ /**
76
+ * The model this process bound at spawn (`--model`), or undefined when it
77
+ * spawned with no model flag at all. A round whose start model differs
78
+ * retires the runtime instead of silently running the old one.
79
+ */
80
+ configurationKey;
81
+ boundModel;
82
+ reclaimed = false;
83
+ pending = new Map();
84
+ nextId = 0;
85
+ buffer = '';
86
+ decoder = new StringDecoder('utf8');
87
+ stderrDecoder = new StringDecoder('utf8');
88
+ stderrTail = '';
89
+ /**
90
+ * Per-round handlers, installed by the active round (one at a time per
91
+ * member) and CLEARED when it settles — a notification with no active
92
+ * round is dropped, never delivered into a stale closure.
93
+ */
94
+ onEvent;
95
+ onStream;
96
+ onIdle;
97
+ /** Fires once when the process dies or is reclaimed (driver bookkeeping). */
98
+ onDead;
99
+ constructor(child, timeouts, warn) {
100
+ this.child = child;
101
+ this.timeouts = timeouts;
102
+ this.warn = warn;
103
+ // StringDecoders hold a multi-byte UTF-8 tail split across chunks instead
104
+ // of corrupting it into U+FFFD (the wire carries arbitrary CJK text).
105
+ child.stdout?.on('data', (chunk) => { this.feed(this.decoder.write(chunk)); });
106
+ child.stderr?.on('data', (chunk) => {
107
+ this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
108
+ });
109
+ void child.done.then(() => { this.markDead(); }, () => { this.markDead(); });
110
+ }
111
+ /** Diagnostic tail for round-failure logs. */
112
+ get diagnostics() {
113
+ return this.stderrTail.trim();
114
+ }
115
+ feed(text) {
116
+ this.buffer += text;
117
+ let index = this.buffer.indexOf('\n');
118
+ while (index >= 0) {
119
+ const line = this.buffer.slice(0, index);
120
+ this.buffer = this.buffer.slice(index + 1);
121
+ index = this.buffer.indexOf('\n');
122
+ if (line.trim() === '')
123
+ continue;
124
+ this.dispatchLine(line);
125
+ }
126
+ }
127
+ /** Handle one wire line; a malformed or hostile line never escapes this frame. */
128
+ dispatchLine(line) {
129
+ let message;
130
+ try {
131
+ message = JSON.parse(line);
132
+ }
133
+ catch {
134
+ this.warn('subagent-dsh live: ignored a malformed wire line');
135
+ return;
136
+ }
137
+ try {
138
+ if (typeof message.id === 'number' && ('result' in message || 'error' in message)) {
139
+ const request = this.pending.get(message.id);
140
+ if (request === undefined)
141
+ return;
142
+ this.pending.delete(message.id);
143
+ clearTimeout(request.timer);
144
+ const wireError = message.error;
145
+ if (wireError !== undefined && wireError !== null) {
146
+ request.reject(new Error(`subagent-dsh live: ${wireError.message ?? 'wire error'}`));
147
+ }
148
+ else {
149
+ request.resolve(message.result);
150
+ }
151
+ return;
152
+ }
153
+ const params = (message.params ?? {});
154
+ if (typeof params.sessionId !== 'string')
155
+ return;
156
+ if (message.method === 'session/event') {
157
+ const event = params.event;
158
+ if (event === null || typeof event !== 'object' || typeof event.type !== 'string')
159
+ return;
160
+ this.onEvent?.(params.sessionId, typeof params.turn === 'number' ? params.turn : null, event);
161
+ }
162
+ else if (message.method === 'session/assistant-stream') {
163
+ if (typeof params.turn !== 'number' || params.frame === null || typeof params.frame !== 'object')
164
+ return;
165
+ this.onStream?.(params.sessionId, params.turn, params.frame);
166
+ }
167
+ else if (message.method === 'session/idle') {
168
+ if (typeof params.turn !== 'number')
169
+ return;
170
+ this.onIdle?.(params.sessionId, params.turn, (params.reason ?? null));
171
+ }
172
+ }
173
+ catch (error) {
174
+ // A handler fault (bad event shape, append failure) degrades to a warn;
175
+ // the wire pump and the process stay alive.
176
+ this.warn(`subagent-dsh live: notification handler failed: ${thrown(error).message}`);
177
+ }
178
+ }
179
+ /** Send one request and await its acknowledgement. */
180
+ request(method, params, timeoutMs) {
181
+ if (this.dead) {
182
+ return Promise.reject(new Error('subagent-dsh live: the runtime process is gone'));
183
+ }
184
+ this.nextId += 1;
185
+ const id = this.nextId;
186
+ return new Promise((resolve, reject) => {
187
+ const timer = setTimeout(() => {
188
+ this.pending.delete(id);
189
+ reject(new Error(`subagent-dsh live: ${method} timed out`));
190
+ timer.unref();
191
+ }, timeoutMs ?? this.timeouts.requestMs);
192
+ timer.unref();
193
+ this.pending.set(id, {
194
+ resolve: result => resolve(result),
195
+ reject,
196
+ timer,
197
+ });
198
+ this.child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
199
+ });
200
+ }
201
+ markDead() {
202
+ if (this.dead)
203
+ return;
204
+ this.dead = true;
205
+ for (const request of this.pending.values()) {
206
+ clearTimeout(request.timer);
207
+ request.reject(new Error('subagent-dsh live: the runtime process exited'));
208
+ }
209
+ this.pending.clear();
210
+ this.onDead?.();
211
+ }
212
+ /** Graceful teardown: wire shutdown, a beat to exit on its own, then the terminate ladder. */
213
+ async reclaim() {
214
+ if (this.reclaimed)
215
+ return;
216
+ this.reclaimed = true;
217
+ if (!this.dead) {
218
+ await this.request('shutdown', {}, RECLAIM_SHUTDOWN_GRACE_MS).catch(() => { });
219
+ await Promise.race([this.child.done.catch(() => { }), delay(RECLAIM_SHUTDOWN_GRACE_MS)]);
220
+ }
221
+ if (!this.dead) {
222
+ // Host 0.1.5 hides the child pid; terminate() is an idempotent no-op
223
+ // once the managed range is gone.
224
+ this.child.terminate();
225
+ await this.child.waitForExit();
226
+ }
227
+ await this.child.done.catch(() => { });
228
+ this.markDead();
229
+ }
230
+ }
231
+ /**
232
+ * The live driver owns every resident runtime of this provider generation.
233
+ * The LiveDriverSwitch builds a generation when the settings resolve live on,
234
+ * retires one with {@link DshLiveDriver.drain} (never interrupts), and
235
+ * disposes with the enabled toggle (disposeAll).
236
+ */
237
+ export class DshLiveDriver {
238
+ ctx;
239
+ config;
240
+ timeouts;
241
+ runtimes = new Map();
242
+ /** In-flight spawns by member: concurrent rounds share one, disposeAll waits them out. */
243
+ ensuring = new Map();
244
+ idleTimers = new Map();
245
+ /** Per-member round serialization (the resume lock covers resume-vs-resume only). */
246
+ roundChains = new Map();
247
+ /** When the channel last failed its spawn/handshake probe (breaker with cooldown). */
248
+ channelBrokenAt;
249
+ disposed = false;
250
+ /**
251
+ * Set by drain() (a settings-driven generation handoff): new rounds are
252
+ * refused so the provider falls back to exec, while in-flight rounds finish
253
+ * on their runtime undisturbed. Unlike `disposed`, the driver still serves
254
+ * what it already accepted.
255
+ */
256
+ draining = false;
257
+ /** Aborts in-flight spawns when the driver is disposed mid-handshake. */
258
+ disposeController = new AbortController();
259
+ constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
260
+ this.ctx = ctx;
261
+ this.config = config;
262
+ this.timeouts = timeouts;
263
+ }
264
+ /**
265
+ * Whether the channel is in its post-failure cooldown. A spawn/handshake
266
+ * failure trips the breaker (rounds fall back to exec), but only for
267
+ * `channelRetryMs` — a transient boot fault must not disable live driving
268
+ * until the next plugin reload.
269
+ */
270
+ get disabled() {
271
+ return this.channelBrokenAt !== undefined
272
+ && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? DEFAULT_LIVE_CHANNEL_RETRY_MS);
273
+ }
274
+ /** Count of resident runtimes currently registered (zombie accounting in tests). */
275
+ get liveCount() {
276
+ return this.runtimes.size;
277
+ }
278
+ clearIdleTimer(key) {
279
+ const timer = this.idleTimers.get(key);
280
+ if (timer !== undefined)
281
+ clearTimeout(timer);
282
+ this.idleTimers.delete(key);
283
+ }
284
+ /** Arm the idle reaper; every round settlement re-arms it. */
285
+ armIdleTimer(key) {
286
+ this.clearIdleTimer(key);
287
+ const idleMs = this.config.liveIdleMs ?? DEFAULT_LIVE_IDLE_MS;
288
+ const timer = setTimeout(() => { void this.reclaim(key); }, idleMs);
289
+ timer.unref();
290
+ this.idleTimers.set(key, timer);
291
+ }
292
+ /** Reclaim one runtime: deregister, wire shutdown, terminate ladder, member release. */
293
+ async reclaim(key) {
294
+ const runtime = this.runtimes.get(key);
295
+ this.clearIdleTimer(key);
296
+ if (runtime === undefined)
297
+ return;
298
+ this.runtimes.delete(key);
299
+ await runtime.reclaim();
300
+ }
301
+ /** Reclaim every runtime (plugin unload); no process survives the profile. */
302
+ async disposeAll() {
303
+ this.disposed = true;
304
+ // Abort in-flight spawns (their handshakes race this signal), then wait
305
+ // them out: each self-reclaims, so no process registers past the teardown.
306
+ this.disposeController.abort();
307
+ await Promise.all([...this.ensuring.values()].map(pending => pending.catch(() => undefined)));
308
+ for (const key of [...this.runtimes.keys()]) {
309
+ await this.reclaim(key);
310
+ }
311
+ }
312
+ /**
313
+ * True while the member's runtime exists or is being spawned. The settings
314
+ * controller gates a new generation on this: a fresh driver must not serve
315
+ * a member whose retiring generation still hosts the (same) sub-dsh session.
316
+ */
317
+ hasRuntime(key) {
318
+ return this.runtimes.has(key) || this.ensuring.has(key);
319
+ }
320
+ /** @deprecated Compatibility no-op: live output is always incremental. */
321
+ setLiveMirrorGranularity(_granularity) { }
322
+ /**
323
+ * Drain for a settings-driven generation handoff: refuse new rounds (the
324
+ * provider's catch falls back to exec), let every in-flight round finish on
325
+ * its runtime, then reclaim. Resolves when no runtime or spawn remains.
326
+ * Unlike disposeAll, in-flight work is never interrupted.
327
+ */
328
+ async drain() {
329
+ this.draining = true;
330
+ // Snapshot: rounds chain synchronously at startRound, so every accepted
331
+ // round is already in roundChains; anything later is refused.
332
+ const keys = new Set([...this.runtimes.keys(), ...this.ensuring.keys(), ...this.roundChains.keys()]);
333
+ await Promise.all([...keys].map(async (key) => {
334
+ await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => undefined);
335
+ await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => undefined);
336
+ await this.reclaim(key);
337
+ }));
338
+ }
339
+ /**
340
+ * The model the member's live runtime bound at spawn: the identifier, or
341
+ * undefined when the runtime spawned with no `--model`. Null when the
342
+ * member has no live runtime in this generation — the broker's switch check
343
+ * distinguishes "no runtime to retire" from "runtime bound to no model".
344
+ */
345
+ boundModelOf(key) {
346
+ const runtime = this.runtimes.get(key);
347
+ if (runtime === undefined || runtime.dead)
348
+ return null;
349
+ return runtime.boundModel;
350
+ }
351
+ /**
352
+ * Retire the member's resident runtime so the NEXT round respawns (the
353
+ * sub-dsh session itself carries over via the on-disk resume). The
354
+ * composer's model switch calls this after storing the new override; the
355
+ * caller guarantees no round is in flight for the member.
356
+ */
357
+ async retireRuntime(key) {
358
+ await this.reclaim(key);
359
+ }
360
+ /**
361
+ * The member's live runtime, spawning it (once per member at a time) when
362
+ * absent or dead. `signal` abandons the spawn on cancellation.
363
+ */
364
+ async ensureRuntime(spec, apiKey, signal) {
365
+ const key = spec.sessionId;
366
+ const startModel = spec.startModel?.trim();
367
+ const existing = this.runtimes.get(key);
368
+ if (existing !== undefined && !existing.dead) {
369
+ // A round that names its own start model never runs on a runtime bound
370
+ // to a different one: retire so the respawn binds the asked-for model
371
+ // (the sub-dsh session resumes from disk — only the process is
372
+ // replaced).
373
+ if ((spec.configuration !== undefined && existing.configurationKey !== JSON.stringify(spec.configuration))
374
+ || (spec.configuration === undefined && existing.configurationKey !== undefined)
375
+ || (startModel !== undefined && startModel !== '' && existing.boundModel !== startModel)) {
376
+ await this.reclaim(key);
377
+ }
378
+ else {
379
+ this.clearIdleTimer(key);
380
+ return existing;
381
+ }
382
+ }
383
+ else if (existing !== undefined) {
384
+ this.runtimes.delete(key);
385
+ }
386
+ const pending = this.ensuring.get(key);
387
+ if (pending !== undefined)
388
+ return pending;
389
+ const spawn = this.spawnRuntime(spec, apiKey, signal)
390
+ .finally(() => { this.ensuring.delete(key); });
391
+ this.ensuring.set(key, spawn);
392
+ return spawn;
393
+ }
394
+ /**
395
+ * Spawn the member's resident runtime and prove the wire with a handshake.
396
+ * Spawn/handshake failure trips the channel breaker (exec fallback) and
397
+ * throws {@link LiveChannelUnavailableError}; a later crash is NOT a broken
398
+ * channel — the next round re-spawns and resumes the on-disk session. A
399
+ * cancellation mid-spawn reclaims the half-started runtime and throws the
400
+ * abort instead, without touching the breaker.
401
+ */
402
+ async spawnRuntime(spec, apiKey, signal) {
403
+ const key = spec.sessionId;
404
+ if (this.disposed) {
405
+ throw new LiveChannelUnavailableError('the live driver is disposed');
406
+ }
407
+ const profileName = this.config.profileName ?? DEFAULT_SUB_PROFILE_NAME;
408
+ // The member-channel coordinates ride the spawn env: the resident process
409
+ // carries them for its whole lifetime, so the token is registered with the
410
+ // process (released on reclaim/crash), not per round like the exec path.
411
+ const member = registerMemberRun(this.ctx, 'dsh-cli', spec.sessionId, spec.parentSessionId);
412
+ // The model binds at spawn through the headless launch's `--model`, which
413
+ // under `--serve` applies to every session the resident process hosts —
414
+ // one process carries one member, so this IS the member's model. The
415
+ // member's START model (a delegation-level request or the session-level
416
+ // override, resolved by the provider) wins; otherwise the spawn resolver
417
+ // reads the member's configured model (override → settings). A later
418
+ // switch reaches the runtime by retiring it first.
419
+ const startModel = spec.startModel?.trim();
420
+ const model = spec.configuration !== undefined ? spec.configuration.model
421
+ : startModel !== undefined && startModel !== '' ? startModel : this.config.modelFor?.(key)?.trim();
422
+ const spawnSpec = {
423
+ argv: [
424
+ ...dshLaunchArgv(this.config),
425
+ '--profile', profileName,
426
+ '--serve',
427
+ ...model === undefined || model === '' ? [] : ['--model', model],
428
+ ...spec.configuration?.effort === undefined ? [] : ['--effort', spec.configuration.effort],
429
+ ],
430
+ cwd: spec.cwd,
431
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
432
+ graceMs: DEFAULT_DISPOSE_GRACE_MS,
433
+ // Same explicit env layer as the exec path: the credential-shaped key
434
+ // and the DSH_* facts survive the shared scrub only here.
435
+ env: delegationEnv({
436
+ DSH_HOME: spec.homeDir,
437
+ DEEPSEEK_API_KEY: apiKey,
438
+ // Same tsx tsconfig carry-over as the exec path (see startDshCliRun).
439
+ ...process.env.TSX_TSCONFIG_PATH === undefined
440
+ ? {}
441
+ : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
442
+ ...member === undefined ? {} : member.env,
443
+ }),
444
+ };
445
+ let child;
446
+ try {
447
+ child = this.ctx.subprocess.spawn(spawnSpec);
448
+ }
449
+ catch (error) {
450
+ member?.release();
451
+ this.markChannelBroken();
452
+ throw new LiveChannelUnavailableError(`the serve process failed to spawn: ${thrown(error).message}`);
453
+ }
454
+ const runtime = new LiveRuntime(child, this.timeouts, message => { this.ctx.logger.warn(message); });
455
+ // A blank resolution binds no model at all — record exactly what the argv
456
+ // carries so a later start-model comparison never retires needlessly.
457
+ runtime.boundModel = model === undefined || model === '' ? undefined : model;
458
+ runtime.configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
459
+ runtime.onDead = () => {
460
+ // Delete only OUR registration: a crash-then-respawn can interleave so
461
+ // the dead runtime's late onDead would otherwise evict the NEW
462
+ // runtime's entry and leak it (the registry race the review caught).
463
+ if (this.runtimes.get(key) === runtime)
464
+ this.runtimes.delete(key);
465
+ this.clearIdleTimer(key);
466
+ member?.release();
467
+ };
468
+ const aborted = new Promise((_, reject) => {
469
+ const cancel = () => { reject(new Error('subagent-dsh: run cancelled locally')); };
470
+ const unload = () => { reject(new LiveChannelUnavailableError('the live driver was disposed during spawn')); };
471
+ if (signal.aborted) {
472
+ cancel();
473
+ return;
474
+ }
475
+ if (this.disposeController.signal.aborted) {
476
+ unload();
477
+ return;
478
+ }
479
+ signal.addEventListener('abort', cancel, { once: true });
480
+ this.disposeController.signal.addEventListener('abort', unload, { once: true });
481
+ });
482
+ aborted.catch(() => { });
483
+ try {
484
+ const hello = await Promise.race([
485
+ runtime.request('initialize', {}, this.timeouts.initializeMs),
486
+ aborted,
487
+ ]);
488
+ if (hello.protocolVersion !== LIVE_WIRE_PROTOCOL_VERSION || hello.serverInfo.name !== LIVE_SERVER_NAME) {
489
+ throw new Error('the serve handshake reported an unexpected identity');
490
+ }
491
+ }
492
+ catch (error) {
493
+ await runtime.reclaim();
494
+ // A caller-cancelled handshake says nothing about the channel's health.
495
+ if (signal.aborted)
496
+ throw thrown(error);
497
+ this.markChannelBroken();
498
+ throw new LiveChannelUnavailableError(`the serve handshake failed: ${thrown(error).message}`);
499
+ }
500
+ if (this.disposed) {
501
+ // The plugin unloaded mid-spawn: reclaim instead of leaking the process.
502
+ await runtime.reclaim();
503
+ throw new LiveChannelUnavailableError('the live driver was disposed during spawn');
504
+ }
505
+ // A good handshake heals the breaker (the cooldown retry path).
506
+ this.channelBrokenAt = undefined;
507
+ this.runtimes.set(key, runtime);
508
+ return runtime;
509
+ }
510
+ markChannelBroken() {
511
+ this.channelBrokenAt = Date.now();
512
+ }
513
+ /**
514
+ * Drive one delegation round on the member's resident runtime. Rounds of
515
+ * one member are strictly serialized: the facade's resume lock covers
516
+ * resume-vs-resume only, and a resume racing an in-flight fresh round would
517
+ * otherwise overwrite the runtime's single notification sink and strand
518
+ * the earlier round forever.
519
+ */
520
+ /** Prepare the owned headless agent and validate its adapter without a model turn. */
521
+ async prepare(spec, signal) {
522
+ signal.throwIfAborted();
523
+ if (this.draining || this.disabled)
524
+ throw new LiveChannelUnavailableError('the live driver is unavailable for preparation');
525
+ try {
526
+ provisionDshScope(spec.homeDir, this.config);
527
+ const apiKey = await resolveApiKey(this.ctx, this.config);
528
+ const runtime = await this.ensureRuntime(spec, apiKey, signal);
529
+ await runtime.request('session/prepare', { sessionId: spec.sessionId, resume: spec.resume !== undefined });
530
+ signal.throwIfAborted();
531
+ this.armIdleTimer(spec.sessionId);
532
+ }
533
+ catch (error) {
534
+ await this.reclaim(spec.sessionId);
535
+ throw error;
536
+ }
537
+ }
538
+ async startRound(request, spec) {
539
+ // A draining generation refuses new rounds BEFORE chaining so the
540
+ // provider's exec fallback does not queue behind an in-flight round.
541
+ if (this.draining) {
542
+ throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
543
+ }
544
+ const key = String(spec.childSession.id);
545
+ const previous = this.roundChains.get(key) ?? Promise.resolve();
546
+ const round = previous.catch(() => { }).then(() => this.startRoundLocked(request, spec));
547
+ // The chain holds until the round's result SETTLES (never rejects by the
548
+ // seam contract), not merely until the handle publishes.
549
+ this.roundChains.set(key, round.then(handle => handle.result.catch(() => ({})), () => ({})));
550
+ return round;
551
+ }
552
+ /**
553
+ * The serialized round body. Mirrors the exec run's settlement contract
554
+ * exactly (settleRunResult + turn/end bookkeeping) so the facade, the
555
+ * tool, and the projections cannot tell the difference — except `cancel`
556
+ * is a runtime interrupt and the process survives. Cancellation is honored
557
+ * in EVERY window: a slow spawn/handshake or a slow accept races the abort
558
+ * signal and settles aborted instead of running the turn to completion
559
+ * unwatched.
560
+ */
561
+ async startRoundLocked(request, spec) {
562
+ const task = dshTextTask(request.prompt);
563
+ if (request.signal.aborted) {
564
+ throw new Error('subagent-dsh: request was aborted before the run started');
565
+ }
566
+ // The drain race: the round chained before drain() but dequeued after it.
567
+ // Refuse so the provider falls back to exec instead of reusing a runtime
568
+ // the handoff is about to reclaim.
569
+ if (this.draining) {
570
+ throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
571
+ }
572
+ if (this.disposed) {
573
+ throw new Error('subagent-dsh: the live driver is disposed');
574
+ }
575
+ const turn = spec.resume?.turn ?? 1;
576
+ const childSession = spec.childSession;
577
+ const localAgent = this.ctx.get('localAgent');
578
+ const runAbort = new AbortController();
579
+ let roundSettled = false;
580
+ let runtime;
581
+ /** True once turn/start may have reached the sub-dsh (interrupt becomes meaningful). */
582
+ let acceptSent = false;
583
+ /** True once the parent's turn/start boundary is in the child session. */
584
+ let turnOpened = false;
585
+ let lastText = '';
586
+ let mirroredMessages = 0;
587
+ /** Events of this round that arrived before the boundary opened (same-chunk batching). */
588
+ const bufferedEvents = [];
589
+ let persistQueue = Promise.resolve();
590
+ const persist = () => {
591
+ persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
592
+ this.ctx.logger.warn(`subagent-dsh: live persistence failed: ${thrown(error).message}`);
593
+ });
594
+ };
595
+ const streamPublisher = localAgent?.liveStreams === undefined ? undefined
596
+ : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
597
+ const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`subagent-dsh: live flush failed: ${String(error)}`));
598
+ let streaming;
599
+ const publishStream = () => {
600
+ if (streaming === undefined || streamPublisher === undefined)
601
+ return;
602
+ const blocks = streaming.assembler.interruptedBlocks();
603
+ const receivedAt = streaming.receivedAt ?? Date.now();
604
+ for (const [index, block] of blocks.entries()) {
605
+ if ((block.type === 'reasoning' || block.type === 'text') && block.text !== '')
606
+ streamPublisher.update(streaming.step, block.type === 'reasoning' ? 'think' : 'text', block.text, { itemId: `${streaming.id}:${index}`, receivedAt });
607
+ }
608
+ delete streaming.receivedAt;
609
+ };
610
+ const acceptStream = (frame) => {
611
+ if (streamPublisher === undefined)
612
+ return;
613
+ if (frame.type === 'start') {
614
+ if (streaming !== undefined)
615
+ liveFlush.cancel(streaming.step);
616
+ streaming = { id: String(frame.attemptId), step: frame.step, assembler: new BlockAssembler(), next: 0 };
617
+ }
618
+ else if (frame.type === 'chunk' && streaming?.id === frame.attemptId) {
619
+ if (frame.index < streaming.next)
620
+ return;
621
+ if (frame.index !== streaming.next)
622
+ throw new Error('non-contiguous DSH assistant stream');
623
+ streaming.next++;
624
+ streaming.receivedAt ??= Date.now();
625
+ streaming.assembler.push(frame.chunk);
626
+ liveFlush.schedule(streaming.step, publishStream);
627
+ }
628
+ };
629
+ const requestCancel = () => {
630
+ if (roundSettled || runAbort.signal.aborted)
631
+ return;
632
+ runAbort.abort(new Error('subagent-dsh: run cancelled locally'));
633
+ // The live driver's core win: a graceful runtime interrupt instead of a
634
+ // process kill. Best-effort; local settlement does not wait for it.
635
+ if (acceptSent && runtime !== undefined && !runtime.dead) {
636
+ void runtime.request('turn/interrupt', { sessionId: spec.sessionId }).catch(() => { });
637
+ }
638
+ };
639
+ const onAbort = () => { requestCancel(); };
640
+ // Registered before any await: a cancel during the slow spawn/handshake/
641
+ // accept windows is exactly where the graceful interrupt matters most.
642
+ request.signal.addEventListener('abort', onAbort, { once: true });
643
+ const abortBranch = new Promise((_, reject) => {
644
+ runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-dsh: run cancelled locally')), { once: true });
645
+ });
646
+ const mirrorOne = (event) => {
647
+ const text = mirrorDshLiveEvent(childSession, event);
648
+ if (event.type === 'assistant/message') {
649
+ liveFlush.cancel(event.data.step);
650
+ streamPublisher?.finish(event.data.step);
651
+ if (streaming?.step === event.data.step)
652
+ streaming = undefined;
653
+ }
654
+ if (event.type === 'user/message' || event.type === 'assistant/message') {
655
+ mirroredMessages += 1;
656
+ persist();
657
+ }
658
+ if (text !== undefined) {
659
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
660
+ }
661
+ if (event.type === 'assistant/message' && text !== undefined && text !== '')
662
+ lastText = text;
663
+ };
664
+ const openTurn = () => {
665
+ // The boundary opens after the accept ack (a definitive reject leaves no
666
+ // dangling turn/start); events that arrived in the ack's chunk were
667
+ // buffered and flush now, in wire order, inside the boundary.
668
+ childSession.append('turn/start', { turn });
669
+ turnOpened = true;
670
+ for (const entry of bufferedEvents.splice(0)) {
671
+ if (entry.kind === 'event')
672
+ mirrorOne(entry.event);
673
+ else
674
+ acceptStream(entry.frame);
675
+ }
676
+ };
677
+ let resolveIdle;
678
+ const idle = new Promise((resolve) => { resolveIdle = resolve; });
679
+ const installHandlers = (rt) => {
680
+ rt.onEvent = (sessionId, eventTurn, event) => {
681
+ // Only this round's events: a cancelled round's late unwind (tagged
682
+ // with ITS turn) and out-of-round events (null) never cross.
683
+ if (sessionId !== spec.sessionId || eventTurn !== turn)
684
+ return;
685
+ if (!turnOpened) {
686
+ bufferedEvents.push({ kind: 'event', event });
687
+ return;
688
+ }
689
+ mirrorOne(event);
690
+ };
691
+ rt.onStream = (sessionId, eventTurn, frame) => {
692
+ if (sessionId !== spec.sessionId || eventTurn !== turn || roundSettled)
693
+ return;
694
+ if (!turnOpened)
695
+ bufferedEvents.push({ kind: 'stream', frame });
696
+ else
697
+ acceptStream(frame);
698
+ };
699
+ rt.onIdle = (sessionId, idleTurn, reason) => {
700
+ // Only this round's idle settles it — a cancelled round's unwind idle
701
+ // must not mis-settle the round that is actually running.
702
+ if (sessionId !== spec.sessionId || idleTurn !== turn)
703
+ return;
704
+ resolveIdle({ reason });
705
+ };
706
+ };
707
+ // Provisioning is idempotent; re-running heals a drifted sub-profile.
708
+ provisionDshScope(spec.homeDir, this.config);
709
+ const apiKey = await resolveApiKey(this.ctx, this.config);
710
+ const accepted = (async () => {
711
+ const rt = await this.ensureRuntime(spec, apiKey, request.signal);
712
+ runtime = rt;
713
+ if (runAbort.signal.aborted) {
714
+ // Cancelled while the runtime came up and no turn exists sub-side: the
715
+ // fresh runtime serves nothing — reclaim it, don't warm an abandoned
716
+ // member.
717
+ await this.reclaim(spec.sessionId);
718
+ throw new Error('subagent-dsh: run cancelled locally');
719
+ }
720
+ installHandlers(rt);
721
+ acceptSent = true;
722
+ try {
723
+ await rt.request('turn/start', {
724
+ sessionId: spec.sessionId,
725
+ text: task,
726
+ resume: spec.resume !== undefined,
727
+ turn,
728
+ });
729
+ }
730
+ catch (error) {
731
+ // The accept failed: the runtime's turn state is unknown, so do not
732
+ // reuse it — reclaim and fail the round loudly. The turn never opened
733
+ // parent-side, so no dangling turn/start (an explicit reject means
734
+ // the sub-dsh provably never started the turn; the narrow timeout
735
+ // race is the same class as exec's SIGKILL-mid-turn window).
736
+ if (!runAbort.signal.aborted)
737
+ await this.reclaim(spec.sessionId);
738
+ throw thrown(error);
739
+ }
740
+ openTurn();
741
+ })();
742
+ const attempt = accepted.then(async () => {
743
+ if (runAbort.signal.aborted)
744
+ throw new Error('subagent-dsh: run cancelled locally');
745
+ const rt = runtime;
746
+ const processFailure = rt.child.done.then(outcome => Promise.reject(new Error('subagent-dsh: the live runtime exited mid-round '
747
+ + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
748
+ processFailure.catch(() => { });
749
+ const { reason } = await Promise.race([idle, processFailure]);
750
+ if (reason?.kind === 'aborted') {
751
+ return { output: collectOutput(), stopReason: 'aborted' };
752
+ }
753
+ if (reason?.kind === 'error') {
754
+ throw new Error(`subagent-dsh live: the turn failed: ${reason.error.message}`);
755
+ }
756
+ if (reason?.kind !== 'completed') {
757
+ throw new Error('subagent-dsh live: the turn produced no outcome');
758
+ }
759
+ const output = collectOutput();
760
+ if (output.length === 0) {
761
+ throw new Error('subagent-dsh live: the turn completed but produced no answer');
762
+ }
763
+ return { output, stopReason: 'completed' };
764
+ });
765
+ const collectOutput = () => lastText === '' ? [] : [{ type: 'text', text: lastText }];
766
+ const result = settleRunResult({
767
+ attempt: () => Promise.race([attempt, abortBranch]),
768
+ collectOutput,
769
+ cancelled: () => runAbort.signal.aborted,
770
+ onError: (error, stopReason) => {
771
+ const diagnostics = runtime?.diagnostics ?? '';
772
+ const suffix = diagnostics === '' ? '' : `; ${diagnostics}`;
773
+ this.ctx.logger.warn(`subagent-dsh: live round failed (${stopReason}): ${error.message}${suffix}`);
774
+ },
775
+ signal: request.signal,
776
+ onAbort,
777
+ }).then(async (settled) => {
778
+ roundSettled = true;
779
+ liveFlush.dispose();
780
+ if (turnOpened && streaming !== undefined && streamPublisher !== undefined) {
781
+ const blocks = streaming.assembler.interruptedBlocks();
782
+ if (blocks.length > 0) {
783
+ childSession.append('assistant/message', {
784
+ turn, step: streaming.step,
785
+ message: createAssistantMessage({ content: blocks, source: { provider: 'dsh-local', model: 'unobserved' } }), stream: [],
786
+ interrupted: true,
787
+ }, { surfaceOp: 'append' });
788
+ streamPublisher.finish(streaming.step);
789
+ persist();
790
+ }
791
+ }
792
+ streamPublisher?.dispose();
793
+ // Identical turn/end bookkeeping to the exec path — but only for a turn
794
+ // that actually opened (a pre-accept cancel records no turn at all).
795
+ if (turnOpened) {
796
+ if (settled.stopReason === 'completed') {
797
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
798
+ }
799
+ else if (settled.stopReason === 'aborted') {
800
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
801
+ }
802
+ else {
803
+ childSession.append('turn/end', {
804
+ turn,
805
+ reason: { kind: 'error', error: { message: 'the live round did not complete', code: 'UNKNOWN' } },
806
+ });
807
+ }
808
+ }
809
+ // Settlement clears the round's handlers: a late notification drops at
810
+ // the runtime frame instead of landing in a dead closure.
811
+ if (runtime !== undefined) {
812
+ runtime.onStream = undefined;
813
+ runtime.onEvent = undefined;
814
+ runtime.onIdle = undefined;
815
+ }
816
+ // A completed answer is not a durable turn until its final boundary
817
+ // and any closing stream checkpoints have reached the child store.
818
+ if (turnOpened) {
819
+ persist();
820
+ await persistQueue;
821
+ }
822
+ return settled;
823
+ });
824
+ // Settle reconciliation: the file-based mirror pass (the serve side flushed
825
+ // before its idle notification) dedupes against the live-mirrored prefix
826
+ // and catches anything the wire dropped; the final mirror progress report
827
+ // is authoritative, exactly as in the exec path. Then re-arm the reaper. A
828
+ // round that never opened owns no span — skip the pass entirely (it would
829
+ // re-scan the previous round or, under exec fallback, fight its polling).
830
+ void result.then(async () => {
831
+ try {
832
+ if (turnOpened) {
833
+ await persistQueue;
834
+ if (mirroredMessages > 0)
835
+ persist();
836
+ await persistQueue.catch(() => { });
837
+ const delta = await mirrorDshSession(this.ctx, childSession, spec.homeDir, spec.sessionId);
838
+ if (localAgent !== undefined) {
839
+ for (const text of delta.texts) {
840
+ localAgent.reportRunProgress(childSession.id, { kind: 'delta', text });
841
+ }
842
+ localAgent.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: delta.total });
843
+ // Every settled round reports its observation through the
844
+ // registry's channel (record merge + `settled` event) — the live
845
+ // drive's half of the exec path's settle-mirror report: the
846
+ // sub-dsh session's own model attribution, the round's usage and
847
+ // tool-call accounting, each absent when the round's events name
848
+ // none. Degrades silently on a core predating recordRoundSettled.
849
+ const round = {
850
+ ...delta.observedModel === undefined ? {} : { observedModel: delta.observedModel },
851
+ ...delta.usage === undefined ? {} : { usage: delta.usage },
852
+ ...delta.toolCalls === undefined ? {} : { toolCalls: delta.toolCalls },
853
+ };
854
+ const registry = localAgent;
855
+ registry?.recordRoundSettled?.(childSession.id, round);
856
+ }
857
+ }
858
+ }
859
+ catch (error) {
860
+ this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
861
+ }
862
+ finally {
863
+ this.armIdleTimer(spec.sessionId);
864
+ }
865
+ });
866
+ // Publication gate: hold start() until the turn is accepted (channel
867
+ // errors still throw for the provider's exec fallback), but NEVER hold it
868
+ // hostage to the slow windows — a cancel returns the run immediately and
869
+ // the result settles aborted.
870
+ await Promise.race([accepted, abortBranch.catch(() => undefined)]);
871
+ return subprocessRunHandle({
872
+ id: childSession.id,
873
+ result,
874
+ signal: request.signal,
875
+ onAbort,
876
+ requestCancel,
877
+ // The live teardown contract: NEVER kill the runtime here. The graceful
878
+ // interrupt already went out via requestCancel; wait (bounded) for the
879
+ // turn to converge so the runtime is left consistent for the next round.
880
+ teardown: async () => {
881
+ if (roundSettled)
882
+ return;
883
+ await Promise.race([idle.catch(() => ({ reason: null })), delay(this.timeouts.convergeMs)]);
884
+ },
885
+ });
886
+ }
887
+ }