@animalabs/connectome-host 0.3.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.
Files changed (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Session manager — isolated Chronicle stores with a JSON index.
3
+ *
4
+ * Each session is an independent Chronicle store directory under
5
+ * `{dataDir}/sessions/{id}/`. The index file `{dataDir}/sessions.json`
6
+ * tracks metadata (name, timestamps, manual naming flag).
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { randomBytes } from 'node:crypto';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface SessionMeta {
18
+ id: string;
19
+ name: string;
20
+ manuallyNamed: boolean;
21
+ createdAt: string;
22
+ lastAccessedAt: string;
23
+ messageCount?: number;
24
+ }
25
+
26
+ export interface SessionIndex {
27
+ version: 1;
28
+ activeSessionId: string;
29
+ sessions: Record<string, SessionMeta>;
30
+ }
31
+
32
+ /**
33
+ * Provenance recorded by the claude.ai importer alongside each session.
34
+ * Fields are optional because older sessions imported before a given field
35
+ * existed will simply omit it. Callers reading this should treat every
36
+ * property as "may be missing" and provide their own fallbacks.
37
+ *
38
+ * Lives in `{dataDir}/sessions/{id}.import-source.json`, NOT inside the
39
+ * Chronicle store directory. Written once by the importer, never updated.
40
+ */
41
+ export interface ImportSource {
42
+ conversationUuid?: string;
43
+ name?: string;
44
+ summary?: string | null;
45
+ /** Original `conv.created_at` from the export. */
46
+ createdAt?: string;
47
+ /** Original `conv.updated_at` from the export. */
48
+ updatedAt?: string;
49
+ /**
50
+ * Participant name assigned to assistant turns at import time. Warmup and
51
+ * conhost both read this so they don't disagree about the agent's identity
52
+ * — disagreement leaves strategy state in the wrong Chronicle namespace
53
+ * and the live agent silently amnesiac.
54
+ */
55
+ agentName?: string;
56
+ originalMessageCount?: number;
57
+ importedMessageCount?: number;
58
+ branched?: boolean;
59
+ importedAt?: string;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // SessionManager
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export class SessionManager {
67
+ private readonly dataDir: string;
68
+ private readonly indexPath: string;
69
+ private readonly sessionsDir: string;
70
+
71
+ constructor(dataDir: string) {
72
+ this.dataDir = dataDir;
73
+ this.indexPath = join(dataDir, 'sessions.json');
74
+ this.sessionsDir = join(dataDir, 'sessions');
75
+ }
76
+
77
+ /** Load the session index from disk. Returns empty index if file doesn't exist. */
78
+ load(): SessionIndex {
79
+ if (!existsSync(this.indexPath)) {
80
+ return { version: 1, activeSessionId: '', sessions: {} };
81
+ }
82
+ const raw = readFileSync(this.indexPath, 'utf-8');
83
+ return JSON.parse(raw) as SessionIndex;
84
+ }
85
+
86
+ /** Persist the session index to disk. */
87
+ save(index: SessionIndex): void {
88
+ mkdirSync(this.dataDir, { recursive: true });
89
+ writeFileSync(this.indexPath, JSON.stringify(index, null, 2) + '\n');
90
+ }
91
+
92
+ /** Generate a short random hex ID. */
93
+ private generateId(): string {
94
+ return randomBytes(4).toString('hex'); // 8 hex chars
95
+ }
96
+
97
+ /** Create a new session and persist the index. Returns the new session metadata. */
98
+ createSession(name?: string): SessionMeta {
99
+ const index = this.load();
100
+ const id = this.generateId();
101
+ const now = new Date().toISOString();
102
+
103
+ const session: SessionMeta = {
104
+ id,
105
+ name: name ?? `Session ${Object.keys(index.sessions).length + 1}`,
106
+ manuallyNamed: !!name,
107
+ createdAt: now,
108
+ lastAccessedAt: now,
109
+ };
110
+
111
+ // Ensure the sessions parent directory exists.
112
+ // Don't create the store directory itself — JsStore.openOrCreate needs it
113
+ // absent to know it should create (not open) the store.
114
+ mkdirSync(this.sessionsDir, { recursive: true });
115
+
116
+ index.sessions[id] = session;
117
+ index.activeSessionId = id;
118
+ this.save(index);
119
+
120
+ return session;
121
+ }
122
+
123
+ /** Delete a session (removes store directory and index entry). Cannot delete the active session. */
124
+ deleteSession(id: string): void {
125
+ const index = this.load();
126
+ if (!index.sessions[id]) {
127
+ throw new Error(`Session "${id}" not found`);
128
+ }
129
+ if (index.activeSessionId === id) {
130
+ throw new Error('Cannot delete the active session');
131
+ }
132
+
133
+ const storePath = this.getStorePath(id);
134
+ if (existsSync(storePath)) {
135
+ rmSync(storePath, { recursive: true, force: true });
136
+ }
137
+
138
+ delete index.sessions[id];
139
+ this.save(index);
140
+ }
141
+
142
+ /** Rename a session and mark it as manually named. */
143
+ renameSession(id: string, name: string, manual = true): void {
144
+ const index = this.load();
145
+ const session = index.sessions[id];
146
+ if (!session) {
147
+ throw new Error(`Session "${id}" not found`);
148
+ }
149
+
150
+ session.name = name;
151
+ if (manual) session.manuallyNamed = true;
152
+ this.save(index);
153
+ }
154
+
155
+ /** Get the Chronicle store directory path for a session. */
156
+ getStorePath(id: string): string {
157
+ return join(this.sessionsDir, id);
158
+ }
159
+
160
+ /**
161
+ * Read the import-source sidecar for a session, if one exists.
162
+ *
163
+ * Returns null when the session wasn't created by the importer (no
164
+ * sidecar), when the file can't be read, when its contents don't parse
165
+ * as JSON, or when the parsed value isn't a plain object (e.g. someone
166
+ * wrote `null` or an array). The caller gets a partial record — every
167
+ * field is optional, because sidecars written by older importer
168
+ * versions may be missing newer fields.
169
+ *
170
+ * Field-level types are NOT validated here: this is a filesystem trust
171
+ * boundary, but field-shape regressions would touch each consumer's
172
+ * usage site anyway, and the typeof-string guards in
173
+ * `resolveAgentName`/inline at call sites catch the values that
174
+ * actually flow through.
175
+ */
176
+ getImportSource(id: string): ImportSource | null {
177
+ const path = join(this.sessionsDir, `${id}.import-source.json`);
178
+ if (!existsSync(path)) return null;
179
+ try {
180
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'));
181
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
182
+ return null;
183
+ }
184
+ return parsed as ImportSource;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /** Get the currently active session, or null if none. */
191
+ getActiveSession(): SessionMeta | null {
192
+ const index = this.load();
193
+ if (!index.activeSessionId) return null;
194
+ return index.sessions[index.activeSessionId] ?? null;
195
+ }
196
+
197
+ /** Set the active session and update its lastAccessedAt. */
198
+ setActiveSession(id: string): void {
199
+ const index = this.load();
200
+ if (!index.sessions[id]) {
201
+ throw new Error(`Session "${id}" not found`);
202
+ }
203
+ index.activeSessionId = id;
204
+ index.sessions[id]!.lastAccessedAt = new Date().toISOString();
205
+ this.save(index);
206
+ }
207
+
208
+ /** Find a session by name or ID. */
209
+ findSession(nameOrId: string): SessionMeta | null {
210
+ const index = this.load();
211
+ // Exact ID match
212
+ if (index.sessions[nameOrId]) return index.sessions[nameOrId]!;
213
+ // Name match (case-insensitive)
214
+ for (const session of Object.values(index.sessions)) {
215
+ if (session.name.toLowerCase() === nameOrId.toLowerCase()) return session;
216
+ }
217
+ // Prefix match on ID
218
+ for (const session of Object.values(index.sessions)) {
219
+ if (session.id.startsWith(nameOrId)) return session;
220
+ }
221
+ return null;
222
+ }
223
+
224
+ /** List all sessions, sorted by lastAccessedAt descending. */
225
+ listSessions(): SessionMeta[] {
226
+ const index = this.load();
227
+ return Object.values(index.sessions)
228
+ .sort((a, b) => b.lastAccessedAt.localeCompare(a.lastAccessedAt));
229
+ }
230
+
231
+ /**
232
+ * Migrate legacy single-store layout to session-based layout.
233
+ *
234
+ * If `{dataDir}/store/` exists but `{dataDir}/sessions.json` doesn't,
235
+ * moves the store into `{dataDir}/sessions/{id}/` and creates the index.
236
+ */
237
+ migrateIfNeeded(): void {
238
+ if (existsSync(this.indexPath)) return;
239
+
240
+ const legacyStorePath = join(this.dataDir, 'store');
241
+ if (!existsSync(legacyStorePath)) return;
242
+
243
+ const id = this.generateId();
244
+ const now = new Date().toISOString();
245
+
246
+ // Move legacy store into sessions directory
247
+ mkdirSync(this.sessionsDir, { recursive: true });
248
+ const newPath = join(this.sessionsDir, id);
249
+ renameSync(legacyStorePath, newPath);
250
+
251
+ const index: SessionIndex = {
252
+ version: 1,
253
+ activeSessionId: id,
254
+ sessions: {
255
+ [id]: {
256
+ id,
257
+ name: 'Initial Session',
258
+ manuallyNamed: false,
259
+ createdAt: now,
260
+ lastAccessedAt: now,
261
+ },
262
+ },
263
+ };
264
+
265
+ this.save(index);
266
+ console.log(`Migrated legacy store to session "${id}" (Initial Session)`);
267
+ }
268
+
269
+ /** Update the message count snapshot for a session. */
270
+ updateMessageCount(id: string, count: number): void {
271
+ const index = this.load();
272
+ const session = index.sessions[id];
273
+ if (session) {
274
+ session.messageCount = count;
275
+ this.save(index);
276
+ }
277
+ }
278
+ }
@@ -0,0 +1,431 @@
1
+ /**
2
+ * AgentTreeReducer — folds framework TraceEvents into a tree of agent nodes
3
+ * with phase / token / tool-count / parent-edge state.
4
+ *
5
+ * Single fold logic, three call sites:
6
+ * 1. Parent process, against local `framework.onTrace()` — drives local TUI.
7
+ * 2. Parent process, against `fleetModule.onChildEvent(name, ...)` — one
8
+ * reducer per fleet child, drives that child's subtree in the TUI.
9
+ * 3. Inside each headless child, against its own `framework.onTrace()` —
10
+ * `describe` IPC handler returns `reducer.getTree()` over the wire.
11
+ *
12
+ * Mirrors the canonical fold currently scattered across:
13
+ * - tui.ts:1019-1037 (token aggregation on inference:usage / completed)
14
+ * - tui.ts:1280-1341 (phase transitions)
15
+ * - tui.ts:1100-1107 (parent-edge inference from subagent--spawn calls)
16
+ * - subagent-module.ts:262-340 (callId routing, live state tracking)
17
+ *
18
+ * The dispatch table EVENT_HANDLERS is the canonical source of truth: each
19
+ * key is an event type the reducer acts on, and `REDUCER_REQUIRED_EVENTS`
20
+ * is derived from `Object.keys(EVENT_HANDLERS)`. Adding a new case requires
21
+ * adding to the table; the wire-level subscription floor enforced by
22
+ * FleetModule picks up the addition automatically. There is no parallel
23
+ * "list of events the reducer needs" to keep in sync by hand.
24
+ */
25
+
26
+ import type { TraceEvent } from '@animalabs/agent-framework';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Public types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export type AgentPhase =
33
+ | 'idle'
34
+ | 'sending'
35
+ | 'streaming'
36
+ | 'invoking'
37
+ | 'executing'
38
+ | 'done'
39
+ | 'failed'
40
+ // Terminal-but-benign: user cancel, zombie-reclaim, supersession, agent
41
+ // reboot, budget-restart. Postmortem 2026-05-28 P1 #3: renderers must
42
+ // treat this as neutral (not red) and terminal (not 'running'). It is the
43
+ // result of `inference:aborted` and reboot-induced `inference:exhausted`,
44
+ // both of which are NOT faults.
45
+ | 'cancelled';
46
+
47
+ export type AgentKind = 'framework' | 'subagent';
48
+
49
+ export type AgentStatus = 'running' | 'completed' | 'failed' | 'cancelled';
50
+
51
+ export interface AgentTokens {
52
+ /** Last-seen input tokens. Represents *current context window size*, not cumulative. */
53
+ input: number;
54
+ /** Cumulative output tokens across all rounds. */
55
+ output: number;
56
+ /** Cumulative cache-read tokens. */
57
+ cacheRead: number;
58
+ /** Cumulative cache-write (creation) tokens. */
59
+ cacheWrite: number;
60
+ }
61
+
62
+ export interface AgentNode {
63
+ /** Stable identifier within this reducer's scope. For framework agents this is
64
+ * the framework's agent name. For subagents it's the spawn/fork display name. */
65
+ name: string;
66
+ kind: AgentKind;
67
+ /** Subagent-only: spawn vs. fork (fork inherits parent context). */
68
+ subagentType?: 'spawn' | 'fork';
69
+ /** Subagent-only: the task string from the spawning tool call. */
70
+ task?: string;
71
+ /** Parent agent name (the agent that spawned this one), if any. */
72
+ parent?: string;
73
+ status: AgentStatus;
74
+ phase: AgentPhase;
75
+ tokens: AgentTokens;
76
+ toolCallsCount: number;
77
+ findingsCount: number;
78
+ startedAt?: number;
79
+ completedAt?: number;
80
+ lastEventAt?: number;
81
+ }
82
+
83
+ export interface AgentTreeSnapshot {
84
+ /** Wall-clock at which this snapshot was produced. Receivers should drop
85
+ * events with `timestamp < asOfTs` after applying. */
86
+ asOfTs: number;
87
+ nodes: AgentNode[];
88
+ /** callId → agentName, for routing tool:* events that arrive after the snapshot. */
89
+ callIdIndex: Record<string, string>;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Internal helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function freshTokens(): AgentTokens {
97
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
98
+ }
99
+
100
+ interface SpawnCallInput {
101
+ name?: string;
102
+ task?: string;
103
+ prompt?: string;
104
+ }
105
+
106
+ interface AnyEvent {
107
+ type: string;
108
+ agentName?: string;
109
+ callId?: string;
110
+ timestamp?: number;
111
+ [k: string]: unknown;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Event handler table — single source of truth for "which events does the
116
+ // reducer act on" and (by extension) "which events must the wire deliver."
117
+ // ---------------------------------------------------------------------------
118
+
119
+ type EventHandler = (r: AgentTreeReducer, e: AnyEvent, ts: number) => void;
120
+
121
+ /** The dispatch table. `applyEvent` is just a Map lookup over this; recipes
122
+ * can't accidentally turn off rendering because `REDUCER_REQUIRED_EVENTS`
123
+ * (below) is derived from the keys here. */
124
+ const EVENT_HANDLERS: Record<string, EventHandler> = {
125
+ 'inference:started': (r, e, ts) => {
126
+ if (!e.agentName) return;
127
+ const node = r._ensureNode(e.agentName);
128
+ node.phase = 'sending';
129
+ node.status = 'running';
130
+ node.lastEventAt = ts;
131
+ if (node.startedAt === undefined) node.startedAt = ts;
132
+ },
133
+
134
+ 'inference:tokens': (r, e, ts) => {
135
+ if (!e.agentName) return;
136
+ const node = r._ensureNode(e.agentName);
137
+ node.phase = 'streaming';
138
+ node.lastEventAt = ts;
139
+ },
140
+
141
+ 'inference:tool_calls_yielded': (r, e, ts) => {
142
+ if (!e.agentName) return;
143
+ const node = r._ensureNode(e.agentName);
144
+ node.phase = 'invoking';
145
+ node.lastEventAt = ts;
146
+ const calls = (e.calls as Array<{ id: string; name: string; input?: unknown }> | undefined) ?? [];
147
+ for (const call of calls) {
148
+ r._setCallIdAgent(call.id, e.agentName);
149
+ // Edge inference: subagent--spawn / subagent--fork / fleet--launch
150
+ // tool calls create a parent edge from the calling agent to the child.
151
+ if (call.name === 'subagent--spawn' || call.name === 'subagent--fork') {
152
+ const childName = (call.input as SpawnCallInput | undefined)?.name;
153
+ if (childName) {
154
+ const child = r._ensureNode(childName, 'subagent');
155
+ child.parent = e.agentName;
156
+ child.subagentType = call.name === 'subagent--fork' ? 'fork' : 'spawn';
157
+ const inp = call.input as SpawnCallInput | undefined;
158
+ if (inp?.task) child.task = inp.task;
159
+ else if (inp?.prompt) child.task = inp.prompt;
160
+ if (child.startedAt === undefined) child.startedAt = ts;
161
+ }
162
+ } else if (call.name === 'fleet--launch') {
163
+ const childName = (call.input as SpawnCallInput | undefined)?.name;
164
+ if (childName) {
165
+ const child = r._ensureNode(childName, 'framework');
166
+ child.parent = e.agentName;
167
+ }
168
+ }
169
+ }
170
+ },
171
+
172
+ 'inference:usage': (r, e, ts) => {
173
+ if (!e.agentName) return;
174
+ const node = r._ensureNode(e.agentName);
175
+ const usage = e.tokenUsage as { input?: number; output?: number; cacheRead?: number; cacheCreation?: number } | undefined;
176
+ if (usage) r._applyTokenUsage(node, usage);
177
+ node.lastEventAt = ts;
178
+ },
179
+
180
+ 'inference:completed': (r, e, ts) => {
181
+ if (!e.agentName) return;
182
+ const node = r._ensureNode(e.agentName);
183
+ node.phase = 'done';
184
+ // Postmortem 2026-05-28 F1: writing 'completed' here closes a one-way
185
+ // failed latch. Before this, the only status transitions were
186
+ // running → failed (via aborted/exhausted/failed) and the only way out
187
+ // was a fresh inference:started. So a benign abort followed by a clean
188
+ // completion left the admin UI red forever for that node.
189
+ node.status = 'completed';
190
+ node.completedAt = ts;
191
+ node.lastEventAt = ts;
192
+ const usage = e.tokenUsage as { input?: number; output?: number; cacheRead?: number; cacheCreation?: number } | undefined;
193
+ if (usage) r._applyTokenUsage(node, usage);
194
+ },
195
+
196
+ 'inference:failed': (r, e, ts) => {
197
+ if (!e.agentName) return;
198
+ const node = r._ensureNode(e.agentName);
199
+ node.phase = 'failed';
200
+ node.status = 'failed';
201
+ node.completedAt = ts;
202
+ node.lastEventAt = ts;
203
+ },
204
+
205
+ // inference:exhausted = budget exhaustion / stream-side cancel / reboot-
206
+ // induced. Postmortem 2026-05-28 P1 #3: not strictly a fault — flip both
207
+ // fields to the dedicated 'cancelled' terminal state so the renderer can
208
+ // distinguish "stopped on purpose" from "errored out".
209
+ 'inference:exhausted': (r, e, ts) => {
210
+ if (!e.agentName) return;
211
+ const node = r._ensureNode(e.agentName);
212
+ node.phase = 'cancelled';
213
+ node.status = 'cancelled';
214
+ node.completedAt = ts;
215
+ node.lastEventAt = ts;
216
+ },
217
+
218
+ // inference:aborted = user cancel / zombie-reclaim / supersession.
219
+ // Postmortem 2026-05-28 P1 #3: same as exhausted — terminal, but benign.
220
+ 'inference:aborted': (r, e, ts) => {
221
+ if (!e.agentName) return;
222
+ const node = r._ensureNode(e.agentName);
223
+ node.phase = 'cancelled';
224
+ node.status = 'cancelled';
225
+ node.completedAt = ts;
226
+ node.lastEventAt = ts;
227
+ },
228
+
229
+ 'inference:stream_resumed': (r, e, ts) => {
230
+ if (!e.agentName) return;
231
+ r._ensureNode(e.agentName).lastEventAt = ts;
232
+ },
233
+
234
+ 'inference:stream_restarted': (r, e, ts) => {
235
+ if (!e.agentName) return;
236
+ r._ensureNode(e.agentName).lastEventAt = ts;
237
+ },
238
+
239
+ 'inference:turn_ended': (r, e, ts) => {
240
+ if (!e.agentName) return;
241
+ r._ensureNode(e.agentName).lastEventAt = ts;
242
+ },
243
+
244
+ 'tool:started': (r, e, ts) => {
245
+ if (typeof e.callId !== 'string') return;
246
+ const agentName = r._getCallIdAgent(e.callId);
247
+ if (!agentName) return;
248
+ const node = r._ensureNode(agentName);
249
+ node.phase = 'executing';
250
+ node.toolCallsCount += 1;
251
+ node.lastEventAt = ts;
252
+ },
253
+
254
+ 'tool:completed': (r, e, ts) => {
255
+ if (typeof e.callId !== 'string') return;
256
+ const agentName = r._getCallIdAgent(e.callId);
257
+ if (!agentName) return;
258
+ r._ensureNode(agentName).lastEventAt = ts;
259
+ // Phase stays 'executing' until the next inference:* event re-binds it.
260
+ // Mirrors tui.ts behavior where tool:completed only clears the per-agent
261
+ // current-tool indicator without changing the high-level phase.
262
+ },
263
+
264
+ 'tool:failed': (r, e, ts) => {
265
+ if (typeof e.callId !== 'string') return;
266
+ const agentName = r._getCallIdAgent(e.callId);
267
+ if (!agentName) return;
268
+ r._ensureNode(agentName).lastEventAt = ts;
269
+ },
270
+ };
271
+
272
+ /**
273
+ * The minimum set of framework TraceEvent types this reducer needs to fold an
274
+ * accurate per-agent tree. **Derived** from `EVENT_HANDLERS` at module load —
275
+ * adding a new case to the table automatically propagates here, so the wire-
276
+ * subscription floor in FleetModule (`unionWithReducerRequired`) self-extends
277
+ * with no manual constant maintenance.
278
+ *
279
+ * If a new event type is wired into the reducer, no other file needs to
280
+ * change for the wire to start delivering it.
281
+ */
282
+ export const REDUCER_REQUIRED_EVENTS: readonly string[] = Object.freeze(Object.keys(EVENT_HANDLERS));
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Reducer
286
+ // ---------------------------------------------------------------------------
287
+
288
+ export class AgentTreeReducer {
289
+ private nodes = new Map<string, AgentNode>();
290
+ /** callId → agentName, populated from inference:tool_calls_yielded. Tool events
291
+ * carry only callId, no agentName, so we route them through here. */
292
+ private callIdIndex = new Map<string, string>();
293
+
294
+ /** Pre-register top-level framework agents so the tree shows them before
295
+ * they emit any events. Lazy creation also works on first event. */
296
+ seedFrameworkAgents(names: string[]): void {
297
+ for (const name of names) {
298
+ if (!this.nodes.has(name)) {
299
+ this.nodes.set(name, this.makeNode(name, 'framework'));
300
+ }
301
+ }
302
+ }
303
+
304
+ applyEvent(event: TraceEvent | { type: string }): void {
305
+ // We only branch on event.type and read fields the handler table expects;
306
+ // fields outside the type's static shape are accessed dynamically. Widen
307
+ // here so callers can pass any tagged event object (TraceEvent, WireEvent,
308
+ // synthetic test events) without an `as never` escape hatch.
309
+ const e = event as AnyEvent;
310
+ const ts = typeof e.timestamp === 'number' ? e.timestamp : Date.now();
311
+ const handler = EVENT_HANDLERS[e.type];
312
+ if (!handler) return;
313
+ handler(this, e, ts);
314
+ }
315
+
316
+ applySnapshot(snapshot: AgentTreeSnapshot): void {
317
+ this.nodes.clear();
318
+ this.callIdIndex.clear();
319
+ for (const node of snapshot.nodes) {
320
+ // Defensive copy so subsequent mutations don't escape into caller's data.
321
+ this.nodes.set(node.name, {
322
+ ...node,
323
+ tokens: { ...node.tokens },
324
+ });
325
+ }
326
+ for (const [callId, agentName] of Object.entries(snapshot.callIdIndex)) {
327
+ this.callIdIndex.set(callId, agentName);
328
+ }
329
+ }
330
+
331
+ reset(): void {
332
+ this.nodes.clear();
333
+ this.callIdIndex.clear();
334
+ }
335
+
336
+ /** Returns a deep copy of the current tree state plus the asOfTs marker
337
+ * needed for receivers to dedupe events. */
338
+ getSnapshot(): AgentTreeSnapshot {
339
+ return {
340
+ asOfTs: Date.now(),
341
+ nodes: this.getNodes(),
342
+ callIdIndex: Object.fromEntries(this.callIdIndex),
343
+ };
344
+ }
345
+
346
+ /** Returns a deep copy of all current nodes. */
347
+ getNodes(): AgentNode[] {
348
+ return [...this.nodes.values()].map(n => ({
349
+ ...n,
350
+ tokens: { ...n.tokens },
351
+ }));
352
+ }
353
+
354
+ getNode(name: string): AgentNode | undefined {
355
+ const n = this.nodes.get(name);
356
+ if (!n) return undefined;
357
+ return { ...n, tokens: { ...n.tokens } };
358
+ }
359
+
360
+ /** Returns the children of a given agent (one level deep).
361
+ * Iterates the live map directly — no full tree clone. */
362
+ getChildren(parentName: string): AgentNode[] {
363
+ const out: AgentNode[] = [];
364
+ for (const n of this.nodes.values()) {
365
+ if (n.parent === parentName) out.push({ ...n, tokens: { ...n.tokens } });
366
+ }
367
+ return out;
368
+ }
369
+
370
+ /** Returns top-level (parent-less) nodes. */
371
+ getRoots(): AgentNode[] {
372
+ const out: AgentNode[] = [];
373
+ for (const n of this.nodes.values()) {
374
+ if (n.parent === undefined) out.push({ ...n, tokens: { ...n.tokens } });
375
+ }
376
+ return out;
377
+ }
378
+
379
+ // ----- @internal: exposed for the module-scope EVENT_HANDLERS table. ----
380
+ // Underscore-prefixed by convention; not part of the public API.
381
+ // External callers should use the public read methods (getNode, getNodes,
382
+ // getChildren, getRoots) and applyEvent / applySnapshot for mutation.
383
+
384
+ /** @internal */
385
+ _ensureNode(name: string, kindHint: AgentKind = 'framework'): AgentNode {
386
+ let node = this.nodes.get(name);
387
+ if (!node) {
388
+ node = this.makeNode(name, kindHint);
389
+ this.nodes.set(name, node);
390
+ }
391
+ return node;
392
+ }
393
+
394
+ /** @internal */
395
+ _setCallIdAgent(callId: string, agentName: string): void {
396
+ this.callIdIndex.set(callId, agentName);
397
+ }
398
+
399
+ /** @internal */
400
+ _getCallIdAgent(callId: string): string | undefined {
401
+ return this.callIdIndex.get(callId);
402
+ }
403
+
404
+ /** @internal */
405
+ _applyTokenUsage(
406
+ node: AgentNode,
407
+ usage: { input?: number; output?: number; cacheRead?: number; cacheCreation?: number },
408
+ ): void {
409
+ // Input represents context window size at this round; overwrite, don't sum
410
+ // (summing inputs would double-count history that's already in the next round's input).
411
+ if (typeof usage.input === 'number') node.tokens.input = usage.input;
412
+ // Output / cache are per-round costs; accumulate.
413
+ if (typeof usage.output === 'number') node.tokens.output += usage.output;
414
+ if (typeof usage.cacheRead === 'number') node.tokens.cacheRead += usage.cacheRead;
415
+ if (typeof usage.cacheCreation === 'number') node.tokens.cacheWrite += usage.cacheCreation;
416
+ }
417
+
418
+ // ----- private --------------------------------------------------------
419
+
420
+ private makeNode(name: string, kind: AgentKind): AgentNode {
421
+ return {
422
+ name,
423
+ kind,
424
+ status: 'running',
425
+ phase: 'idle',
426
+ tokens: freshTokens(),
427
+ toolCallsCount: 0,
428
+ findingsCount: 0,
429
+ };
430
+ }
431
+ }