@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.
@@ -10,6 +10,7 @@ import { cageSpawn, noteSessionAgentPid, memoryDeathSentence, releaseSessionScop
10
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
13
+ import { AgentTaskTray } from './agent-tasks.js';
13
14
  import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
14
15
  import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
15
16
  /** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
@@ -255,18 +256,6 @@ const EFFORT_LABELS = {
255
256
  const EFFORT_DESCRIPTIONS = {
256
257
  [ULTRACODE]: 'Extra high, plus the agent orchestrates sub-agent workflows on its own',
257
258
  };
258
- /** Longest a task title may be before the tray truncates it. */
259
- const TASK_TITLE_LIMIT = 120;
260
- /** How many live tasks travel in one `agent_tasks` event. */
261
- const TASK_LIST_CAP = 20;
262
- /**
263
- * Floor between two `agent_tasks` publications.
264
- *
265
- * A twenty-agent fan-out changes membership dozens of times a minute and every
266
- * running subagent adds a `task_progress` every ~30s. Each event is a row in
267
- * `DevSessionEvent`, so the tray coalesces rather than narrating.
268
- */
269
- const TASK_PUBLISH_INTERVAL_MS = 1_500;
270
259
  /**
271
260
  * What the agent calls a finished task, mapped onto the tray's three states.
272
261
  *
@@ -433,29 +422,15 @@ class ClaudeSession {
433
422
  */
434
423
  liveWireModel;
435
424
  /**
436
- * Everything running beside the conversation, keyed by the agent's task id
437
- * (ticket #113). Membership is owned by `background_tasks_changed`; the
438
- * details come from the `task_*` bookends.
425
+ * Everything running beside the conversation (ticket #113). Membership is
426
+ * owned by `background_tasks_changed`; the details come from the `task_*`
427
+ * bookends. How the set is published – the floor, the de-duplication, the
428
+ * per-turn counters – is the tray's, and shared with the Codex adapter (#382).
439
429
  */
440
- tasks = new Map();
441
- /** Ids currently in the agent's live set — the tray shows exactly these. */
442
- liveTaskIds = [];
443
- /**
444
- * Which turn is being counted (#147).
445
- *
446
- * There are no `started` / `done` accumulators any more, and that is the
447
- * whole fix. Two counters with different lifetimes could disagree, and did:
448
- * `endTaskTurn` zeroed both while deliberately keeping the ROWS of tasks
449
- * still running, so a background shell that outlived its turn was counted as
450
- * finished in the next one without ever having been counted as started —
451
- * «22 of 21 done». Both numbers are now derived from this map in one pass, so
452
- * `done <= total` holds by cardinality rather than by clamping.
453
- */
454
- turnEpoch = 0;
455
- taskPublishTimer = null;
456
- taskPublishedAt = 0;
457
- /** Last published snapshot, minus the ages — see `flushTasks` (QA-111 M4). */
458
- lastTaskFingerprint = '';
430
+ tray = new AgentTaskTray({
431
+ emit: (event) => this.emit(event),
432
+ isStopped: () => this.stopped,
433
+ });
459
434
  /**
460
435
  * Ambient tasks the SDK marked `skip_transcript`. Remembered by id because
461
436
  * the flag arrives on `task_started`, which is usually NOT the first message
@@ -642,13 +617,6 @@ class ClaudeSession {
642
617
  };
643
618
  this.q = queryFn({ prompt: this.input, options });
644
619
  void this.consume();
645
- // Ticket #113. The task level is per-PROCESS: the SDK emits nothing at
646
- // startup and tells consumers to reset to the empty set whenever the
647
- // session's CLI process (re)starts. The dashboard reads the newest
648
- // `agent_tasks` in the stored feed, so without this a session killed
649
- // mid-turn — the runner restarted, the machine rebooted — would come back
650
- // and show the subagents of its previous life as though they were still
651
- // running, right up until the next membership change.
652
620
  // Ticket #119 / QA-114 MAJOR-3: if the protected file could not be written,
653
621
  // say so where a person will see it, not only in journald.
654
622
  if (this.mcpFallbackNotice) {
@@ -658,8 +626,14 @@ class ClaudeSession {
658
626
  this.emit({ type: 'notice', level: 'warn', text: MODE_REFUSED_TEXT });
659
627
  this.emit({ type: 'settings', mode: this.mode });
660
628
  }
661
- this.lastTaskFingerprint = JSON.stringify({ done: 0, total: 0, tasks: [] });
662
- this.emit({ type: 'agent_tasks', tasks: [], done: 0, total: 0 });
629
+ // Ticket #113. The task level is per-PROCESS: the SDK emits nothing at
630
+ // startup and tells consumers to reset to the empty set whenever the
631
+ // session's CLI process (re)starts. The dashboard reads the newest
632
+ // `agent_tasks` in the stored feed, so without this a session killed
633
+ // mid-turn — the runner restarted, the machine rebooted — would come back
634
+ // and show the subagents of its previous life as though they were still
635
+ // running, right up until the next membership change.
636
+ this.tray.announceEmpty();
663
637
  // Report what the agent can do right away. `system:init` only arrives with
664
638
  // the first turn (verified live), so a free session waiting for its first
665
639
  // message would otherwise show no model list at all — while the control
@@ -1540,7 +1514,7 @@ class ClaudeSession {
1540
1514
  if (!announced.has(id))
1541
1515
  this.skipTaskIds.delete(id);
1542
1516
  }
1543
- this.liveTaskIds = [];
1517
+ const liveIds = [];
1544
1518
  for (const row of rows) {
1545
1519
  const id = typeof row['task_id'] === 'string' ? row['task_id'] : null;
1546
1520
  if (!id)
@@ -1551,16 +1525,17 @@ class ClaudeSession {
1551
1525
  // and it never left again (QA-111 m4).
1552
1526
  if (this.skipTaskIds.has(id))
1553
1527
  continue;
1554
- this.liveTaskIds.push(id);
1528
+ liveIds.push(id);
1555
1529
  // A task can reach the live set before its own `task_started` — the
1556
1530
  // SDK says the level usually precedes the bookends. Seed it here so
1557
1531
  // the tray never has a live id it cannot name.
1558
- this.touchTask(id, {
1532
+ this.tray.touch(id, {
1559
1533
  kind: typeof row['task_type'] === 'string' ? row['task_type'] : 'task',
1560
1534
  title: typeof row['description'] === 'string' ? row['description'] : 'Working…',
1561
1535
  });
1562
1536
  }
1563
- this.publishTasks();
1537
+ this.tray.setLive(liveIds);
1538
+ this.tray.publish();
1564
1539
  break;
1565
1540
  }
1566
1541
  case 'task_started': {
@@ -1572,14 +1547,11 @@ class ClaudeSession {
1572
1547
  // may already have taken it in before this message arrived.
1573
1548
  if (msg['skip_transcript'] === true) {
1574
1549
  this.skipTaskIds.add(id);
1575
- this.tasks.delete(id);
1576
- const before = this.liveTaskIds.length;
1577
- this.liveTaskIds = this.liveTaskIds.filter((live) => live !== id);
1578
- if (this.liveTaskIds.length !== before)
1579
- this.publishTasks();
1550
+ if (this.tray.forget(id))
1551
+ this.tray.publish();
1580
1552
  break;
1581
1553
  }
1582
- this.touchTask(id, {
1554
+ this.tray.touch(id, {
1583
1555
  kind: typeof msg['task_type'] === 'string' ? msg['task_type'] : 'task',
1584
1556
  ...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
1585
1557
  ...(typeof msg['subagent_type'] === 'string'
@@ -1589,7 +1561,7 @@ class ClaudeSession {
1589
1561
  ? { workflowName: msg['workflow_name'] }
1590
1562
  : {}),
1591
1563
  });
1592
- this.publishTasks();
1564
+ this.tray.publish();
1593
1565
  break;
1594
1566
  }
1595
1567
  case 'task_progress': {
@@ -1600,7 +1572,7 @@ class ClaudeSession {
1600
1572
  // denominator (#147).
1601
1573
  if (!id || this.skipTaskIds.has(id))
1602
1574
  break;
1603
- this.touchTask(id, {
1575
+ this.tray.touch(id, {
1604
1576
  ...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
1605
1577
  ...(typeof msg['subagent_type'] === 'string'
1606
1578
  ? { subagentType: msg['subagent_type'] }
@@ -1608,20 +1580,20 @@ class ClaudeSession {
1608
1580
  ...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
1609
1581
  ...usagePatch(msg['usage']),
1610
1582
  });
1611
- this.publishTasks();
1583
+ this.tray.publish();
1612
1584
  break;
1613
1585
  }
1614
1586
  case 'task_updated': {
1615
1587
  const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
1616
1588
  const patch = (msg['patch'] ?? {});
1617
- if (!id || !this.tasks.has(id))
1589
+ if (!id || !this.tray.has(id))
1618
1590
  break;
1619
1591
  const status = typeof patch['status'] === 'string' ? patch['status'] : undefined;
1620
- this.touchTask(id, {
1592
+ this.tray.touch(id, {
1621
1593
  ...(typeof patch['description'] === 'string' ? { title: patch['description'] } : {}),
1622
1594
  ...(status ? { status: taskStatus(status) } : {}),
1623
1595
  });
1624
- this.publishTasks();
1596
+ this.tray.publish();
1625
1597
  break;
1626
1598
  }
1627
1599
  case 'task_notification': {
@@ -1638,12 +1610,12 @@ class ClaudeSession {
1638
1610
  }
1639
1611
  break;
1640
1612
  }
1641
- this.touchTask(id, {
1613
+ this.tray.touch(id, {
1642
1614
  status: taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed'),
1643
1615
  ...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
1644
1616
  ...usagePatch(msg['usage']),
1645
1617
  });
1646
- this.publishTasks();
1618
+ this.tray.publish();
1647
1619
  break;
1648
1620
  }
1649
1621
  default:
@@ -1651,181 +1623,21 @@ class ClaudeSession {
1651
1623
  }
1652
1624
  }
1653
1625
  /**
1654
- * Create-or-merge one task row, and count the TRANSITIONS while doing it.
1655
- *
1656
- * The counters live here rather than in the individual message handlers
1657
- * because «started» and «finished» are properties of the transition, not of
1658
- * whichever message happened to announce it (QA-111 M1). They were counted
1659
- * per-message, and both orderings the SDK actually produces missed:
1660
- *
1661
- * `background_tasks_changed` → `task_started` → `total` stayed 0
1662
- * `task_updated{completed}` → `task_notification` → `done` stayed 0
1663
- *
1664
- * The first of those is the COMMON ordering — the SDK documents the level as
1665
- * usually preceding the bookends — so «N of M done», the one number the
1666
- * ticket asked for by name, was never printed at all.
1667
- *
1668
- * `startedAt` is stamped once and never moves.
1669
- */
1670
- touchTask(id, patch) {
1671
- const existing = this.tasks.get(id);
1672
- if (existing) {
1673
- const settledAs = existing.status === 'running' ? null : existing.status;
1674
- Object.assign(existing, patch);
1675
- if (patch.title)
1676
- existing.title = truncate(patch.title, TASK_TITLE_LIMIT);
1677
- if (patch.summary)
1678
- existing.summary = truncate(patch.summary, TASK_TITLE_LIMIT);
1679
- // Settling is one-way. A task that has stopped cannot start again under
1680
- // the same id: the only messages that could say so are a late
1681
- // `task_updated` carrying a status this runner does not recognise, or a
1682
- // frame redelivered after a reconnect — neither of which is news that the
1683
- // work resumed. Letting either through would make the derived `done`
1684
- // count downwards on screen.
1685
- if (settledAs && existing.status === 'running')
1686
- existing.status = settledAs;
1687
- return;
1688
- }
1689
- this.tasks.set(id, {
1690
- ...patch,
1691
- id,
1692
- kind: patch.kind ?? 'task',
1693
- title: truncate(patch.title ?? 'Working…', TASK_TITLE_LIMIT),
1694
- status: patch.status ?? 'running',
1695
- turnEpoch: this.turnEpoch,
1696
- startedAt: Date.now(),
1697
- // The only free-text field an agent writes with no length of its own:
1698
- // a subagent's closing `summary` runs to kilobytes, and twenty of them
1699
- // push the event past the payload cap, which replaces the WHOLE payload
1700
- // with `{truncated:true}` and blinks the tray out (QA-111 m1).
1701
- ...(patch.summary ? { summary: truncate(patch.summary, TASK_TITLE_LIMIT) } : {}),
1702
- });
1703
- }
1704
- /**
1705
- * Publish the tray, at most once every `TASK_PUBLISH_INTERVAL_MS`.
1706
- *
1707
- * Every publication is a stored row in the session feed, and a wide fan-out
1708
- * changes membership dozens of times a minute. The trailing timer matters as
1709
- * much as the floor: the LAST change in a burst is the one that says the work
1710
- * is over, and dropping it would leave the tray running forever.
1711
- */
1712
- publishTasks() {
1713
- if (this.stopped)
1714
- return;
1715
- const wait = TASK_PUBLISH_INTERVAL_MS - (Date.now() - this.taskPublishedAt);
1716
- if (wait > 0) {
1717
- if (!this.taskPublishTimer) {
1718
- this.taskPublishTimer = setTimeout(() => {
1719
- this.taskPublishTimer = null;
1720
- this.flushTasks();
1721
- }, wait);
1722
- this.taskPublishTimer.unref();
1723
- }
1724
- return;
1725
- }
1726
- this.flushTasks();
1727
- }
1728
- flushTasks() {
1729
- if (this.stopped)
1730
- return;
1731
- this.taskPublishedAt = Date.now();
1732
- const now = Date.now();
1733
- // Only what the agent still calls live. A task that finished keeps its row
1734
- // in `this.tasks` for the counters, but the tray is about NOW.
1735
- const tasks = this.liveTaskIds
1736
- .map((id) => this.tasks.get(id))
1737
- .filter((t) => Boolean(t))
1738
- .slice(0, TASK_LIST_CAP)
1739
- // How long it has been running, measured HERE (QA-111 m2). `startedAt` is
1740
- // this machine's clock and the browser's is a different one — subtracting
1741
- // across them put the dev server's clock skew straight into the number,
1742
- // so a host ten minutes behind showed «10m 03s» on a task one second old.
1743
- .map(({ turnEpoch: _turnEpoch, ...task }) => ({
1744
- ...task,
1745
- ageMs: Math.max(0, now - task.startedAt),
1746
- }));
1747
- // Both numbers, one pass, one map (#147). `done` counts a SUBSET of what
1748
- // `total` counts, so `done <= total` is a property of set cardinality and
1749
- // cannot be broken by a message ordering, a redelivery, a re-title, an
1750
- // ambient row being deleted or a turn boundary. There is no pair of
1751
- // counters to keep in agreement, because there is no pair.
1752
- //
1753
- // Tasks that outlived an earlier turn keep that turn's epoch: they still
1754
- // render as live rows — background work outliving its turn is the whole
1755
- // point of the tray — but they belong to neither number here, exactly as
1756
- // this file's own doctrine says («done/total are per-turn by definition.
1757
- // The live set does NOT»).
1758
- let total = 0;
1759
- let done = 0;
1760
- for (const task of this.tasks.values()) {
1761
- if (task.turnEpoch !== this.turnEpoch)
1762
- continue;
1763
- total += 1;
1764
- if (task.status !== 'running')
1765
- done += 1;
1766
- }
1767
- const payload = { type: 'agent_tasks', tasks, done, total };
1768
- // A frame that says exactly what the last one said is not worth a row in
1769
- // the session feed (QA-111 M4). `task_progress` fires every ~30s per
1770
- // running subagent and usually carries nothing new, and with twenty of
1771
- // them that alone is the throttle's whole budget. `ageMs` is excluded from
1772
- // the comparison on purpose — it changes every time by definition, and
1773
- // including it would make every frame unique and the check pointless.
1774
- const fingerprint = JSON.stringify({
1775
- done: payload.done,
1776
- total: payload.total,
1777
- tasks: tasks.map(({ ageMs: _ageMs, ...rest }) => rest),
1778
- });
1779
- if (fingerprint === this.lastTaskFingerprint)
1780
- return;
1781
- this.lastTaskFingerprint = fingerprint;
1782
- this.emit(payload);
1783
- }
1784
- /**
1785
- * The turn ended. Reset what belongs to the TURN — and only that.
1786
- *
1787
- * `done`/`total` are per-turn by definition and go back to zero. The live set
1788
- * does NOT: background work is precisely the work that outlives the turn that
1789
- * started it, which is the whole reason the ticket asks for it. Caught on
1790
- * production during this session's own verification — the agent ended its
1791
- * turn with `sleep 40` still running in the background and the tray, which
1792
- * cleared everything here, showed nothing at exactly the moment somebody was
1793
- * reading the answer and wondering whether the deploy had finished.
1626
+ * The turn ended: the tray resets the turn's counters and says what is still
1627
+ * running (see `AgentTaskTray.endTurn`).
1794
1628
  *
1795
1629
  * Membership stays owned by `background_tasks_changed`, which reports the set
1796
- * emptying when it actually empties. Finished tasks are dropped here because
1797
- * only the live ids are ever rendered anyway, and keeping their rows would
1798
- * grow the map for the life of the session.
1630
+ * emptying when it actually empties.
1631
+ *
1632
+ * `skipTaskIds` is NOT cleared here (#147). An ambient task the SDK asked
1633
+ * consumers to hide can span a turn boundary, and clearing the set let its
1634
+ * id back into the live list on the next `background_tasks_changed` – where
1635
+ * it was drawn as a tray row and counted as work. The id is forgotten when
1636
+ * that task settles instead, which is the moment it stops being able to come
1637
+ * back.
1799
1638
  */
1800
1639
  endTaskTurn() {
1801
- if (this.taskPublishTimer) {
1802
- clearTimeout(this.taskPublishTimer);
1803
- this.taskPublishTimer = null;
1804
- }
1805
- const live = new Set(this.liveTaskIds);
1806
- for (const id of [...this.tasks.keys()]) {
1807
- if (!live.has(id))
1808
- this.tasks.delete(id);
1809
- }
1810
- // `skipTaskIds` is NOT cleared here any more (#147). An ambient task the
1811
- // SDK asked consumers to hide can span a turn boundary, and clearing the
1812
- // set let its id back into the live list on the next
1813
- // `background_tasks_changed` — where it was drawn as a tray row and counted
1814
- // as work. The id is forgotten when that task settles instead, which is the
1815
- // moment it stops being able to come back.
1816
- this.turnEpoch += 1;
1817
- // Straight through `flushTasks` rather than an empty frame of its own, and
1818
- // with the de-duplication disarmed for this one frame (plan
1819
- // `workflow-mode-fixes` S1 p.10). The turn's counters usually changed, but
1820
- // not always: a task started in an earlier turn and still running, and a
1821
- // turn that started nothing new, produce exactly the frame the last turn
1822
- // ended on — and the end of a turn is the moment the API and the tray most
1823
- // need to hear what is still running, whether or not it is news. The
1824
- // supervisor de-duplicates the COUNT on its own side; this frame is the
1825
- // tray's freshness, not the database's.
1826
- this.taskPublishedAt = 0;
1827
- this.lastTaskFingerprint = '';
1828
- this.flushTasks();
1640
+ this.tray.endTurn();
1829
1641
  }
1830
1642
  /**
1831
1643
  * Forget what the LAST turn did, now that a new one is starting (#252).
@@ -2266,10 +2078,7 @@ class ClaudeSession {
2266
2078
  return;
2267
2079
  this.stopped = true;
2268
2080
  // A pending tray publication would fire into a closed output queue.
2269
- if (this.taskPublishTimer) {
2270
- clearTimeout(this.taskPublishTimer);
2271
- this.taskPublishTimer = null;
2272
- }
2081
+ this.tray.close();
2273
2082
  // Before the process goes: withdraw everything a human was still being
2274
2083
  // asked, WITH a cause. A pending permission used to gutter out as a plain
2275
2084
  // "denied", which put a decision in the audit trail that nobody made.
@@ -0,0 +1,169 @@
1
+ import type { AgentTaskTray } from './agent-tasks.js';
2
+ /**
3
+ * The tray's word for a helper. The dashboard draws the robot for any kind with
4
+ * «agent» in it, and this is the word the Claude SDK already uses for the same
5
+ * thing – so the row looks the same whichever CLI started it.
6
+ */
7
+ export declare const SUBAGENT_TASK_KIND = "local_agent";
8
+ /** How often the set is re-checked against Codex while a helper is running. */
9
+ export declare const SUBAGENT_RECONCILE_MS = 60000;
10
+ /**
11
+ * How long after a helper became `running` the snapshot may believe an `idle`.
12
+ *
13
+ * A thread handed work reports `idle` first and `active` a little later
14
+ * (measured: 400 ms and 1.3 s after a spawn), so a snapshot landing in that gap
15
+ * – a parent that spawns and ends its turn at once – would otherwise settle a
16
+ * helper that has not started yet. A real ending is still caught: by its own
17
+ * events at once, or by the next tick once this has passed.
18
+ */
19
+ export declare const SUBAGENT_SPAWN_GRACE_MS = 30000;
20
+ type HelperState = 'running' | 'done' | 'failed';
21
+ /** Codex's `CollabAgentStatus` → the tray. Unknown is «no news», never a guess. */
22
+ export declare function collabAgentState(status: unknown): HelperState | null;
23
+ /**
24
+ * Codex's `SubAgentActivityKind` → the tray. Unknown is «no news».
25
+ *
26
+ * `interacted` is deliberately NOT «running»: it is one agent putting words in
27
+ * another's mailbox, and the CLI's own tool description draws the line —
28
+ * `followup_task` «gives an existing agent a new task and triggers a turn»,
29
+ * `send_message` «passes a message to a running agent without triggering a
30
+ * turn». Both produce this one kind. Measured: a helper reported to the session
31
+ * with `interacted` and the session's thread started no turn for the next 120
32
+ * seconds. Read as «working», a message to an agent that has already finished
33
+ * would put a row back in the tray that nothing takes out again until the
34
+ * snapshot's grace is over. A re-tasked agent announces itself the honest way,
35
+ * with its own `turn/started` on its own thread.
36
+ */
37
+ export declare function activityState(kind: unknown): HelperState | null;
38
+ /** What one `thread/read` said, reduced to what the set needs. */
39
+ export type ThreadReading = {
40
+ kind: 'thread';
41
+ /** `active` · `idle` · `notLoaded` · `systemError`, or null when absent. */
42
+ status: string | null;
43
+ parentThreadId: string | null;
44
+ /** Spawned by an agent's collaboration tools – the only helpers that count. */
45
+ spawned: boolean;
46
+ nickname: string | null;
47
+ role: string | null;
48
+ path: string | null;
49
+ }
50
+ /** The thread does not exist in this app-server any more. */
51
+ | {
52
+ kind: 'gone';
53
+ }
54
+ /** This Codex has no such request – stop asking. */
55
+ | {
56
+ kind: 'unsupported';
57
+ }
58
+ /** No answer worth acting on (a timeout, a transport hiccup). */
59
+ | {
60
+ kind: 'unknown';
61
+ };
62
+ export declare function threadReading(raw: unknown): ThreadReading;
63
+ /**
64
+ * A refused `thread/read` or `thread/loaded/list`, classified by what Codex
65
+ * said. Measured on 0.154.0: an unknown thread is `-32600 thread not loaded:
66
+ * <id>`, an unknown request is `-32600 Invalid request: unknown variant …` –
67
+ * the same code, so the sentence decides.
68
+ */
69
+ export declare function threadReadingOfError(error: unknown): ThreadReading;
70
+ type Request = (method: string, params: Record<string, unknown>, timeoutMs: number) => Promise<unknown>;
71
+ export interface CodexThreadProbe {
72
+ read(threadId: string): Promise<ThreadReading>;
73
+ /** Loaded thread ids, `unsupported`, or null when there was no answer. */
74
+ loaded(): Promise<string[] | 'unsupported' | null>;
75
+ }
76
+ export declare function threadProbeOver(request: Request): CodexThreadProbe;
77
+ export interface CodexSubagentsOptions {
78
+ tray: AgentTaskTray;
79
+ /** The session's own thread – never counted as its own helper. */
80
+ ownThreadId: () => string | null;
81
+ probe: CodexThreadProbe;
82
+ isStopped: () => boolean;
83
+ reconcileMs?: number;
84
+ spawnGraceMs?: number;
85
+ }
86
+ export declare class CodexSubagents {
87
+ private readonly opts;
88
+ private readonly helpers;
89
+ /** Loaded threads the snapshot already found are not ours to count. */
90
+ private readonly notOurs;
91
+ private timer;
92
+ private reconciling;
93
+ /** A full reconcile asked for while one was running – run once more after it. */
94
+ private reconcileAgain;
95
+ private snapshotSupported;
96
+ private closed;
97
+ private readonly reconcileMs;
98
+ private readonly spawnGraceMs;
99
+ constructor(opts: CodexSubagentsOptions);
100
+ /** One of this session's helpers, at any depth. */
101
+ isHelper(threadId: string): boolean;
102
+ /**
103
+ * A collaboration item, on the session's own thread or on a helper's.
104
+ *
105
+ * A helper's helpers are this session's too: a spawned agent can spawn, and a
106
+ * grandchild at work keeps the session just as busy. An item from any other
107
+ * thread speaks for somebody else and is ignored.
108
+ */
109
+ onItem(senderThreadId: string, item: Record<string, unknown>): void;
110
+ /**
111
+ * A helper's own turn started or ended.
112
+ *
113
+ * Only for a helper already known: a thread's turn says it is working, not
114
+ * whose it is, and Codex runs threads of its own (review, compaction) that are
115
+ * nobody's helpers.
116
+ */
117
+ onHelperTurn(threadId: string, phase: 'started' | 'completed', turnStatus?: string): void;
118
+ /** A helper's thread was closed. */
119
+ onThreadClosed(threadId: string): void;
120
+ /**
121
+ * The session's turn ended: reset the turn's counters, say what is still
122
+ * running, then check the set against Codex.
123
+ *
124
+ * The frame goes out synchronously, BEFORE the caller emits `turn_end` – the
125
+ * supervisor decides «is this the person's turn now» on the count it holds at
126
+ * that moment. The snapshot follows in the background; if it changes the
127
+ * set, the supervisor re-reports the resting status with the new count.
128
+ */
129
+ endTurn(): void;
130
+ close(): void;
131
+ /**
132
+ * Check the set against Codex (R16).
133
+ *
134
+ * `everything` – at the end of a turn: every helper not known to be gone, plus
135
+ * every loaded thread not classified yet. Otherwise (the minute tick) only
136
+ * the ones believed running, which is what can go stale.
137
+ */
138
+ reconcile(options: {
139
+ everything: boolean;
140
+ }): Promise<void>;
141
+ private gone;
142
+ private reconcileOnce;
143
+ /** What one reading means for a helper already held. */
144
+ private absorb;
145
+ /** The helper's record, created (as finished) if it is new. */
146
+ private remember;
147
+ /**
148
+ * Move one helper to a state, and publish.
149
+ *
150
+ * The session itself is never its own helper: a helper reporting back to its
151
+ * parent names the PARENT as the agent it «interacted» with
152
+ * (`agentThreadId` = the session's thread, `agentPath: "/root"`) – measured
153
+ * on 0.154.0, and counted it would keep the session «busy» for ever.
154
+ */
155
+ private apply;
156
+ /** New facts about a helper, onto its row if it has one. */
157
+ private learn;
158
+ /**
159
+ * Ask Codex for the helper's name once, right after it appears. The activity
160
+ * item carries only its path (`/root/a`); the name Codex gives it
161
+ * («Heisenberg») is what Codex's own interface calls it.
162
+ */
163
+ private askName;
164
+ private syncLive;
165
+ private startTimer;
166
+ private stopTimer;
167
+ }
168
+ export {};
169
+ //# sourceMappingURL=codex-subagents.d.ts.map