@bridge4dev/runner 0.64.1 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/agent-tasks.d.ts +113 -0
- package/dist/adapters/agent-tasks.js +260 -0
- package/dist/adapters/claude.js +45 -236
- package/dist/adapters/codex-subagents.d.ts +169 -0
- package/dist/adapters/codex-subagents.js +569 -0
- package/dist/adapters/codex.d.ts +4 -0
- package/dist/adapters/codex.js +194 -18
- package/dist/supervisor.d.ts +20 -0
- package/dist/supervisor.js +51 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -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".
|