@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
}
|