@khorsheed/dsh-local-agent-codex 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.
@@ -0,0 +1,1279 @@
1
+ /**
2
+ * The codex provider's live driver: one resident `codex app-server --stdio`
3
+ * process per member (child session), driven over the vendor's experimental
4
+ * JSON-RPC app-server wire (protocol-probed against codex-cli 0.144.0's
5
+ * `app-server generate-json-schema`; the harness `subagent-codex` package is
6
+ * the reference implementation). A delegation round is a `turn/start` on the
7
+ * member's resident thread instead of a fresh `codex exec` process, which buys
8
+ * what the process boundary could not: runtime-level graceful interrupt
9
+ * (`turn/interrupt`; the process survives and the thread stays continuable)
10
+ * and push-mode mirroring (`item/completed` + delta notifications instead of
11
+ * folding a polled stdout stream).
12
+ *
13
+ * Lifecycle mirrors the dsh live driver's discipline (M1): lazy spawn with an
14
+ * initialize handshake, one in-flight spawn per member, idle-timeout reclaim
15
+ * (stdin EOF → grace → SIGTERM ladder — the app-server wire has no shutdown
16
+ * method), crash re-spawn with `thread/resume` of the recorded thread id, a
17
+ * channel breaker with cooldown, and `disposeAll` on plugin unload. The wire
18
+ * adapter is self-contained (no new dependency): line framing with a
19
+ * StringDecoder for split UTF-8 tails, request correlation, and unattended
20
+ * auto-answers for the server→client approval requests (the proposal's
21
+ * default permission policy: answer exactly as the unattended exec mode does).
22
+ * @module @khorsheed/dsh-local-agent-codex/live-driver
23
+ */
24
+ import { randomUUID } from 'node:crypto';
25
+ import { StringDecoder } from 'node:string_decoder';
26
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
27
+ import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
28
+ import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
29
+ import { appendCodexTranscriptLine, codexAssistantEvent, codexLineText, DEFAULT_DISPOSE_GRACE_MS, registerCodexMemberRun, textTask, } from './codex-cli-provider.js';
30
+ import { codexRolloutRoundFacts } from './records.js';
31
+ /** Default idle lifetime of an unused resident runtime before reclaim. */
32
+ export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
33
+ /** Default timeout for a wire request acknowledgement. */
34
+ export const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 30_000;
35
+ /** Default timeout for the app-server handshake (a cold boot can be slow). */
36
+ export const DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS = 60_000;
37
+ /** Bounded wait for an interrupted turn to converge while a run disposes. */
38
+ export const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5_000;
39
+ /** How long a tripped channel breaker stays on the exec fallback. */
40
+ export const DEFAULT_LIVE_CHANNEL_RETRY_MS = 5 * 60_000;
41
+ /** Grace between stdin EOF and SIGTERM when reclaiming an app-server. */
42
+ const RECLAIM_EOF_GRACE_MS = 1_000;
43
+ /** Default minimum interval between one streaming item's snapshot messages. */
44
+ export const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
45
+ /** Default minimum text growth between one streaming item's snapshot messages. */
46
+ export const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
47
+ /**
48
+ * The app-server channel could not come up (spawn failure or handshake
49
+ * timeout/mismatch). The provider catches exactly this and falls back to the
50
+ * exec one-shot; any other error is a real round failure.
51
+ */
52
+ export class LiveChannelUnavailableError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'LiveChannelUnavailableError';
56
+ }
57
+ }
58
+ const DEFAULT_TIMEOUTS = {
59
+ initializeMs: DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS,
60
+ requestMs: DEFAULT_LIVE_REQUEST_TIMEOUT_MS,
61
+ convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
62
+ channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS,
63
+ };
64
+ function thrown(value) {
65
+ return value instanceof Error ? value : new Error(String(value));
66
+ }
67
+ /**
68
+ * Auth-shaped failure signatures in codex's error output: the endpoint
69
+ * rejected the credential. Mirrors the exec path's detector
70
+ * (CODEX_AUTH_FAILURE in codex-cli-provider.ts); the live path matches
71
+ * against turn-failure messages, which is where a mid-run 401 surfaces when
72
+ * the process does not exit.
73
+ */
74
+ export const CODEX_LIVE_AUTH_FAILURE = /401 unauthorized|unauthorized|authentication failed|not logged in|access token/i;
75
+ function delay(ms) {
76
+ return new Promise(resolve => setTimeout(resolve, ms));
77
+ }
78
+ /**
79
+ * Map one app-server `item/completed` ThreadItem onto the shared transcript
80
+ * line shape — the live transport's entry into the SAME fold the exec NDJSON
81
+ * parser owns (`foldCodexStreamLine`); both produce `CodexTranscriptLine`s
82
+ * and append through `appendCodexTranscriptLine`, so the two drivers' final
83
+ * child transcripts cannot drift.
84
+ */
85
+ export function codexAppServerItemToLine(item) {
86
+ // The app-server ThreadItem carries its own id; the fold falls back to a
87
+ // synthetic one when it is absent (pairing only needs per-run uniqueness).
88
+ const itemId = typeof item['id'] === 'string' ? item['id'] : undefined;
89
+ switch (item['type']) {
90
+ case 'reasoning': {
91
+ const parts = [item['summary'], item['content']]
92
+ .filter(Array.isArray)
93
+ .flat()
94
+ .filter((entry) => typeof entry === 'string' && entry.trim() !== '');
95
+ return parts.length === 0
96
+ ? undefined
97
+ : { kind: 'think', text: parts.join('\n'), ...itemId === undefined ? {} : { itemId } };
98
+ }
99
+ case 'agentMessage':
100
+ return typeof item['text'] === 'string'
101
+ ? { kind: 'text', text: item['text'], ...itemId === undefined ? {} : { itemId } }
102
+ : undefined;
103
+ case 'commandExecution': {
104
+ const command = typeof item['command'] === 'string' ? item.command : undefined;
105
+ const output = typeof item['aggregatedOutput'] === 'string' && item['aggregatedOutput'].trim() !== ''
106
+ ? item['aggregatedOutput']
107
+ : undefined;
108
+ if (command === undefined && output === undefined)
109
+ return undefined;
110
+ return {
111
+ kind: 'tool',
112
+ id: itemId ?? `codex-live-${randomUUID()}`,
113
+ name: 'Bash',
114
+ ...command === undefined ? {} : { args: command },
115
+ ...output === undefined ? {} : { result: output },
116
+ };
117
+ }
118
+ case 'webSearch':
119
+ return { kind: 'tool', id: itemId ?? `codex-live-${randomUUID()}`, name: 'WebSearch' };
120
+ case 'fileChange': {
121
+ // A patch application: {id, changes: [{path, kind, diff?}], status}.
122
+ // Without this case codex's file edits were invisible in the mirror.
123
+ const changes = Array.isArray(item['changes']) ? item['changes'] : [];
124
+ const args = changes
125
+ .map(change => `${typeof change['kind'] === 'string' ? change['kind'] : 'update'}: ${typeof change['path'] === 'string' ? change['path'] : '?'}`)
126
+ .join('\n');
127
+ const diff = changes
128
+ .map(change => change['diff'])
129
+ .filter((entry) => typeof entry === 'string' && entry.trim() !== '')
130
+ .join('\n');
131
+ return {
132
+ kind: 'tool',
133
+ id: itemId ?? `codex-live-${randomUUID()}`,
134
+ name: 'ApplyPatch',
135
+ ...args === '' ? {} : { args },
136
+ ...diff === '' ? {} : { result: diff },
137
+ };
138
+ }
139
+ case 'dynamicToolCall': {
140
+ // Non-shell tools (e.g. the multi-agent `wait`): {id, tool, arguments, status}.
141
+ const tool = typeof item['tool'] === 'string' ? item.tool : 'dynamic';
142
+ const args = item['arguments'] === undefined || item['arguments'] === null
143
+ ? undefined
144
+ : typeof item['arguments'] === 'string' ? item['arguments'] : JSON.stringify(item['arguments']);
145
+ return {
146
+ kind: 'tool',
147
+ id: itemId ?? `codex-live-${randomUUID()}`,
148
+ name: tool,
149
+ ...args === undefined || args === '' ? {} : { args },
150
+ };
151
+ }
152
+ case 'collabAgentToolCall': {
153
+ const tool = typeof item['tool'] === 'string' ? item.tool : 'collab';
154
+ return { kind: 'tool', id: itemId ?? `codex-live-${randomUUID()}`, name: `collab/${tool}` };
155
+ }
156
+ case 'mcpToolCall': {
157
+ const server = typeof item['server'] === 'string' ? item.server : undefined;
158
+ const tool = typeof item['tool'] === 'string' ? item.tool : undefined;
159
+ return {
160
+ kind: 'tool',
161
+ id: itemId ?? `codex-live-${randomUUID()}`,
162
+ name: server !== undefined && tool !== undefined ? `${server}/${tool}` : tool ?? 'mcp',
163
+ };
164
+ }
165
+ case 'plan':
166
+ return typeof item['text'] === 'string' && item['text'].trim() !== ''
167
+ ? { kind: 'think', text: item['text'] }
168
+ : undefined;
169
+ default:
170
+ return undefined;
171
+ }
172
+ }
173
+ /** Map the app-server's per-turn usage breakdown onto the shared usage contract. */
174
+ function usageFromAppServer(breakdown) {
175
+ const input = Number(breakdown['inputTokens']);
176
+ const cached = Number(breakdown['cachedInputTokens']);
177
+ const output = Number(breakdown['outputTokens']);
178
+ const usage = {
179
+ inputTokens: Number.isFinite(input) && Number.isFinite(cached)
180
+ ? Math.max(0, input - cached)
181
+ : Number.isFinite(input) ? input : 0,
182
+ outputTokens: Number.isFinite(output) ? output : 0,
183
+ };
184
+ if (Number.isFinite(cached) && cached > 0)
185
+ usage.cacheReadTokens = cached;
186
+ return usage;
187
+ }
188
+ /**
189
+ * The app-server wire peer: newline-delimited JSON-RPC over the child's
190
+ * stdio. Self-contained on purpose (no new package dependency): request
191
+ * correlation, StringDecoder framing, and server→client request answering
192
+ * (the approval surface, auto-declined per the unattended policy).
193
+ */
194
+ class CodexWirePeer {
195
+ output;
196
+ warn;
197
+ onServerRequest;
198
+ onNotification;
199
+ requestMs;
200
+ buffer = '';
201
+ decoder = new StringDecoder('utf8');
202
+ nextId = 0;
203
+ pending = new Map();
204
+ closed = false;
205
+ constructor(input, output, warn, onServerRequest, onNotification, requestMs) {
206
+ this.output = output;
207
+ this.warn = warn;
208
+ this.onServerRequest = onServerRequest;
209
+ this.onNotification = onNotification;
210
+ this.requestMs = requestMs;
211
+ input?.on('data', (chunk) => { this.feed(this.decoder.write(chunk)); });
212
+ }
213
+ write(message) {
214
+ this.output?.write(JSON.stringify(message) + '\n');
215
+ }
216
+ request(method, params, timeoutMs) {
217
+ if (this.closed)
218
+ return Promise.reject(new Error('subagent-codex live: the wire is closed'));
219
+ this.nextId += 1;
220
+ const id = `req_${this.nextId}`;
221
+ return new Promise((resolve, reject) => {
222
+ const timer = setTimeout(() => {
223
+ this.pending.delete(id);
224
+ reject(new Error(`subagent-codex live: ${method} timed out`));
225
+ timer.unref();
226
+ }, timeoutMs ?? this.requestMs);
227
+ timer.unref();
228
+ this.pending.set(id, { resolve: result => resolve(result), reject, timer });
229
+ this.write({ jsonrpc: '2.0', id, method, params });
230
+ });
231
+ }
232
+ notify(method, params) {
233
+ this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params });
234
+ }
235
+ /** Detach and reject every outstanding request. */
236
+ close() {
237
+ if (this.closed)
238
+ return;
239
+ this.closed = true;
240
+ for (const request of this.pending.values()) {
241
+ clearTimeout(request.timer);
242
+ request.reject(new Error('subagent-codex live: the wire closed'));
243
+ }
244
+ this.pending.clear();
245
+ }
246
+ feed(text) {
247
+ this.buffer += text;
248
+ let index = this.buffer.indexOf('\n');
249
+ while (index >= 0) {
250
+ const line = this.buffer.slice(0, index);
251
+ this.buffer = this.buffer.slice(index + 1);
252
+ index = this.buffer.indexOf('\n');
253
+ if (line.trim() !== '')
254
+ this.dispatchLine(line);
255
+ }
256
+ }
257
+ /** Handle one wire line; a malformed or hostile line never escapes this frame. */
258
+ dispatchLine(line) {
259
+ let message;
260
+ try {
261
+ message = JSON.parse(line);
262
+ }
263
+ catch {
264
+ this.warn('subagent-codex live: ignored a malformed wire line');
265
+ return;
266
+ }
267
+ try {
268
+ const hasId = typeof message.id === 'string' || typeof message.id === 'number';
269
+ if (hasId && typeof message.method === 'string') {
270
+ // A server→client REQUEST (the approval surface): answer unattended.
271
+ const id = message.id;
272
+ try {
273
+ const result = this.onServerRequest(message.method, (message.params ?? {}));
274
+ this.write({ jsonrpc: '2.0', id: id, result: result });
275
+ }
276
+ catch (error) {
277
+ this.write({
278
+ jsonrpc: '2.0', id: id,
279
+ error: { code: -32603, message: thrown(error).message },
280
+ });
281
+ }
282
+ return;
283
+ }
284
+ if (hasId && ('result' in message || 'error' in message)) {
285
+ const request = this.pending.get(String(message.id));
286
+ if (request === undefined)
287
+ return;
288
+ this.pending.delete(String(message.id));
289
+ clearTimeout(request.timer);
290
+ const wireError = message.error;
291
+ if (wireError !== undefined && wireError !== null) {
292
+ request.reject(new Error(`subagent-codex live: ${wireError.message ?? 'wire error'}`));
293
+ }
294
+ else {
295
+ request.resolve(message.result);
296
+ }
297
+ return;
298
+ }
299
+ if (typeof message.method === 'string') {
300
+ this.onNotification(message.method, (message.params ?? {}));
301
+ }
302
+ }
303
+ catch (error) {
304
+ this.warn(`subagent-codex live: wire line handler failed: ${thrown(error).message}`);
305
+ }
306
+ }
307
+ }
308
+ /** One resident app-server process: the wire peer, the loaded thread's id, and
309
+ * the reclaim ladder (stdin EOF → grace → SIGTERM → grace → SIGKILL).
310
+ */
311
+ class CodexLiveRuntime {
312
+ child;
313
+ dead = false;
314
+ reclaimed = false;
315
+ peer;
316
+ /** The member's loaded thread; assigned by the first round's thread/start or thread/resume. */
317
+ threadId;
318
+ /**
319
+ * The model this process bound at spawn (the `-c model=…` override), or
320
+ * undefined when it spawned with no model flag at all. A round whose start
321
+ * model differs retires the runtime instead of silently running the old one.
322
+ */
323
+ configurationKey;
324
+ boundModel;
325
+ /** Turn ids of settled/interrupted rounds — their late notifications are tagged out. */
326
+ retiredTurnIds = new Set();
327
+ /** The active round's notification sink; installed per round, cleared at settle. */
328
+ onWireNotification;
329
+ onDead;
330
+ stderrTail = '';
331
+ stderrDecoder = new StringDecoder('utf8');
332
+ constructor(child, onServerRequest, warn, requestMs) {
333
+ this.child = child;
334
+ this.peer = new CodexWirePeer(child.stdout, child.stdin, warn, onServerRequest, (method, params) => { this.onWireNotification?.(method, params); }, requestMs);
335
+ child.stderr?.on('data', (chunk) => {
336
+ this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
337
+ });
338
+ void child.done.then(() => { this.markDead(); }, () => { this.markDead(); });
339
+ }
340
+ get diagnostics() {
341
+ return this.stderrTail.trim();
342
+ }
343
+ markDead() {
344
+ if (this.dead)
345
+ return;
346
+ this.dead = true;
347
+ this.peer.close();
348
+ this.onDead?.();
349
+ }
350
+ /** Graceful teardown: close the wire, EOF stdin, grace, then the terminate ladder. */
351
+ async reclaim() {
352
+ if (this.reclaimed)
353
+ return;
354
+ this.reclaimed = true;
355
+ this.peer.close();
356
+ if (!this.dead) {
357
+ this.child.stdin?.end();
358
+ await Promise.race([this.child.done.catch(() => { }), delay(RECLAIM_EOF_GRACE_MS)]);
359
+ }
360
+ if (!this.dead) {
361
+ // Host 0.1.5 hides the child pid; terminate() is an idempotent no-op
362
+ // once the managed range is gone.
363
+ this.child.terminate();
364
+ await this.child.waitForExit();
365
+ }
366
+ await this.child.done.catch(() => { });
367
+ this.markDead();
368
+ }
369
+ }
370
+ /** Unattended approval answer: prefer cancel, else decline (harness-proven tables). */
371
+ function unattendedDecision(params) {
372
+ const available = params['availableDecisions'];
373
+ if (Array.isArray(available)) {
374
+ if (available.includes('cancel'))
375
+ return 'cancel';
376
+ if (available.includes('decline'))
377
+ return 'decline';
378
+ }
379
+ return 'decline';
380
+ }
381
+ /**
382
+ * The codex live driver owns every resident app-server runtime of this
383
+ * provider generation. Created only when `live: true`; disposed with the
384
+ * plugin.
385
+ */
386
+ export class CodexLiveDriver {
387
+ ctx;
388
+ config;
389
+ timeouts;
390
+ runtimes = new Map();
391
+ /** In-flight spawns by member: concurrent rounds share one, disposeAll waits them out. */
392
+ ensuring = new Map();
393
+ idleTimers = new Map();
394
+ /** Per-member round serialization (the resume lock covers resume-vs-resume only). */
395
+ roundChains = new Map();
396
+ channelBrokenAt;
397
+ disposed = false;
398
+ /**
399
+ * Set by drain() (a settings-driven generation handoff): new rounds are
400
+ * refused so the provider falls back to exec, while in-flight rounds finish
401
+ * on their runtime undisturbed. Unlike `disposed`, the driver still serves
402
+ * what it already accepted.
403
+ */
404
+ draining = false;
405
+ disposeController = new AbortController();
406
+ constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
407
+ this.ctx = ctx;
408
+ this.config = config;
409
+ this.timeouts = timeouts;
410
+ }
411
+ /** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
412
+ get disabled() {
413
+ return this.channelBrokenAt !== undefined
414
+ && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? DEFAULT_LIVE_CHANNEL_RETRY_MS);
415
+ }
416
+ /** Count of resident runtimes currently registered (zombie accounting in tests). */
417
+ get liveCount() {
418
+ return this.runtimes.size;
419
+ }
420
+ clearIdleTimer(key) {
421
+ const timer = this.idleTimers.get(key);
422
+ if (timer !== undefined)
423
+ clearTimeout(timer);
424
+ this.idleTimers.delete(key);
425
+ }
426
+ armIdleTimer(key) {
427
+ this.clearIdleTimer(key);
428
+ const idleMs = this.config.liveIdleMs ?? DEFAULT_LIVE_IDLE_MS;
429
+ const timer = setTimeout(() => { void this.reclaim(key); }, idleMs);
430
+ timer.unref();
431
+ this.idleTimers.set(key, timer);
432
+ }
433
+ async reclaim(key) {
434
+ const runtime = this.runtimes.get(key);
435
+ this.clearIdleTimer(key);
436
+ if (runtime === undefined)
437
+ return;
438
+ this.runtimes.delete(key);
439
+ await runtime.reclaim();
440
+ }
441
+ /** Reclaim every runtime (plugin unload); no process survives the profile. */
442
+ async disposeAll() {
443
+ this.disposed = true;
444
+ this.disposeController.abort();
445
+ await Promise.all([...this.ensuring.values()].map(pending => pending.catch(() => undefined)));
446
+ for (const key of [...this.runtimes.keys()]) {
447
+ await this.reclaim(key);
448
+ }
449
+ }
450
+ /**
451
+ * True while the member's runtime exists or is being spawned. The settings
452
+ * controller gates a new generation on this: a fresh driver must not serve
453
+ * a member whose retiring generation still hosts the (same) codex thread.
454
+ */
455
+ hasRuntime(key) {
456
+ return this.runtimes.has(key) || this.ensuring.has(key);
457
+ }
458
+ /** @deprecated Compatibility no-op: live output is always incremental. */
459
+ setLiveMirrorGranularity(_granularity) { }
460
+ /**
461
+ * Drain for a settings-driven generation handoff: refuse new rounds (the
462
+ * provider's catch falls back to exec), let every in-flight round finish on
463
+ * its runtime, then reclaim. Resolves when no runtime or spawn remains.
464
+ * Unlike disposeAll, in-flight work is never interrupted.
465
+ */
466
+ async drain() {
467
+ this.draining = true;
468
+ // Snapshot: rounds chain synchronously at startRound, so every accepted
469
+ // round is already in roundChains; anything later is refused.
470
+ const keys = new Set([...this.runtimes.keys(), ...this.ensuring.keys(), ...this.roundChains.keys()]);
471
+ await Promise.all([...keys].map(async (key) => {
472
+ await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => undefined);
473
+ await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => undefined);
474
+ await this.reclaim(key);
475
+ }));
476
+ }
477
+ /**
478
+ * The model the member's live runtime bound at spawn: the identifier, or
479
+ * undefined when the runtime spawned with no model flag. Null when the
480
+ * member has no live runtime in this generation — the broker's switch check
481
+ * distinguishes "no runtime to retire" from "runtime bound to no model".
482
+ */
483
+ boundModelOf(key) {
484
+ const runtime = this.runtimes.get(key);
485
+ if (runtime === undefined || runtime.dead)
486
+ return null;
487
+ return runtime.boundModel;
488
+ }
489
+ /**
490
+ * Retire the member's resident runtime so the NEXT round respawns (the CLI
491
+ * session itself carries over via thread/resume). The composer's model
492
+ * switch calls this after storing the new override; the caller guarantees
493
+ * no round is in flight for the member.
494
+ */
495
+ async retireRuntime(key) {
496
+ await this.reclaim(key);
497
+ }
498
+ /** The member's runtime, spawning it (once per member at a time) when absent or dead. */
499
+ async ensureRuntime(spec, signal) {
500
+ const key = String(spec.childSession.id);
501
+ const startModel = spec.startModel?.trim();
502
+ const existing = this.runtimes.get(key);
503
+ if (existing !== undefined && !existing.dead) {
504
+ // A round that names its own start model never runs on a runtime bound
505
+ // to a different one: retire so the respawn binds the asked-for model
506
+ // (the codex thread resumes — only the process is replaced).
507
+ if ((spec.configuration !== undefined && existing.configurationKey !== JSON.stringify(spec.configuration))
508
+ || (spec.configuration === undefined && existing.configurationKey !== undefined)
509
+ || (startModel !== undefined && startModel !== '' && existing.boundModel !== startModel)) {
510
+ await this.reclaim(key);
511
+ }
512
+ else {
513
+ this.clearIdleTimer(key);
514
+ return existing;
515
+ }
516
+ }
517
+ else if (existing !== undefined) {
518
+ this.runtimes.delete(key);
519
+ }
520
+ const pending = this.ensuring.get(key);
521
+ if (pending !== undefined)
522
+ return pending;
523
+ const spawn = this.spawnRuntime(spec, signal)
524
+ .finally(() => { this.ensuring.delete(key); });
525
+ this.ensuring.set(key, spawn);
526
+ return spawn;
527
+ }
528
+ /**
529
+ * Spawn the member's resident app-server and prove the wire with a
530
+ * handshake. Failure trips the channel breaker; a cancellation mid-spawn
531
+ * reclaims the half-started process without touching the breaker.
532
+ */
533
+ async spawnRuntime(spec, signal) {
534
+ const key = String(spec.childSession.id);
535
+ if (this.disposed) {
536
+ throw new LiveChannelUnavailableError('the live driver is disposed');
537
+ }
538
+ // The member bridge rides a per-process `-c` override, exactly as in the
539
+ // exec path; the resident process carries one member, so its token lives
540
+ // with the process (released on reclaim/crash), not per round.
541
+ const member = registerCodexMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
542
+ // `codex app-server` has no `-m` (verified against codex-cli 0.144.0), so
543
+ // the model rides the process-level `-c` override the CLI documents for
544
+ // exactly this — the same mechanism the member bridge already uses. The
545
+ // member's START model (a delegation-level request or the session-level
546
+ // override, resolved by the provider) wins; otherwise the spawn resolver
547
+ // reads the member's configured model (override → settings). The resident
548
+ // process carries one member, so reading here binds the model for that
549
+ // member's runtime; a later switch reaches it by retiring the runtime
550
+ // first (idle reclaim, crash, a live toggle, or the composer's model
551
+ // picker).
552
+ const startModel = spec.startModel?.trim();
553
+ const model = spec.configuration !== undefined ? spec.configuration.model
554
+ : startModel !== undefined && startModel !== '' ? startModel : this.config.model?.(key)?.trim();
555
+ const spawnSpec = {
556
+ argv: [
557
+ 'codex', 'app-server',
558
+ ...member === undefined ? [] : ['-c', member.configOverride],
559
+ ...model === undefined || model === '' ? [] : ['-c', `model=${JSON.stringify(model)}`],
560
+ ...spec.configuration?.effort === undefined ? [] : ['-c', `model_reasoning_effort=${JSON.stringify(spec.configuration.effort)}`],
561
+ '--stdio',
562
+ ],
563
+ cwd: spec.cwd,
564
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
565
+ graceMs: DEFAULT_DISPOSE_GRACE_MS,
566
+ env: delegationEnv({ CODEX_HOME: spec.homeDir }),
567
+ };
568
+ let child;
569
+ try {
570
+ child = this.ctx.subprocess.spawn(spawnSpec);
571
+ }
572
+ catch (error) {
573
+ member?.release();
574
+ this.markChannelBroken();
575
+ throw new LiveChannelUnavailableError(`the app-server failed to spawn: ${thrown(error).message}`);
576
+ }
577
+ const runtime = new CodexLiveRuntime(child, (method, params) => this.answerServerRequest(method, params), message => { this.ctx.logger.warn(message); }, this.timeouts.requestMs);
578
+ // A blank resolution binds no model at all — record exactly what the argv
579
+ // carries so a later start-model comparison never retires needlessly.
580
+ runtime.boundModel = model === undefined || model === '' ? undefined : model;
581
+ runtime.configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
582
+ runtime.onDead = () => {
583
+ // Delete only OUR registration (crash-then-respawn interleave safety).
584
+ if (this.runtimes.get(key) === runtime)
585
+ this.runtimes.delete(key);
586
+ this.clearIdleTimer(key);
587
+ member?.release();
588
+ };
589
+ const aborted = new Promise((_, reject) => {
590
+ const cancel = () => { reject(new Error('subagent-codex: run cancelled locally')); };
591
+ const unload = () => { reject(new LiveChannelUnavailableError('the live driver was disposed during spawn')); };
592
+ if (signal.aborted) {
593
+ cancel();
594
+ return;
595
+ }
596
+ if (this.disposeController.signal.aborted) {
597
+ unload();
598
+ return;
599
+ }
600
+ signal.addEventListener('abort', cancel, { once: true });
601
+ this.disposeController.signal.addEventListener('abort', unload, { once: true });
602
+ });
603
+ aborted.catch(() => { });
604
+ try {
605
+ await Promise.race([
606
+ runtime.peer.request('initialize', {
607
+ clientInfo: { name: 'dsh-local-agent-codex', title: 'dsh local-agent codex live driver', version: '0.1.0' },
608
+ capabilities: { experimentalApi: false, requestAttestation: false },
609
+ }, this.timeouts.initializeMs),
610
+ aborted,
611
+ ]);
612
+ runtime.peer.notify('initialized');
613
+ }
614
+ catch (error) {
615
+ await runtime.reclaim();
616
+ if (signal.aborted)
617
+ throw thrown(error);
618
+ this.markChannelBroken();
619
+ throw new LiveChannelUnavailableError(`the app-server handshake failed: ${thrown(error).message}`);
620
+ }
621
+ if (this.disposed) {
622
+ await runtime.reclaim();
623
+ throw new LiveChannelUnavailableError('the live driver was disposed during spawn');
624
+ }
625
+ this.channelBrokenAt = undefined;
626
+ this.runtimes.set(key, runtime);
627
+ return runtime;
628
+ }
629
+ markChannelBroken() {
630
+ this.channelBrokenAt = Date.now();
631
+ }
632
+ /**
633
+ * Report an auth-shaped round failure to the family registry's auth-failure
634
+ * mark (the exec path's post-exit 401 detection ported to a process that
635
+ * never exits: the match runs against the settled error instead). Degrades
636
+ * silently on a core predating reportAuthFailure.
637
+ */
638
+ reportAuthIfShaped(error) {
639
+ if (!CODEX_LIVE_AUTH_FAILURE.test(error.message))
640
+ return;
641
+ // Duck-typed: the method lands with the auth-truthfulness core; older
642
+ // cores simply skip the mark.
643
+ const registry = this.ctx.localAgent;
644
+ if (typeof registry.reportAuthFailure === 'function') {
645
+ registry.reportAuthFailure('codex', error.message.split('\n').find(line => line.trim() !== '') ?? error.message);
646
+ }
647
+ }
648
+ /**
649
+ * Auto-answer the app-server's approval/elicitation requests, matching the
650
+ * unattended exec behavior (and the harness reference implementation's
651
+ * tables): the live driver never grants interactive approval. This is the
652
+ * proposal's default permission policy; the relay hook for a human
653
+ * approval UI is a later increment.
654
+ */
655
+ answerServerRequest(method, params) {
656
+ switch (method) {
657
+ case 'item/commandExecution/requestApproval':
658
+ case 'item/fileChange/requestApproval':
659
+ return { decision: unattendedDecision(params) };
660
+ case 'item/permissions/requestApproval':
661
+ return { permissions: {}, scope: 'turn' };
662
+ case 'item/tool/requestUserInput':
663
+ return { answers: {} };
664
+ case 'mcpServer/elicitation/request':
665
+ return { action: 'decline', content: null, _meta: null };
666
+ default:
667
+ throw new Error(`subagent-codex live: unsupported app-server request ${method}`);
668
+ }
669
+ }
670
+ /**
671
+ * Drive one delegation round on the member's resident app-server. Mirrors
672
+ * the exec run's settlement contract exactly so the facade, the tool, and
673
+ * the projections cannot tell the difference — except `cancel` is a runtime
674
+ * interrupt and the process survives. Cancellation is honored in every
675
+ * window (the M1 acceptance lesson): the abort listener registers before
676
+ * any await, the spawn/handshake races it, and a pre-accept cancel reclaims
677
+ * the fresh runtime instead of letting the turn run unwatched.
678
+ */
679
+ /** Create/resume the native thread without starting a model turn. */
680
+ async prepare(spec, signal) {
681
+ signal.throwIfAborted();
682
+ if (this.draining || this.disabled)
683
+ throw new LiveChannelUnavailableError('the live driver is unavailable for preparation');
684
+ try {
685
+ const runtime = await this.ensureRuntime(spec, signal);
686
+ await this.ensureThread(runtime, spec);
687
+ signal.throwIfAborted();
688
+ this.armIdleTimer(String(spec.childSession.id));
689
+ }
690
+ catch (error) {
691
+ await this.reclaim(String(spec.childSession.id));
692
+ throw error;
693
+ }
694
+ }
695
+ async ensureThread(rt, spec) {
696
+ if (rt.threadId === undefined) {
697
+ const permissionParams = {
698
+ approvalPolicy: 'never',
699
+ sandbox: this.config.sandbox ?? 'workspace-write',
700
+ };
701
+ if (spec.resume === undefined) {
702
+ const response = await rt.peer.request('thread/start', {
703
+ cwd: spec.cwd,
704
+ ephemeral: false,
705
+ ...permissionParams,
706
+ });
707
+ if (typeof response?.thread?.id !== 'string') {
708
+ throw new Error('subagent-codex live: thread/start returned no thread id');
709
+ }
710
+ rt.threadId = response.thread.id;
711
+ }
712
+ else {
713
+ const response = await rt.peer.request('thread/resume', {
714
+ threadId: spec.resume.cliSessionId,
715
+ cwd: spec.cwd,
716
+ ...permissionParams,
717
+ });
718
+ if (typeof response?.thread?.id !== 'string') {
719
+ throw new Error('subagent-codex live: thread/resume returned no thread');
720
+ }
721
+ rt.threadId = response.thread.id;
722
+ }
723
+ }
724
+ if (spec.resume === undefined && rt.threadId !== undefined)
725
+ spec.onThreadId?.(rt.threadId);
726
+ }
727
+ async startRound(request, spec) {
728
+ // A draining generation refuses new rounds BEFORE chaining so the
729
+ // provider's exec fallback does not queue behind an in-flight round.
730
+ if (this.draining) {
731
+ throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
732
+ }
733
+ const key = String(spec.childSession.id);
734
+ const previous = this.roundChains.get(key) ?? Promise.resolve();
735
+ const round = previous.catch(() => { }).then(() => this.startRoundLocked(request, spec));
736
+ this.roundChains.set(key, round.then(handle => handle.result.catch(() => ({})), () => ({})));
737
+ return round;
738
+ }
739
+ /** The serialized round body. */
740
+ async startRoundLocked(request, spec) {
741
+ const task = textTask(request.prompt);
742
+ if (request.signal.aborted) {
743
+ throw new Error('subagent-codex: request was aborted before the run started');
744
+ }
745
+ // The drain race: the round chained before drain() but dequeued after it.
746
+ // Refuse so the provider falls back to exec instead of reusing a runtime
747
+ // the handoff is about to reclaim.
748
+ if (this.draining) {
749
+ throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
750
+ }
751
+ if (this.disposed) {
752
+ throw new Error('subagent-codex: the live driver is disposed');
753
+ }
754
+ const turn = spec.resume?.turn ?? 1;
755
+ const childSession = spec.childSession;
756
+ const localAgent = this.ctx.get('localAgent');
757
+ /** The round's start moment, anchoring the settle read-back's rollout time window. */
758
+ const startedAtMs = Date.now();
759
+ const runAbort = new AbortController();
760
+ let roundSettled = false;
761
+ let runtime;
762
+ /** The in-flight turn's server-assigned id; interrupt needs it. */
763
+ let activeTurnId;
764
+ /** Pre-ack turn id observed via turn/started (notifications can precede the response). */
765
+ let pendingTurnId;
766
+ let turnOpened = false;
767
+ let lastText = '';
768
+ /** Final-answer selection per the harness wire: final_answer phase beats unphased. */
769
+ let lastFinalAnswer;
770
+ let lastUnphasedAnswer;
771
+ /** The round's folded transcript (the exec fold's line shape). */
772
+ const lines = [];
773
+ let mirrored = 0;
774
+ let usage;
775
+ /** Whether a folded line already carries the round's usage (no backfill event exists). */
776
+ let usageCarried = false;
777
+ /**
778
+ * The step ledger: `reservedSteps` holds every step a stream reservation
779
+ * consumed (in increasing order — permanently, whether the stream later
780
+ * completes or stays an orphan snapshot), and a sequential line folds at
781
+ * its index shifted past every reservation before it. A tool completed
782
+ * mid-stream therefore folds ABOVE the stream's step and the projection
783
+ * renders the round's chronological order.
784
+ */
785
+ const reservedSteps = [];
786
+ /**
787
+ * The streaming items seen this round by item id:
788
+ * deltas accumulate into throttled snapshot assistant/messages appended
789
+ * at the item's reserved (turn, step) — the host folds repeated settles
790
+ * at one coordinate into one live-updating chat node, which is the only
791
+ * streaming channel left after 0.1.5 retired the durable per-chunk event.
792
+ * The item's completion folds at the same step and finalizes it; an entry
793
+ * whose completion never arrives is force-finalized at settle.
794
+ */
795
+ const streams = new Map();
796
+ /** The item deltas currently accumulate into (items stream sequentially). */
797
+ let activeStream;
798
+ /** Items/completions that arrived before the turn id was known. */
799
+ const earlyNotifications = [];
800
+ let persistQueue = Promise.resolve();
801
+ const persist = () => {
802
+ persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
803
+ this.ctx.logger.warn(`subagent-codex: live persistence failed: ${thrown(error).message}`);
804
+ });
805
+ };
806
+ const requestCancel = () => {
807
+ if (roundSettled || runAbort.signal.aborted)
808
+ return;
809
+ runAbort.abort(new Error('subagent-codex: run cancelled locally'));
810
+ // The live driver's core win: a graceful runtime interrupt instead of a
811
+ // process kill. Best-effort; local settlement does not wait for it. The
812
+ // turn id comes from the accept response — or from the turn/started
813
+ // notification, which can land FIRST, so the accept window is covered
814
+ // too (the B1 review finding).
815
+ const turnId = activeTurnId ?? pendingTurnId;
816
+ if (turnId !== undefined && runtime?.threadId !== undefined && !runtime.dead) {
817
+ void runtime.peer.request('turn/interrupt', {
818
+ threadId: runtime.threadId,
819
+ turnId,
820
+ }).catch(() => { });
821
+ }
822
+ };
823
+ const onAbort = () => { requestCancel(); };
824
+ request.signal.addEventListener('abort', onAbort, { once: true });
825
+ const abortBranch = new Promise((_, reject) => {
826
+ runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-codex: run cancelled locally')), { once: true });
827
+ });
828
+ const collectOutput = () => {
829
+ const partial = activeStream === undefined ? '' : streams.get(activeStream)?.text ?? '';
830
+ const selected = lastFinalAnswer ?? lastUnphasedAnswer ?? (lastText !== '' ? lastText : partial === '' ? undefined : partial);
831
+ return selected === undefined || selected.trim() === '' ? [] : [{ type: 'text', text: selected }];
832
+ };
833
+ /**
834
+ * Append one snapshot of a streaming item at its reserved (turn, step).
835
+ * Throttled per item by interval and growth unless `force`; the forced
836
+ * final snapshot carries `interrupted` (a cancelled turn reads 已停止
837
+ * legitimately) and, when `withUsage` and no folded line carried it, the
838
+ * round's usage.
839
+ */
840
+ const streamPublisher = localAgent?.liveStreams === undefined ? undefined
841
+ : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
842
+ const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
843
+ const pendingStreamArrival = new Map();
844
+ const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
845
+ if (stream.text.trim() === '')
846
+ return;
847
+ const now = Date.now();
848
+ if (!force) {
849
+ if (!pendingStreamArrival.has(stream.step))
850
+ pendingStreamArrival.set(stream.step, now);
851
+ liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
852
+ return;
853
+ }
854
+ liveFlush.cancel(stream.step);
855
+ const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
856
+ pendingStreamArrival.delete(stream.step);
857
+ if (streamPublisher !== undefined && !final) {
858
+ if (!stream.opened) {
859
+ childSession.append('step/start', { turn, step: stream.step });
860
+ stream.opened = true;
861
+ }
862
+ streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
863
+ stream.lastSnapshotAt = now;
864
+ stream.lastSnapshotLen = stream.text.length;
865
+ return;
866
+ }
867
+ if (!stream.opened) {
868
+ childSession.append('step/start', { turn, step: stream.step });
869
+ stream.opened = true;
870
+ }
871
+ const attachUsage = withUsage && !usageCarried && usage !== undefined;
872
+ childSession.append('assistant/message', {
873
+ turn,
874
+ step: stream.step,
875
+ message: codexAssistantEvent([
876
+ stream.kind === 'think'
877
+ ? { type: 'reasoning', text: stream.text }
878
+ : { type: 'text', text: stream.text },
879
+ ]),
880
+ stream: [],
881
+ ...withUsage && !usageCarried && usage !== undefined ? { usage } : {},
882
+ ...interrupted ? { interrupted: true } : {},
883
+ }, { surfaceOp: 'append' });
884
+ if (attachUsage)
885
+ usageCarried = true;
886
+ stream.lastSnapshotAt = now;
887
+ stream.lastSnapshotLen = stream.text.length;
888
+ persist();
889
+ };
890
+ /**
891
+ * Reserve the step one new streaming item will occupy — past every
892
+ * completed line (folded or held back) and every earlier reservation. A
893
+ * different item's deltas force one final snapshot of the current stream
894
+ * (its completion still folds at its own reserved step when it lands).
895
+ */
896
+ const reserveStream = (itemId, kind) => {
897
+ if (activeStream !== undefined && activeStream !== itemId) {
898
+ const previous = streams.get(activeStream);
899
+ // A freshness snapshot on the item switch, skipped when the text has
900
+ // not grown since the last one (the settle force always lands).
901
+ if (previous !== undefined && previous.text.length !== previous.lastSnapshotLen) {
902
+ appendStreamSnapshot(previous, true, false);
903
+ }
904
+ activeStream = undefined;
905
+ }
906
+ let stream = streams.get(itemId);
907
+ if (stream === undefined) {
908
+ const step = lines.length + reservedSteps.length + 1;
909
+ reservedSteps.push(step);
910
+ stream = { itemId, kind, step, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
911
+ streams.set(itemId, stream);
912
+ }
913
+ activeStream = itemId;
914
+ };
915
+ /** The sequential fold step for `lines[index]`: its position shifted past every reservation before it. */
916
+ const foldStep = (index) => {
917
+ let step = index + 1;
918
+ for (const reserved of reservedSteps) {
919
+ if (reserved <= step)
920
+ step += 1;
921
+ else
922
+ break;
923
+ }
924
+ return step;
925
+ };
926
+ /** Mirror folded lines [mirrored, upto); the last line is held back until completion. */
927
+ const mirrorUpTo = (upto, withUsage) => {
928
+ // The usage rides the last NON-tool line (tool events carry no usage
929
+ // slot, and a kill mid-command ends the transcript with a tool line).
930
+ // A carrier mirrored in an earlier flush (before the usage was knowable)
931
+ // loses the accounting — host 0.1.5 has no usage-backfill event.
932
+ let usageIndex = -1;
933
+ if (withUsage) {
934
+ for (let index = 0; index < lines.length; index += 1) {
935
+ if (lines[index]?.kind !== 'tool')
936
+ usageIndex = index;
937
+ }
938
+ }
939
+ for (let index = mirrored; index < upto; index += 1) {
940
+ const line = lines[index];
941
+ if (line === undefined)
942
+ continue;
943
+ const lineUsage = withUsage && index === usageIndex ? usage : undefined;
944
+ if (lineUsage !== undefined)
945
+ usageCarried = true;
946
+ const itemId = line.kind === 'tool' ? undefined : line.itemId;
947
+ const stream = itemId === undefined ? undefined : streams.get(itemId);
948
+ if (itemId !== undefined && stream !== undefined) {
949
+ // A streamed item folds at its reserved step, finalizing the
950
+ // snapshots: the step opens only if no snapshot ever landed (a
951
+ // fast item that stayed under the throttle), and closes here.
952
+ liveFlush.cancel(stream.step);
953
+ streams.delete(itemId);
954
+ if (activeStream === itemId)
955
+ activeStream = undefined;
956
+ if (!stream.opened)
957
+ childSession.append('step/start', { turn, step: stream.step });
958
+ const blocks = stream.kind === 'think'
959
+ ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
960
+ : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
961
+ childSession.append('assistant/message', {
962
+ turn,
963
+ step: stream.step,
964
+ message: codexAssistantEvent(blocks),
965
+ stream: [],
966
+ ...lineUsage === undefined ? {} : { usage: lineUsage },
967
+ }, { surfaceOp: 'append' });
968
+ streamPublisher?.finish(stream.step);
969
+ childSession.append('step/end', { turn, step: stream.step });
970
+ }
971
+ else {
972
+ appendCodexTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
973
+ }
974
+ mirrored = index + 1;
975
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: codexLineText(line) });
976
+ }
977
+ if (upto > 0)
978
+ persist();
979
+ };
980
+ let resolveCompletion;
981
+ const completion = new Promise((resolve) => { resolveCompletion = resolve; });
982
+ const openTurn = () => {
983
+ // The boundary opens after the accept ack (a definitive reject leaves no
984
+ // dangling turn/start); the prompt and any early items flush inside it.
985
+ childSession.append('turn/start', { turn });
986
+ childSession.append('user/message', createUserMessage({
987
+ content: [{ type: 'text', text: task }],
988
+ source: { kind: 'user' },
989
+ }), { surfaceOp: 'append' });
990
+ turnOpened = true;
991
+ persist();
992
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
993
+ for (const early of earlyNotifications.splice(0)) {
994
+ dispatchTurnNotification(early.method, early.params);
995
+ }
996
+ };
997
+ /** Process one turn-scoped notification for the round's own turn id. */
998
+ const dispatchTurnNotification = (method, params) => {
999
+ if (method === 'item/completed') {
1000
+ const item = params['item'];
1001
+ const line = codexAppServerItemToLine(item);
1002
+ if (item['type'] === 'agentMessage' && typeof item['text'] === 'string') {
1003
+ const phase = item['phase'];
1004
+ if (phase === 'final_answer')
1005
+ lastFinalAnswer = item['text'];
1006
+ else if (phase === null || phase === undefined)
1007
+ lastUnphasedAnswer = item['text'];
1008
+ lastText = item['text'];
1009
+ }
1010
+ if (line === undefined)
1011
+ return;
1012
+ lines.push(line);
1013
+ // Hold back the volatile last line until turn/completed (it is the
1014
+ // round's usage carrier) — the exec live mirror's exact rule.
1015
+ mirrorUpTo(lines.length - 1, false);
1016
+ return;
1017
+ }
1018
+ if (method === 'item/agentMessage/delta' || method === 'item/reasoning/textDelta') {
1019
+ if (typeof params['delta'] !== 'string')
1020
+ return;
1021
+ const text = params['delta'];
1022
+ if (text === '')
1023
+ return;
1024
+ const reasoning = method === 'item/reasoning/textDelta';
1025
+ // The item id pairs the stream with its completion (the fold then
1026
+ // lands at the reserved step); a delta without one shares a
1027
+ // per-kind stream, matching the pre-item-id behavior.
1028
+ const itemId = typeof params['itemId'] === 'string'
1029
+ ? params['itemId']
1030
+ : `codex-stream-${reasoning ? 'think' : 'text'}-${turn}`;
1031
+ reserveStream(itemId, reasoning ? 'think' : 'text');
1032
+ const stream = activeStream === undefined ? undefined : streams.get(activeStream);
1033
+ if (stream !== undefined) {
1034
+ stream.text += text;
1035
+ appendStreamSnapshot(stream, false, false);
1036
+ }
1037
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
1038
+ return;
1039
+ }
1040
+ if (method === 'thread/tokenUsage/updated') {
1041
+ const tokenUsage = params['tokenUsage'];
1042
+ const last = tokenUsage?.['last'];
1043
+ if (last !== undefined)
1044
+ usage = usageFromAppServer(last);
1045
+ return;
1046
+ }
1047
+ if (method === 'turn/completed') {
1048
+ const turnData = params['turn'];
1049
+ mirrorUpTo(lines.length, true);
1050
+ resolveCompletion({
1051
+ status: String(turnData['status']),
1052
+ ...(turnData['error'] === null || turnData['error'] === undefined
1053
+ ? {}
1054
+ : { error: turnData['error'] }),
1055
+ });
1056
+ }
1057
+ };
1058
+ /** Route one wire notification: round-scoped, turn-id tagged, early ones buffered. */
1059
+ const dispatchNotification = (method, params) => {
1060
+ const paramThreadId = params['threadId'];
1061
+ if (typeof paramThreadId === 'string' && runtime?.threadId !== undefined && paramThreadId !== runtime.threadId)
1062
+ return;
1063
+ if (method === 'turn/started') {
1064
+ const turnData = params['turn'];
1065
+ const id = turnData?.['id'];
1066
+ if (typeof id === 'string' && activeTurnId === undefined && !runtime?.retiredTurnIds.has(id)) {
1067
+ pendingTurnId = id;
1068
+ }
1069
+ return;
1070
+ }
1071
+ if (!['item/completed', 'item/agentMessage/delta', 'item/reasoning/textDelta',
1072
+ 'thread/tokenUsage/updated', 'turn/completed'].includes(method))
1073
+ return;
1074
+ // turn/completed carries the id inside its turn object; the rest carry
1075
+ // a top-level turnId.
1076
+ const turnId = method === 'turn/completed'
1077
+ ? params['turn']?.['id']
1078
+ : params['turnId'];
1079
+ const matchesActive = activeTurnId !== undefined && turnId === activeTurnId;
1080
+ const matchesPending = activeTurnId === undefined && pendingTurnId !== undefined && turnId === pendingTurnId;
1081
+ if (!matchesActive && !matchesPending)
1082
+ return; // retired or foreign turn
1083
+ if (!turnOpened) {
1084
+ earlyNotifications.push({ method, params });
1085
+ return;
1086
+ }
1087
+ dispatchTurnNotification(method, params);
1088
+ };
1089
+ const accepted = (async () => {
1090
+ const rt = await this.ensureRuntime(spec, request.signal);
1091
+ runtime = rt;
1092
+ if (runAbort.signal.aborted) {
1093
+ await this.reclaim(String(childSession.id));
1094
+ throw new Error('subagent-codex: run cancelled locally');
1095
+ }
1096
+ rt.onWireNotification = dispatchNotification;
1097
+ try {
1098
+ await this.ensureThread(rt, spec);
1099
+ const response = await rt.peer.request('turn/start', {
1100
+ threadId: rt.threadId,
1101
+ input: [{ type: 'text', text: task, text_elements: [] }],
1102
+ ...spec.configuration?.model === undefined ? {} : { model: spec.configuration.model },
1103
+ ...spec.configuration?.effort === undefined ? {} : { effort: spec.configuration.effort },
1104
+ });
1105
+ if (typeof response?.turn?.id !== 'string') {
1106
+ throw new Error('subagent-codex live: turn/start returned no turn id');
1107
+ }
1108
+ activeTurnId = response.turn.id;
1109
+ }
1110
+ catch (error) {
1111
+ // The accept failed: the runtime's thread/turn state is unknown, so do
1112
+ // not reuse it — reclaim and fail the round loudly. The turn never
1113
+ // opened parent-side, so no dangling turn/start. (An auth-shaped
1114
+ // failure here flows through settleRunResult's onError, which owns
1115
+ // the registry mark — reporting here too would double it.)
1116
+ if (!runAbort.signal.aborted)
1117
+ await this.reclaim(String(childSession.id));
1118
+ throw thrown(error);
1119
+ }
1120
+ // The turn exists sub-side from here on: if a cancel landed mid-accept,
1121
+ // the interrupt goes out immediately (never leave a turn running wild).
1122
+ if (runAbort.signal.aborted && runtime !== undefined && runtime.threadId !== undefined) {
1123
+ void runtime.peer.request('turn/interrupt', {
1124
+ threadId: runtime.threadId,
1125
+ turnId: activeTurnId,
1126
+ }).catch(() => { });
1127
+ }
1128
+ openTurn();
1129
+ })();
1130
+ const attempt = accepted.then(async () => {
1131
+ if (runAbort.signal.aborted)
1132
+ throw new Error('subagent-codex: run cancelled locally');
1133
+ const rt = runtime;
1134
+ const processFailure = rt.child.done.then(outcome => Promise.reject(new Error('subagent-codex: the live runtime exited mid-round '
1135
+ + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
1136
+ processFailure.catch(() => { });
1137
+ const outcome = await Promise.race([completion, processFailure]);
1138
+ if (outcome.status === 'interrupted') {
1139
+ return { output: collectOutput(), stopReason: 'aborted' };
1140
+ }
1141
+ if (outcome.status === 'failed') {
1142
+ const message = typeof outcome.error?.['message'] === 'string'
1143
+ ? outcome.error['message']
1144
+ : 'the codex turn failed';
1145
+ throw new Error(`subagent-codex live: ${message}`);
1146
+ }
1147
+ if (outcome.status !== 'completed') {
1148
+ throw new Error(`subagent-codex live: the turn ended with unexpected status ${outcome.status}`);
1149
+ }
1150
+ const output = collectOutput();
1151
+ if (output.length === 0) {
1152
+ throw new Error('subagent-codex live: the turn completed but produced no answer');
1153
+ }
1154
+ return { output, stopReason: 'completed' };
1155
+ });
1156
+ const result = settleRunResult({
1157
+ attempt: () => Promise.race([attempt, abortBranch]),
1158
+ collectOutput,
1159
+ cancelled: () => runAbort.signal.aborted,
1160
+ onError: (error, stopReason) => {
1161
+ const diagnostics = runtime?.diagnostics ?? '';
1162
+ const suffix = diagnostics === '' ? '' : `; ${diagnostics}`;
1163
+ this.ctx.logger.warn(`subagent-codex: live round failed (${stopReason}): ${error.message}${suffix}`);
1164
+ this.reportAuthIfShaped(error);
1165
+ },
1166
+ signal: request.signal,
1167
+ onAbort,
1168
+ }).then(async (settled) => {
1169
+ roundSettled = true;
1170
+ liveFlush.dispose();
1171
+ if (turnOpened) {
1172
+ // An aborted or failed turn never sees turn/completed, so its
1173
+ // hold-back line (and the usage already observed) would be lost —
1174
+ // flush it now (the exec settle-mirror's partial-work contract).
1175
+ if (mirrored < lines.length)
1176
+ mirrorUpTo(lines.length, true);
1177
+ // Streams whose item/completed never arrived (abort, or the server
1178
+ // ended the turn mid-item) still finalize INSIDE the turn window:
1179
+ // one forced snapshot each — interrupted on a non-completed round,
1180
+ // the LAST one carrying the usage when no folded line did — then
1181
+ // step/end.
1182
+ if (streams.size > 0) {
1183
+ const remaining = [...streams.values()];
1184
+ for (const [position, stream] of remaining.entries()) {
1185
+ appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
1186
+ streamPublisher?.finish(stream.step);
1187
+ if (stream.opened)
1188
+ childSession.append('step/end', { turn, step: stream.step });
1189
+ }
1190
+ streams.clear();
1191
+ activeStream = undefined;
1192
+ }
1193
+ if (settled.stopReason === 'completed') {
1194
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
1195
+ }
1196
+ else if (settled.stopReason === 'aborted') {
1197
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
1198
+ }
1199
+ else {
1200
+ childSession.append('turn/end', {
1201
+ turn,
1202
+ reason: { kind: 'error', error: { message: 'the live round did not complete', code: 'UNKNOWN' } },
1203
+ });
1204
+ }
1205
+ }
1206
+ // Settlement retires the turn: its late notifications are tagged out,
1207
+ // and the round's sink is cleared so a stray notification drops dead.
1208
+ if (runtime !== undefined) {
1209
+ if (activeTurnId !== undefined)
1210
+ runtime.retiredTurnIds.add(activeTurnId);
1211
+ runtime.onWireNotification = undefined;
1212
+ }
1213
+ streamPublisher?.dispose();
1214
+ // A completed answer is not a durable turn until its final boundary
1215
+ // and any closing stream checkpoints have reached the child store.
1216
+ if (turnOpened) {
1217
+ persist();
1218
+ await persistQueue;
1219
+ }
1220
+ return settled;
1221
+ });
1222
+ // Final mirror report + reaper re-arm, mirroring the exec settle pass's
1223
+ // progress contract. A round that never opened owns no span — skip it.
1224
+ void result.then(async () => {
1225
+ try {
1226
+ if (turnOpened) {
1227
+ await persistQueue.catch(() => { });
1228
+ localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: mirrored });
1229
+ // Every settled round reports its observation through the registry's
1230
+ // channel (record merge + `settled` event) — the live drive's half
1231
+ // of the exec path's onRoundSettled. The app-server wire names no
1232
+ // model (codex 0.144.0), so the round's own rollout file answers,
1233
+ // exactly as in the exec settle mirror; a non-completed turn's
1234
+ // unstreamed spend comes back from the same read. Best-effort: a
1235
+ // missing rollout leaves the fields absent, never guessed.
1236
+ const facts = await codexRolloutRoundFacts(spec.homeDir, {
1237
+ threadId: runtime?.threadId,
1238
+ windowStart: startedAtMs,
1239
+ cwd: spec.cwd,
1240
+ });
1241
+ const settledUsage = usage ?? facts.usage;
1242
+ const round = {
1243
+ ...facts.model === undefined ? {} : { observedModel: facts.model },
1244
+ ...facts.cliVersion === undefined ? {} : { cliVersion: facts.cliVersion },
1245
+ ...settledUsage === undefined ? {} : { usage: settledUsage },
1246
+ };
1247
+ // Degrades silently on a core predating recordRoundSettled.
1248
+ const registry = localAgent;
1249
+ registry?.recordRoundSettled?.(childSession.id, round);
1250
+ }
1251
+ }
1252
+ catch (error) {
1253
+ this.ctx.logger.warn(`subagent-codex: live settle observation failed: ${thrown(error).message}`);
1254
+ }
1255
+ finally {
1256
+ this.armIdleTimer(String(childSession.id));
1257
+ }
1258
+ });
1259
+ // Publication gate: hold start() until the turn is accepted (channel
1260
+ // errors still throw for the provider's exec fallback), but never hold it
1261
+ // hostage to the slow windows — a cancel returns the run immediately.
1262
+ await Promise.race([accepted, abortBranch.catch(() => undefined)]);
1263
+ return subprocessRunHandle({
1264
+ id: childSession.id,
1265
+ result,
1266
+ signal: request.signal,
1267
+ onAbort,
1268
+ requestCancel,
1269
+ // The live teardown contract: NEVER kill the runtime here. The graceful
1270
+ // interrupt already went out via requestCancel; wait (bounded) for the
1271
+ // turn to converge so the runtime is left consistent for the next round.
1272
+ teardown: async () => {
1273
+ if (roundSettled)
1274
+ return;
1275
+ await Promise.race([completion.catch(() => ({ status: 'interrupted' })), delay(this.timeouts.convergeMs)]);
1276
+ },
1277
+ });
1278
+ }
1279
+ }