@bridge4dev/runner 0.64.1 → 0.65.1

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,569 @@
1
+ import { log } from '../log.js';
2
+ import { asRecord, str } from './codex-protocol.js';
3
+ // Codex's helpers, translated into the one tray every adapter publishes through
4
+ // (ticket #382).
5
+ //
6
+ // The rule «while helpers are working the session is not waiting on a person»
7
+ // is shared and never branches on the agent: the supervisor counts the running
8
+ // rows of `agent_tasks`, and the API and the dashboard read that number through
9
+ // `@devbridge/shared`. Codex was left out at exactly one point – nothing here
10
+ // turned its own events into rows – so its count was always zero and «Waiting»
11
+ // went out over helpers that had another minute of work in front of them.
12
+ //
13
+ // What Codex actually says, measured against codex-cli 0.154.0 on 16.09.2026
14
+ // (models of `multiAgentVersion: v2`; see devreport `codex-background-agents`):
15
+ //
16
+ // - on the session's OWN thread, one `subAgentActivity` item per event:
17
+ // `started` when a helper is spawned, `interacted` when words are put in its
18
+ // mailbox, `interrupted`, and `completed` when its turn ends. The `completed`
19
+ // one arrives AFTER the parent's turn is over, still carrying that old turn's
20
+ // id – which is exactly the window the ticket is about;
21
+ // - `collabAgentToolCall` items carrying `agentsStates`, a map of helper thread
22
+ // → status. Measured: a `wait` call with an empty map. The tools
23
+ // (`spawnAgent`, `closeAgent`, …) and the seven statuses are the schema's;
24
+ // - and the helpers' own threads, on the same connection: their `turn/*`,
25
+ // `item/*` and status notifications, each tagged with the helper's thread id.
26
+ // Those are NOT this session's turns (see `CodexSession.onNotification`).
27
+ //
28
+ // There is no LEVEL signal – nothing like Claude's `background_tasks_changed`
29
+ // that states the whole set at once. So the set is kept here from those events,
30
+ // and a snapshot (R16 of plan `critical-tickets-batch`) re-checks it: the full
31
+ // one at the end of every turn (`thread/loaded/list` + `thread/read`, so a lost
32
+ // `started` does not leave a helper uncounted), and a re-read of the helpers
33
+ // believed running once a minute while any of them is (so a lost `completed`
34
+ // does not keep one «running» for ever).
35
+ //
36
+ // What does NOT count: background shell commands (owner's decision D9 – Codex
37
+ // learns nothing when one ends, so there would be nothing to take the count
38
+ // back down with), and the helpers Codex runs for its own housekeeping
39
+ // (review, compaction, memory consolidation), which are not `thread_spawn`.
40
+ /**
41
+ * The tray's word for a helper. The dashboard draws the robot for any kind with
42
+ * «agent» in it, and this is the word the Claude SDK already uses for the same
43
+ * thing – so the row looks the same whichever CLI started it.
44
+ */
45
+ export const SUBAGENT_TASK_KIND = 'local_agent';
46
+ /** How often the set is re-checked against Codex while a helper is running. */
47
+ export const SUBAGENT_RECONCILE_MS = 60_000;
48
+ /**
49
+ * How long after a helper became `running` the snapshot may believe an `idle`.
50
+ *
51
+ * A thread handed work reports `idle` first and `active` a little later
52
+ * (measured: 400 ms and 1.3 s after a spawn), so a snapshot landing in that gap
53
+ * – a parent that spawns and ends its turn at once – would otherwise settle a
54
+ * helper that has not started yet. A real ending is still caught: by its own
55
+ * events at once, or by the next tick once this has passed.
56
+ */
57
+ export const SUBAGENT_SPAWN_GRACE_MS = 30_000;
58
+ /** Codex's `CollabAgentStatus` → the tray. Unknown is «no news», never a guess. */
59
+ export function collabAgentState(status) {
60
+ switch (status) {
61
+ case 'pendingInit':
62
+ case 'running':
63
+ return 'running';
64
+ case 'completed':
65
+ case 'shutdown':
66
+ return 'done';
67
+ case 'interrupted':
68
+ case 'errored':
69
+ case 'notFound':
70
+ return 'failed';
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Codex's `SubAgentActivityKind` → the tray. Unknown is «no news».
77
+ *
78
+ * `interacted` is deliberately NOT «running»: it is one agent putting words in
79
+ * another's mailbox, and the CLI's own tool description draws the line —
80
+ * `followup_task` «gives an existing agent a new task and triggers a turn»,
81
+ * `send_message` «passes a message to a running agent without triggering a
82
+ * turn». Both produce this one kind. Measured: a helper reported to the session
83
+ * with `interacted` and the session's thread started no turn for the next 120
84
+ * seconds. Read as «working», a message to an agent that has already finished
85
+ * would put a row back in the tray that nothing takes out again until the
86
+ * snapshot's grace is over. A re-tasked agent announces itself the honest way,
87
+ * with its own `turn/started` on its own thread.
88
+ */
89
+ export function activityState(kind) {
90
+ switch (kind) {
91
+ case 'started':
92
+ return 'running';
93
+ case 'completed':
94
+ return 'done';
95
+ case 'interrupted':
96
+ return 'failed';
97
+ default:
98
+ return null;
99
+ }
100
+ }
101
+ export function threadReading(raw) {
102
+ const thread = asRecord(raw);
103
+ if (!str(thread['id']))
104
+ return { kind: 'unknown' };
105
+ // `source` is a plain string for a top-level thread (`vscode`, `cli`…) and an
106
+ // object only for a helper; `asRecord` reads the string as «no fields».
107
+ const spawn = asRecord(asRecord(asRecord(thread['source'])['subAgent'])['thread_spawn']);
108
+ const spawned = Object.keys(spawn).length > 0;
109
+ return {
110
+ kind: 'thread',
111
+ status: str(asRecord(thread['status'])['type']) ?? null,
112
+ parentThreadId: str(thread['parentThreadId']) ?? str(spawn['parent_thread_id']) ?? null,
113
+ spawned,
114
+ nickname: str(thread['agentNickname']) ?? str(spawn['agent_nickname']) ?? null,
115
+ role: str(thread['agentRole']) ?? str(spawn['agent_role']) ?? null,
116
+ path: str(spawn['agent_path']) ?? null,
117
+ };
118
+ }
119
+ /**
120
+ * A refused `thread/read` or `thread/loaded/list`, classified by what Codex
121
+ * said. Measured on 0.154.0: an unknown thread is `-32600 thread not loaded:
122
+ * <id>`, an unknown request is `-32600 Invalid request: unknown variant …` –
123
+ * the same code, so the sentence decides.
124
+ */
125
+ export function threadReadingOfError(error) {
126
+ const record = asRecord(error);
127
+ const message = str(record['message']) ?? String(error);
128
+ if (record['name'] === 'RpcTimeoutError')
129
+ return { kind: 'unknown' };
130
+ if (record['code'] === -32601 || /unknown variant|method not found/i.test(message)) {
131
+ return { kind: 'unsupported' };
132
+ }
133
+ if (/thread not loaded|invalid thread id|thread not found|no such thread/i.test(message)) {
134
+ return { kind: 'gone' };
135
+ }
136
+ return { kind: 'unknown' };
137
+ }
138
+ /** How long one snapshot request may take. It is metadata only – no turns. */
139
+ const PROBE_TIMEOUT_MS = 15_000;
140
+ export function threadProbeOver(request) {
141
+ return {
142
+ async read(threadId) {
143
+ try {
144
+ // Metadata only: `includeTurns` stays off. Full-history hydration is
145
+ // what made `thread/resume` take a minute on a long session (#364).
146
+ const result = asRecord(await request('thread/read', { threadId }, PROBE_TIMEOUT_MS));
147
+ return threadReading(result['thread']);
148
+ }
149
+ catch (error) {
150
+ return threadReadingOfError(error);
151
+ }
152
+ },
153
+ async loaded() {
154
+ try {
155
+ const result = asRecord(await request('thread/loaded/list', {}, PROBE_TIMEOUT_MS));
156
+ const data = result['data'];
157
+ return Array.isArray(data)
158
+ ? data.filter((id) => typeof id === 'string' && id.length > 0)
159
+ : null;
160
+ }
161
+ catch (error) {
162
+ return threadReadingOfError(error).kind === 'unsupported' ? 'unsupported' : null;
163
+ }
164
+ },
165
+ };
166
+ }
167
+ export class CodexSubagents {
168
+ opts;
169
+ helpers = new Map();
170
+ /** Loaded threads the snapshot already found are not ours to count. */
171
+ notOurs = new Set();
172
+ timer = null;
173
+ reconciling = false;
174
+ /** A full reconcile asked for while one was running – run once more after it. */
175
+ reconcileAgain = false;
176
+ snapshotSupported = true;
177
+ closed = false;
178
+ reconcileMs;
179
+ spawnGraceMs;
180
+ constructor(opts) {
181
+ this.opts = opts;
182
+ this.reconcileMs = opts.reconcileMs ?? SUBAGENT_RECONCILE_MS;
183
+ this.spawnGraceMs = opts.spawnGraceMs ?? SUBAGENT_SPAWN_GRACE_MS;
184
+ }
185
+ /** One of this session's helpers, at any depth. */
186
+ isHelper(threadId) {
187
+ return this.helpers.has(threadId);
188
+ }
189
+ /**
190
+ * A collaboration item, on the session's own thread or on a helper's.
191
+ *
192
+ * A helper's helpers are this session's too: a spawned agent can spawn, and a
193
+ * grandchild at work keeps the session just as busy. An item from any other
194
+ * thread speaks for somebody else and is ignored.
195
+ */
196
+ onItem(senderThreadId, item) {
197
+ if (this.closed)
198
+ return;
199
+ if (senderThreadId !== this.opts.ownThreadId() && !this.helpers.has(senderThreadId))
200
+ return;
201
+ switch (item['type']) {
202
+ case 'subAgentActivity': {
203
+ const target = str(item['agentThreadId']);
204
+ if (!target)
205
+ return;
206
+ const state = activityState(item['kind']);
207
+ if (!state) {
208
+ // A message to a helper already at work is not news about it, but it
209
+ // does carry its path — worth keeping for the row it already has.
210
+ if (item['kind'] === 'interacted') {
211
+ this.learn(target, { path: str(item['agentPath']) ?? null });
212
+ }
213
+ return;
214
+ }
215
+ this.apply(target, state, { path: str(item['agentPath']) ?? null });
216
+ return;
217
+ }
218
+ case 'collabAgentToolCall': {
219
+ const states = asRecord(item['agentsStates']);
220
+ for (const [target, raw] of Object.entries(states)) {
221
+ const state = collabAgentState(asRecord(raw)['status']);
222
+ if (state)
223
+ this.apply(target, state, {});
224
+ }
225
+ // What the call did to the agents it names, for a call that finished
226
+ // and said nothing about them in `agentsStates` – a v1 `spawnAgent`
227
+ // returns before its agent has reported anything at all.
228
+ if (item['status'] !== 'completed')
229
+ return;
230
+ const receivers = Array.isArray(item['receiverThreadIds'])
231
+ ? item['receiverThreadIds'].filter((id) => typeof id === 'string')
232
+ : [];
233
+ for (const target of receivers) {
234
+ if (target in states)
235
+ continue;
236
+ if (item['tool'] === 'spawnAgent') {
237
+ this.apply(target, 'running', { task: str(item['prompt']) ?? null });
238
+ }
239
+ else if (item['tool'] === 'closeAgent') {
240
+ this.apply(target, 'done', {});
241
+ }
242
+ }
243
+ return;
244
+ }
245
+ default:
246
+ return;
247
+ }
248
+ }
249
+ /**
250
+ * A helper's own turn started or ended.
251
+ *
252
+ * Only for a helper already known: a thread's turn says it is working, not
253
+ * whose it is, and Codex runs threads of its own (review, compaction) that are
254
+ * nobody's helpers.
255
+ */
256
+ onHelperTurn(threadId, phase, turnStatus) {
257
+ if (this.closed || !this.helpers.has(threadId))
258
+ return;
259
+ if (phase === 'started') {
260
+ this.apply(threadId, 'running', {});
261
+ return;
262
+ }
263
+ this.apply(threadId, turnStatus === 'failed' || turnStatus === 'interrupted' ? 'failed' : 'done', {});
264
+ }
265
+ /** A helper's thread was closed. */
266
+ onThreadClosed(threadId) {
267
+ if (this.closed || !this.helpers.has(threadId))
268
+ return;
269
+ this.apply(threadId, 'done', {});
270
+ }
271
+ /**
272
+ * The session's turn ended: reset the turn's counters, say what is still
273
+ * running, then check the set against Codex.
274
+ *
275
+ * The frame goes out synchronously, BEFORE the caller emits `turn_end` – the
276
+ * supervisor decides «is this the person's turn now» on the count it holds at
277
+ * that moment. The snapshot follows in the background; if it changes the
278
+ * set, the supervisor re-reports the resting status with the new count.
279
+ */
280
+ endTurn() {
281
+ if (this.closed)
282
+ return;
283
+ this.opts.tray.endTurn();
284
+ void this.reconcile({ everything: true });
285
+ }
286
+ close() {
287
+ this.closed = true;
288
+ this.stopTimer();
289
+ }
290
+ /**
291
+ * Check the set against Codex (R16).
292
+ *
293
+ * `everything` – at the end of a turn: every helper not known to be gone, plus
294
+ * every loaded thread not classified yet. Otherwise (the minute tick) only
295
+ * the ones believed running, which is what can go stale.
296
+ */
297
+ async reconcile(options) {
298
+ if (this.closed || !this.snapshotSupported)
299
+ return;
300
+ if (this.reconciling) {
301
+ if (options.everything)
302
+ this.reconcileAgain = true;
303
+ return;
304
+ }
305
+ this.reconciling = true;
306
+ try {
307
+ await this.reconcileOnce(options.everything);
308
+ while (this.reconcileAgain && !this.gone()) {
309
+ this.reconcileAgain = false;
310
+ await this.reconcileOnce(true);
311
+ }
312
+ }
313
+ catch (error) {
314
+ log.warn('codex: helper snapshot failed', { error: String(error) });
315
+ }
316
+ finally {
317
+ this.reconciling = false;
318
+ this.reconcileAgain = false;
319
+ }
320
+ }
321
+ gone() {
322
+ return this.closed || this.opts.isStopped();
323
+ }
324
+ async reconcileOnce(everything) {
325
+ const own = this.opts.ownThreadId();
326
+ if (!own)
327
+ return;
328
+ for (const [threadId, helper] of [...this.helpers]) {
329
+ if (this.gone() || !this.snapshotSupported)
330
+ return;
331
+ if (helper.retired)
332
+ continue;
333
+ if (!everything && helper.state !== 'running')
334
+ continue;
335
+ const reading = await this.opts.probe.read(threadId);
336
+ if (this.gone())
337
+ return;
338
+ this.absorb(threadId, reading);
339
+ }
340
+ if (!everything || !this.snapshotSupported)
341
+ return;
342
+ const loaded = await this.opts.probe.loaded();
343
+ if (this.gone())
344
+ return;
345
+ if (loaded === 'unsupported') {
346
+ this.snapshotSupported = false;
347
+ return;
348
+ }
349
+ if (!loaded)
350
+ return;
351
+ for (const threadId of loaded) {
352
+ if (threadId === own || this.helpers.has(threadId) || this.notOurs.has(threadId))
353
+ continue;
354
+ const reading = await this.opts.probe.read(threadId);
355
+ if (this.gone())
356
+ return;
357
+ if (reading.kind === 'unsupported') {
358
+ this.snapshotSupported = false;
359
+ return;
360
+ }
361
+ if (reading.kind !== 'thread')
362
+ continue;
363
+ // Loaded threads come oldest first, so a grandchild is read after the
364
+ // child that makes it ours.
365
+ const parent = reading.parentThreadId;
366
+ const ours = reading.spawned && parent !== null && (parent === own || this.helpers.has(parent));
367
+ if (!ours) {
368
+ this.notOurs.add(threadId);
369
+ continue;
370
+ }
371
+ // Ours, and missed. Remembered whatever it is doing, so that its next turn
372
+ // is counted; a row only if it is working now.
373
+ this.remember(threadId, {
374
+ path: reading.path,
375
+ nickname: reading.nickname,
376
+ role: reading.role,
377
+ }).asked = true;
378
+ if (reading.status === 'active')
379
+ this.apply(threadId, 'running', {});
380
+ }
381
+ }
382
+ /** What one reading means for a helper already held. */
383
+ absorb(threadId, reading) {
384
+ const helper = this.helpers.get(threadId);
385
+ if (!helper)
386
+ return;
387
+ switch (reading.kind) {
388
+ case 'unsupported':
389
+ this.snapshotSupported = false;
390
+ return;
391
+ case 'unknown':
392
+ return;
393
+ case 'gone':
394
+ helper.retired = true;
395
+ this.apply(threadId, 'done', {});
396
+ return;
397
+ case 'thread':
398
+ break;
399
+ }
400
+ this.learn(threadId, { nickname: reading.nickname, role: reading.role, path: reading.path });
401
+ switch (reading.status) {
402
+ case 'active':
403
+ // Working, and held as finished: a lost `started` / `interacted`.
404
+ if (helper.state !== 'running')
405
+ this.apply(threadId, 'running', {});
406
+ return;
407
+ case 'idle':
408
+ if (helper.state !== 'running')
409
+ return;
410
+ // A thread handed work reports `idle` for a moment before its turn
411
+ // starts – believed only once that moment is well past.
412
+ if (Date.now() - helper.runningSince < this.spawnGraceMs)
413
+ return;
414
+ this.apply(threadId, 'done', {});
415
+ return;
416
+ case 'notLoaded':
417
+ helper.retired = true;
418
+ this.apply(threadId, 'done', {});
419
+ return;
420
+ case 'systemError':
421
+ this.apply(threadId, 'failed', {});
422
+ return;
423
+ default:
424
+ // A status this runner does not know is not news about the helper.
425
+ return;
426
+ }
427
+ }
428
+ /** The helper's record, created (as finished) if it is new. */
429
+ remember(threadId, facts) {
430
+ let helper = this.helpers.get(threadId);
431
+ if (!helper) {
432
+ helper = {
433
+ state: 'done',
434
+ runningSince: Date.now(),
435
+ retired: false,
436
+ asked: false,
437
+ nickname: null,
438
+ path: null,
439
+ role: null,
440
+ task: null,
441
+ };
442
+ this.helpers.set(threadId, helper);
443
+ }
444
+ helper.nickname = facts.nickname ?? helper.nickname;
445
+ helper.path = facts.path ?? helper.path;
446
+ helper.role = facts.role ?? helper.role;
447
+ helper.task = facts.task ?? helper.task;
448
+ return helper;
449
+ }
450
+ /**
451
+ * Move one helper to a state, and publish.
452
+ *
453
+ * The session itself is never its own helper: a helper reporting back to its
454
+ * parent names the PARENT as the agent it «interacted» with
455
+ * (`agentThreadId` = the session's thread, `agentPath: "/root"`) – measured
456
+ * on 0.154.0, and counted it would keep the session «busy» for ever.
457
+ */
458
+ apply(threadId, state, facts) {
459
+ if (threadId === this.opts.ownThreadId())
460
+ return;
461
+ // Nobody we counted finishing is no news at all.
462
+ if (state !== 'running' && !this.helpers.has(threadId))
463
+ return;
464
+ const helper = this.remember(threadId, facts);
465
+ const tray = this.opts.tray;
466
+ if (state === 'running') {
467
+ helper.retired = false;
468
+ if (helper.state !== 'running') {
469
+ // Handed new work after it had finished: a new row, not the old one
470
+ // coming back – the tray's settling is one-way on purpose.
471
+ tray.forget(threadId);
472
+ tray.touch(threadId, { kind: SUBAGENT_TASK_KIND, status: 'running', ...rowFacts(helper) });
473
+ helper.state = 'running';
474
+ helper.runningSince = Date.now();
475
+ }
476
+ else {
477
+ tray.touch(threadId, rowFacts(helper));
478
+ }
479
+ this.syncLive();
480
+ if (!helper.asked && !helper.nickname) {
481
+ helper.asked = true;
482
+ void this.askName(threadId);
483
+ }
484
+ return;
485
+ }
486
+ if (helper.state !== 'running')
487
+ return;
488
+ helper.state = state;
489
+ tray.touch(threadId, { status: state });
490
+ this.syncLive();
491
+ }
492
+ /** New facts about a helper, onto its row if it has one. */
493
+ learn(threadId, facts) {
494
+ const helper = this.helpers.get(threadId);
495
+ if (!helper)
496
+ return;
497
+ this.remember(threadId, facts);
498
+ if (helper.state !== 'running' || !this.opts.tray.has(threadId))
499
+ return;
500
+ this.opts.tray.touch(threadId, rowFacts(helper));
501
+ this.opts.tray.publish();
502
+ }
503
+ /**
504
+ * Ask Codex for the helper's name once, right after it appears. The activity
505
+ * item carries only its path (`/root/a`); the name Codex gives it
506
+ * («Heisenberg») is what Codex's own interface calls it.
507
+ */
508
+ async askName(threadId) {
509
+ if (!this.snapshotSupported)
510
+ return;
511
+ const reading = await this.opts.probe.read(threadId);
512
+ if (this.gone())
513
+ return;
514
+ if (reading.kind === 'unsupported') {
515
+ this.snapshotSupported = false;
516
+ return;
517
+ }
518
+ if (reading.kind !== 'thread')
519
+ return;
520
+ this.learn(threadId, { nickname: reading.nickname, role: reading.role, path: reading.path });
521
+ }
522
+ syncLive() {
523
+ const live = [];
524
+ for (const [threadId, helper] of this.helpers) {
525
+ if (helper.state === 'running')
526
+ live.push(threadId);
527
+ }
528
+ this.opts.tray.setLive(live);
529
+ this.opts.tray.publish();
530
+ if (live.length > 0)
531
+ this.startTimer();
532
+ else
533
+ this.stopTimer();
534
+ }
535
+ startTimer() {
536
+ if (this.timer || this.closed)
537
+ return;
538
+ this.timer = setInterval(() => {
539
+ void this.reconcile({ everything: false });
540
+ }, this.reconcileMs);
541
+ this.timer.unref?.();
542
+ }
543
+ stopTimer() {
544
+ if (!this.timer)
545
+ return;
546
+ clearInterval(this.timer);
547
+ this.timer = null;
548
+ }
549
+ }
550
+ /** A helper's row, as far as it is known. */
551
+ function rowFacts(helper) {
552
+ return {
553
+ title: titleOf(helper.nickname, helper.path),
554
+ ...(helper.role ? { subagentType: helper.role } : {}),
555
+ ...(helper.task ? { summary: helper.task } : {}),
556
+ };
557
+ }
558
+ /**
559
+ * «Heisenberg · a» – the name Codex gave the helper, and the path the parent
560
+ * gave it. Either alone when the other is unknown; the path loses its `/root/`
561
+ * prefix, which every helper of this session shares.
562
+ */
563
+ function titleOf(nickname, path) {
564
+ const shortPath = path ? path.replace(/^\/root\/?/, '') || path : null;
565
+ if (nickname && shortPath)
566
+ return `${nickname} · ${shortPath}`;
567
+ return nickname ?? shortPath ?? 'Subagent';
568
+ }
569
+ //# sourceMappingURL=codex-subagents.js.map
@@ -33,6 +33,10 @@ export interface CodexAdapterDeps {
33
33
  * runner's credential separate.
34
34
  */
35
35
  authMode?: 'link' | 'own';
36
+ /** #382: how often live helpers are re-checked against Codex – a test seam. */
37
+ subagentReconcileMs?: number;
38
+ /** #382: how long a just-started helper's `idle` is disbelieved – a test seam. */
39
+ subagentSpawnGraceMs?: number;
36
40
  }
37
41
  /**
38
42
  * Codex's own wording for "the sign-in did not work".