@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,2595 @@
1
+ /**
2
+ * WebUiModule — serves a single-page web admin UI plus a JSON-over-WebSocket
3
+ * control plane. Mirrors what TuiModule provides for the terminal: a way to
4
+ * see the conversation, the agent tree, and to issue user messages and slash
5
+ * commands. Designed for remote admin over a VPN, fronted by a reverse proxy
6
+ * for TLS and outer auth.
7
+ *
8
+ * Lifecycle:
9
+ * - Module's `start()` opens a Bun.serve HTTP+WS server.
10
+ * - `setApp()` (called from index.ts after framework creation) plugs in the
11
+ * full AppContext so slash commands, sessions, and branch state work.
12
+ * - `start()` is intentionally tolerant of `setApp` being called late: WS
13
+ * clients that connect before app-binding are parked until binding lands.
14
+ *
15
+ * Decoupled transport: the module speaks plain HTTP. TLS / external auth /
16
+ * fan-out across many VMs are the reverse-proxy's job; an optional Basic-Auth
17
+ * check is available as defense-in-depth.
18
+ *
19
+ * See WEBUI-PLAN.md and src/web/protocol.ts for the wire shape.
20
+ */
21
+
22
+ import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
23
+ import type {
24
+ AgentFramework,
25
+ Module,
26
+ ModuleContext,
27
+ ProcessEvent,
28
+ ProcessState,
29
+ EventResponse,
30
+ ToolDefinition,
31
+ ToolCall,
32
+ ToolResult,
33
+ TraceEvent,
34
+ SessionUsageSnapshot,
35
+ } from '@animalabs/agent-framework';
36
+ import type { ServerWebSocket } from 'bun';
37
+ import { readFile, stat } from 'node:fs/promises';
38
+ import { join, resolve, normalize, dirname, sep as pathSep } from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
40
+ import { createHash, timingSafeEqual } from 'node:crypto';
41
+ import type { Recipe } from '../recipe.js';
42
+ import type { SessionManager } from '../session-manager.js';
43
+ import type { BranchState } from '../commands.js';
44
+ import { handleCommand } from '../commands.js';
45
+ import { AgentTreeReducer, type AgentTreeSnapshot } from '../state/agent-tree-reducer.js';
46
+ import { FleetTreeAggregator } from '../state/fleet-tree-aggregator.js';
47
+ import type { FleetModule } from './fleet-module.js';
48
+ import type { WireEvent } from './fleet-types.js';
49
+ import {
50
+ WEB_PROTOCOL_VERSION,
51
+ isClientMessage,
52
+ type WebUiServerMessage,
53
+ type WelcomeMessage,
54
+ type WelcomeMessageEntry,
55
+ type RequestHistoryMessage,
56
+ type TokenUsage,
57
+ type McplListMessage,
58
+ type LessonsListMessage,
59
+ } from '../web/protocol.js';
60
+ import {
61
+ readMcplServersFile,
62
+ saveMcplServers,
63
+ DEFAULT_CONFIG_PATH,
64
+ } from '../mcpl-config.js';
65
+ import { loadRecipe } from '../recipe.js';
66
+
67
+ /**
68
+ * Minimal slice of AppContext the module needs. Defined locally to avoid
69
+ * importing the full type from index.ts (which would create a cycle).
70
+ */
71
+ export interface WebUiAppRef {
72
+ framework: AgentFramework;
73
+ sessionManager: SessionManager;
74
+ recipe: Recipe;
75
+ branchState: BranchState;
76
+ switchSession(id: string): Promise<void>;
77
+ }
78
+
79
+ export interface WebUiModuleConfig {
80
+ /** TCP port to bind. Default: 7340. */
81
+ port?: number;
82
+ /**
83
+ * Host to bind. Default: 0.0.0.0 — connectome deployments are remote, not
84
+ * local. Any non-loopback bind (which includes the default) hard-requires
85
+ * `basicAuth`; the server refuses to start otherwise. Set explicitly to
86
+ * `127.0.0.1` for local development, which skips the auth requirement.
87
+ */
88
+ host?: string;
89
+ /**
90
+ * Basic-Auth credentials. Mandatory for any non-loopback bind (the default).
91
+ * Sourced from `${VAR}` substitution at recipe load time — never commit
92
+ * literal credentials to a recipe.
93
+ */
94
+ basicAuth?: { username: string; password: string };
95
+ /** Path to the SPA build output. Default: `<cwd>/dist/web`. */
96
+ staticDir?: string;
97
+ /**
98
+ * Origin allowlist for the WebSocket upgrade. Browsers do not enforce
99
+ * same-origin on `new WebSocket(...)` the way they do on fetch, so without
100
+ * an explicit Origin check any page the operator opens in another tab
101
+ * could connect to a localhost-bound /ws and drive the host. Default:
102
+ * `http://127.0.0.1:<port>`, `http://localhost:<port>`, plus the matching
103
+ * `https://` forms. Override when fronted by a reverse proxy that rewrites
104
+ * Origin (e.g. `["https://admin.example.com"]`).
105
+ *
106
+ * Set explicitly to `[]` to allow any Origin (or none) — only sensible
107
+ * when the host is behind a proxy that already enforces Origin or when
108
+ * the entire host is firewalled off from browsers.
109
+ */
110
+ allowedOrigins?: string[];
111
+ }
112
+
113
+ /** Per-connection state. */
114
+ interface ClientState {
115
+ /** Stable id matching ws.data.id; used for routing fleet IPC responses. */
116
+ id: number;
117
+ ws: ServerWebSocket<{ id: number }>;
118
+ /** True after we've sent the welcome message. */
119
+ welcomed: boolean;
120
+ /** Open peek subscriptions for this client, keyed by scope. Each entry
121
+ * carries its detacher so unsubscribe and disconnect both clean up
122
+ * without the framework leaking listeners. */
123
+ peeks: Map<string, () => void>;
124
+ }
125
+
126
+ /** Default port — picked to be memorable and unlikely to collide. */
127
+ const DEFAULT_PORT = 7340;
128
+
129
+ /** Messages shipped in the welcome frame (the tail window). */
130
+ const WELCOME_HISTORY_LIMIT = 200;
131
+ /** Default / max page size for request-history. */
132
+ const HISTORY_PAGE_DEFAULT = 200;
133
+ const HISTORY_PAGE_MAX = 500;
134
+
135
+ /**
136
+ * Structural view of the windowed-read facade added to
137
+ * @animalabs/context-manager alongside this protocol version. Typed
138
+ * structurally (not imported) so the host keeps running against older
139
+ * context-manager copies on boxes — call sites feature-detect.
140
+ */
141
+ interface WindowCapableCm {
142
+ getMessageCount(): number;
143
+ getMessageWindow(
144
+ offset: number,
145
+ limit: number,
146
+ opts?: { resolveBlobs?: boolean; alignToBodyGroups?: boolean },
147
+ ): { messages: unknown[]; startIndex: number; totalCount: number };
148
+ onMessage?(listener: (event: { type: string; message?: unknown }) => void): () => void;
149
+ }
150
+
151
+ /**
152
+ * Default bind host. Connectome deployments are remote (none are local), so
153
+ * we bind all interfaces by default. This is a non-loopback bind, so it
154
+ * hard-requires `basicAuth` via `assertSafeBind` — the server refuses to
155
+ * start without credentials. Override with `host: '127.0.0.1'` for local dev.
156
+ */
157
+ const DEFAULT_HOST = '0.0.0.0';
158
+
159
+ /**
160
+ * Process-level singleton state. The HTTP server, WS clients, and accumulated
161
+ * usage snapshot must outlive any single framework instance — session-switch
162
+ * rebuilds the framework (and thus the WebUiModule), but the open WebSocket
163
+ * connections need to stay up. Module instances bind to the singleton on
164
+ * `start()` and rebind their AppContext on `setApp()`; the server itself
165
+ * never restarts within a process lifetime.
166
+ */
167
+ interface SharedServerState {
168
+ server: ReturnType<typeof Bun.serve>;
169
+ port: number;
170
+ host: string;
171
+ staticRoot: string;
172
+ basicAuth?: { username: string; password: string };
173
+ /** Resolved origin allowlist. Empty array means "no Origin check". */
174
+ allowedOrigins: string[];
175
+ clients: Map<number, ClientState>;
176
+ nextClientId: number;
177
+ /** Aggregate session usage published to clients: parent's own totals plus
178
+ * the most-recent `usage:updated` totals reported by each fleet child.
179
+ * Recomputed from `parentUsage` + `childUsage` on every relevant event. */
180
+ latestUsage: TokenUsage;
181
+ /** Parent process' own session totals (from local `usage:updated`). Kept
182
+ * separately so child-side updates don't clobber the parent's number when
183
+ * recomputing the aggregate. */
184
+ parentUsage: TokenUsage;
185
+ /** Most-recent `usage:updated` totals per fleet child, keyed by childName.
186
+ * Each entry overwrites on the next event from that child — children's
187
+ * UsageTrackers already emit cumulative session totals, so summing across
188
+ * the map gives the fleet-wide total without double-counting rounds. */
189
+ childUsage: Map<string, TokenUsage>;
190
+ /** Per-agent cost breakdown captured alongside latestUsage. Re-derived on
191
+ * every usage:updated event so the welcome and live UsageMessage frames
192
+ * carry consistent values. */
193
+ latestPerAgentCost: import('../web/protocol.js').PerAgentCost[];
194
+ /** Currently-bound app, refreshed on every setApp() call. WS handlers read
195
+ * from here so the singleton always points at the live framework regardless
196
+ * of which WebUiModule instance is "active". */
197
+ app: WebUiAppRef | null;
198
+ /** Per-bind aggregator and fleet detacher. Re-created in setApp; cleared
199
+ * in stop(). Lives on the singleton so old WebUiModule instances don't
200
+ * retain handles to dead frameworks. */
201
+ treeAggregator: FleetTreeAggregator | null;
202
+ fleetEventDetacher: (() => void) | null;
203
+ /** Detacher for the message-store 'add' listener driving message-appended
204
+ * pushes. Re-installed on every setApp (fresh ContextManager per session)
205
+ * and cleared in stop(). */
206
+ messageListenerDetacher: (() => void) | null;
207
+ /** Cached child recipe summaries keyed by recipe path. Recipes are
208
+ * static-ish per host run (re-spawn doesn't change the file), so we
209
+ * parse once and reuse on every welcome. Cleared on session switch. */
210
+ childRecipeCache: Map<string, { name: string; description?: string; version?: string; agentModel?: string }>;
211
+ /** corrId → originating client + request kind, for routing scoped panel
212
+ * query responses (lessons / workspace) back to the requesting client.
213
+ * Entries are deleted on response or pruned by TTL. */
214
+ pendingFleetRequests: Map<string, { clientId: number; kind: string; expiresAt: number }>;
215
+ }
216
+
217
+ let sharedServer: SharedServerState | null = null;
218
+
219
+ export class WebUiModule implements Module {
220
+ readonly name = 'webui';
221
+
222
+ private readonly config: WebUiModuleConfig;
223
+
224
+ /** Serialized SPA bundle path resolved from staticDir at construction. */
225
+ private readonly staticRoot: string;
226
+
227
+ constructor(config: WebUiModuleConfig = {}) {
228
+ this.config = config;
229
+ // Default static root: <package-root>/dist/web. Derived from the module's
230
+ // own file location so the resolution is stable regardless of process cwd.
231
+ // This file lives at <package>/src/modules/web-ui-module.ts, so going up
232
+ // two levels from its directory gets us the package root.
233
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
234
+ const packageRoot = resolve(moduleDir, '..', '..');
235
+ this.staticRoot = resolve(config.staticDir ?? join(packageRoot, 'dist', 'web'));
236
+ }
237
+
238
+ // -------------------------------------------------------------------------
239
+ // Module interface
240
+ // -------------------------------------------------------------------------
241
+
242
+ async start(_ctx: ModuleContext): Promise<void> {
243
+ if (sharedServer) {
244
+ // Server already up from a previous framework lifetime. Reuse it. Config
245
+ // collisions (e.g. a different port across recipes) are out of scope —
246
+ // recipes within one process should declare consistent webui config.
247
+ return;
248
+ }
249
+
250
+ const port = this.config.port ?? DEFAULT_PORT;
251
+ const host = this.config.host ?? DEFAULT_HOST;
252
+ this.assertSafeBind(host);
253
+
254
+ const state: SharedServerState = {
255
+ server: undefined as unknown as ReturnType<typeof Bun.serve>,
256
+ port,
257
+ host,
258
+ staticRoot: this.staticRoot,
259
+ basicAuth: this.config.basicAuth,
260
+ allowedOrigins: this.config.allowedOrigins ?? defaultAllowedOrigins(port),
261
+ clients: new Map(),
262
+ nextClientId: 1,
263
+ latestUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
264
+ parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
265
+ childUsage: new Map(),
266
+ latestPerAgentCost: [],
267
+ pendingFleetRequests: new Map(),
268
+ childRecipeCache: new Map(),
269
+ app: null,
270
+ treeAggregator: null,
271
+ fleetEventDetacher: null,
272
+ messageListenerDetacher: null,
273
+ };
274
+ state.server = Bun.serve({
275
+ port,
276
+ hostname: host,
277
+ fetch: (req, server) => this.handleHttp(req, server),
278
+ websocket: {
279
+ open: (ws) => this.onWsOpen(ws as ServerWebSocket<{ id: number }>),
280
+ message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<{ id: number }>, msg),
281
+ close: (ws) => this.onWsClose(ws as ServerWebSocket<{ id: number }>),
282
+ },
283
+ });
284
+ // When port=0 is passed (test setups, ephemeral binds), the OS picks a
285
+ // free port and Bun.serve exposes it via `.port`. Re-read so the cached
286
+ // port and the default Origin allowlist match the actual listener.
287
+ // Bun's typings widen `port` to `number | undefined` for some socket
288
+ // listener types; fall back to the requested port if the runtime didn't
289
+ // expose one.
290
+ const boundPort = state.server.port ?? port;
291
+ state.port = boundPort;
292
+ if (this.config.allowedOrigins === undefined) {
293
+ state.allowedOrigins = defaultAllowedOrigins(boundPort);
294
+ }
295
+ sharedServer = state;
296
+
297
+ console.log(`[webui] listening on http://${host}:${boundPort}`);
298
+ }
299
+
300
+ async stop(): Promise<void> {
301
+ // Tear down framework-bound state only. The HTTP server and WS clients
302
+ // belong to the process-level singleton and survive across session
303
+ // switches; closing them here would drop active admin connections every
304
+ // time the operator switches sessions or the framework restarts.
305
+ if (!sharedServer) return;
306
+ sharedServer.fleetEventDetacher?.();
307
+ sharedServer.fleetEventDetacher = null;
308
+ sharedServer.messageListenerDetacher?.();
309
+ sharedServer.messageListenerDetacher = null;
310
+ sharedServer.treeAggregator?.dispose();
311
+ sharedServer.treeAggregator = null;
312
+ sharedServer.app = null;
313
+ }
314
+
315
+ getTools(): ToolDefinition[] { return []; }
316
+
317
+ async handleToolCall(_call: ToolCall): Promise<ToolResult> {
318
+ return { success: false, error: 'WebUiModule has no tools', isError: true };
319
+ }
320
+
321
+ async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
322
+ return {};
323
+ }
324
+
325
+ // -------------------------------------------------------------------------
326
+ // Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework)
327
+ // -------------------------------------------------------------------------
328
+
329
+ setApp(app: WebUiAppRef): void {
330
+ if (!sharedServer) return;
331
+ const ss = sharedServer;
332
+ ss.app = app;
333
+
334
+ // Tear down any previous aggregator (session-switch path).
335
+ ss.fleetEventDetacher?.();
336
+ ss.fleetEventDetacher = null;
337
+ ss.messageListenerDetacher?.();
338
+ ss.messageListenerDetacher = null;
339
+ ss.treeAggregator?.dispose();
340
+ ss.treeAggregator = null;
341
+
342
+ // Live message pushes: assistant turns and tool results never surface as
343
+ // `message:added` traces (those fire only for external/MCPL sources), so
344
+ // subscribe to the message store itself. Feature-detected — older
345
+ // context-manager copies without onMessage simply don't get live pushes
346
+ // (clients still see streamed text via traces).
347
+ const cm0 = app.framework.getAllAgents()[0]?.getContextManager() as unknown as WindowCapableCm | undefined;
348
+ if (cm0 && typeof cm0.onMessage === 'function' && typeof cm0.getMessageCount === 'function') {
349
+ ss.messageListenerDetacher = cm0.onMessage((ev) => {
350
+ if (ev.type !== 'add' || !ev.message) return;
351
+ try {
352
+ const entry = toWireEntry(ev.message as MessageLike, cm0.getMessageCount() - 1);
353
+ const push: WebUiServerMessage = { type: 'message-appended', entry };
354
+ for (const c of ss.clients.values()) {
355
+ if (c.welcomed) this.send(c, push);
356
+ }
357
+ } catch (err) {
358
+ // Never let a viewer-side projection error break the store's
359
+ // listener chain (ContextManager subscribes on the same path).
360
+ console.error('[webui] message-appended projection failed:', err);
361
+ }
362
+ });
363
+ }
364
+
365
+ // Re-derive cost snapshot for the new framework. Without this the
366
+ // welcome of the first connecting client (or all clients after a
367
+ // session switch) would carry stale or empty per-agent costs until the
368
+ // next inference completes.
369
+ ss.latestPerAgentCost = this.collectPerAgentCost();
370
+
371
+ // Session switch rebuilds the framework with a fresh UsageTracker; any
372
+ // tally accumulated on the previous framework would be misleading carried
373
+ // forward. Children's `usage:updated` events repopulate childUsage live;
374
+ // resetting here keeps the header total in lockstep with the new session.
375
+ // Seed parentUsage from the framework's restored snapshot so a session
376
+ // reopened from disk shows its historical totals immediately rather than
377
+ // appearing reset until the next inference event lands.
378
+ ss.parentUsage = this.snapshotParentUsage();
379
+ ss.childUsage.clear();
380
+ ss.latestUsage = aggregateFleetUsage(ss);
381
+ // Recipe cache is keyed by file path; on session switch the framework
382
+ // is fresh but children may carry over, so the cache is still valid.
383
+ // Only clear if the entire app reference changed in a way that matters —
384
+ // for now, retain across setApp (keeps welcome fast).
385
+
386
+ // Single fan-out listener. The framework's `onTrace` does not return a
387
+ // detacher, so per-client subscriptions would leak across reconnects.
388
+ // Instead, one listener iterates the live client set and the WS lifecycle
389
+ // owns membership. Cheap as long as the client count stays small (admin UI).
390
+ app.framework.onTrace((event: TraceEvent) => this.fanOutTrace(event));
391
+
392
+ // Fleet integration: if FleetModule is mounted, spin up a private
393
+ // FleetTreeAggregator and start forwarding child events to clients. The
394
+ // aggregator's per-child reducers are populated via the `describe`/snapshot
395
+ // protocol, exactly as the TUI uses them — see UNIFIED-TREE-PLAN.md §3.
396
+ const fleetMod = app.framework
397
+ .getAllModules()
398
+ .find((m) => m.name === 'fleet') as FleetModule | undefined;
399
+
400
+ if (fleetMod) {
401
+ const agg = new FleetTreeAggregator(fleetMod);
402
+ ss.treeAggregator = agg;
403
+ // Register existing children up front. autoStart launches finish before
404
+ // setApp() runs, so this catches everything currently up.
405
+ for (const childName of fleetMod.getChildren().keys()) {
406
+ agg.registerChild(childName);
407
+ }
408
+
409
+ // One subscription on '*' — fan out to clients AND register newly-seen
410
+ // children with the aggregator. This avoids a polling loop and keeps the
411
+ // late-attach path correct.
412
+ ss.fleetEventDetacher = fleetMod.onChildEvent('*', (childName, event) =>
413
+ this.handleFleetEvent(childName, event),
414
+ );
415
+ }
416
+
417
+ // Welcome any client that's currently connected. Two cases land here:
418
+ // - First setApp(): fresh page-loads parked at onWsOpen are flushed.
419
+ // - Post-session-switch: every previously-welcomed client gets a fresh
420
+ // welcome reflecting the new framework / messages / agents / branch.
421
+ for (const client of sharedServer!.clients.values()) {
422
+ // Force a re-welcome by clearing the flag and resending.
423
+ client.welcomed = false;
424
+ void this.sendWelcome(client);
425
+ }
426
+ }
427
+
428
+ private handleFleetEvent(childName: string, event: WireEvent): void {
429
+ // Auto-register on first sight so the aggregator picks up children that
430
+ // launched after setApp() ran.
431
+ if (sharedServer?.treeAggregator) {
432
+ const known = new Set(sharedServer?.treeAggregator.getAllChildNames());
433
+ if (!known.has(childName)) {
434
+ sharedServer?.treeAggregator.registerChild(childName);
435
+ }
436
+ }
437
+
438
+ // Snapshot responses to scoped panel queries — route to the requesting
439
+ // client only. The corrId came from us; we know which client to send
440
+ // back to without leaking child-internal data to every connected client.
441
+ const eType = (event as { type?: unknown }).type;
442
+ const corrId = (event as { corrId?: unknown }).corrId;
443
+ if (
444
+ typeof eType === 'string'
445
+ && typeof corrId === 'string'
446
+ && (eType === 'lessons-snapshot'
447
+ || eType === 'workspace-mounts-snapshot'
448
+ || eType === 'workspace-tree-snapshot'
449
+ || eType === 'workspace-file-snapshot'
450
+ || eType === 'cancel-subagent-result')
451
+ ) {
452
+ this.routeChildSnapshotResponse(eType, corrId, event as Record<string, unknown>);
453
+ return; // don't fan out — these are private replies, not telemetry
454
+ }
455
+
456
+ // Roll up fleet-child session usage so the header total reflects every
457
+ // process the operator is paying for, not just the parent. Children emit
458
+ // their own `usage:updated` events; we cache the last `totals` for each
459
+ // and sum into the aggregate that goes out as latestUsage. Drop the
460
+ // child's entry on graceful exit so its stale totals don't keep counting
461
+ // after fleet--stop. SIGKILL'd / crashed children never emit
462
+ // `lifecycle:exiting`, so their last reported totals stay in the map
463
+ // until the next session reset — accepted trade-off, the map is bounded
464
+ // by total-children-ever-spawned-this-session and crashes are rare.
465
+ let usageChanged = false;
466
+ if (eType === 'usage:updated') {
467
+ const totalsObj = (event as unknown as { totals?: unknown }).totals;
468
+ const parsed = parseUsageTotals(totalsObj);
469
+ if (parsed) {
470
+ sharedServer!.childUsage.set(childName, parsed);
471
+ usageChanged = true;
472
+ }
473
+ } else if (eType === 'lifecycle') {
474
+ const phase = (event as { phase?: unknown }).phase;
475
+ if (phase === 'exiting' || phase === 'exited') {
476
+ if (sharedServer!.childUsage.delete(childName)) usageChanged = true;
477
+ }
478
+ }
479
+
480
+ let usageMsg: WebUiServerMessage | null = null;
481
+ if (usageChanged) {
482
+ sharedServer!.latestUsage = aggregateFleetUsage(sharedServer!);
483
+ usageMsg = {
484
+ type: 'usage',
485
+ usage: sharedServer!.latestUsage,
486
+ ...(sharedServer!.latestPerAgentCost.length > 0
487
+ ? { perAgentCost: sharedServer!.latestPerAgentCost }
488
+ : {}),
489
+ };
490
+ }
491
+
492
+ if (sharedServer!.clients.size === 0) return;
493
+ // Forward the verbatim event so the SPA can fold it into its own
494
+ // per-child AgentTreeReducer for live updates.
495
+ const msg: WebUiServerMessage = {
496
+ type: 'child-event',
497
+ childName,
498
+ event: event as unknown as { type: string; [k: string]: unknown },
499
+ };
500
+ for (const client of sharedServer!.clients.values()) {
501
+ if (!client.welcomed) continue;
502
+ this.send(client, msg);
503
+ if (usageMsg) this.send(client, usageMsg);
504
+ }
505
+ }
506
+
507
+ /** Translate a child snapshot event back into the matching wire message
508
+ * type and forward to the originating client. The pendingFleetRequests
509
+ * map is the source of truth for which client asked. */
510
+ private routeChildSnapshotResponse(
511
+ eType: string,
512
+ corrId: string,
513
+ event: Record<string, unknown>,
514
+ ): void {
515
+ if (!sharedServer) return;
516
+ const entry = sharedServer.pendingFleetRequests.get(corrId);
517
+ if (!entry) return; // stale or foreign corrId; ignore
518
+ sharedServer.pendingFleetRequests.delete(corrId);
519
+ const client = sharedServer.clients.get(entry.clientId);
520
+ if (!client) return;
521
+
522
+ if (eType === 'lessons-snapshot') {
523
+ this.send(client, {
524
+ type: 'lessons-list',
525
+ loaded: Boolean(event.loaded),
526
+ lessons: (event.lessons as LessonsListMessage['lessons']) ?? [],
527
+ });
528
+ return;
529
+ }
530
+ if (eType === 'workspace-mounts-snapshot') {
531
+ this.send(client, {
532
+ type: 'workspace-mounts',
533
+ loaded: Boolean(event.loaded),
534
+ mounts: (event.mounts as Array<{ name: string; path: string; mode: string }>) ?? [],
535
+ });
536
+ return;
537
+ }
538
+ if (eType === 'workspace-tree-snapshot') {
539
+ this.send(client, {
540
+ type: 'workspace-tree',
541
+ mount: String(event.mount ?? ''),
542
+ entries: (event.entries as Array<{ path: string; size: number }>) ?? [],
543
+ });
544
+ return;
545
+ }
546
+ if (eType === 'workspace-file-snapshot') {
547
+ const errStr = typeof event.error === 'string' ? event.error : undefined;
548
+ if (errStr) {
549
+ this.send(client, { type: 'error', message: `read failed: ${errStr}` });
550
+ return;
551
+ }
552
+ this.send(client, {
553
+ type: 'workspace-file',
554
+ path: String(event.path ?? ''),
555
+ totalLines: Number(event.totalLines ?? 0),
556
+ fromLine: Number(event.fromLine ?? 1),
557
+ toLine: Number(event.toLine ?? 0),
558
+ content: String(event.content ?? ''),
559
+ truncated: Boolean(event.truncated),
560
+ });
561
+ return;
562
+ }
563
+ if (eType === 'cancel-subagent-result') {
564
+ const name = String(event.name ?? '');
565
+ const cancelled = Boolean(event.cancelled);
566
+ const reason = typeof event.reason === 'string' ? event.reason : undefined;
567
+ this.send(client, {
568
+ type: 'command-result',
569
+ lines: [{
570
+ text: cancelled
571
+ ? `cancelled subagent ${name}`
572
+ : `subagent ${name} not cancelled${reason ? ` (${reason})` : ''}`,
573
+ style: cancelled ? 'system' : 'tool',
574
+ }],
575
+ });
576
+ return;
577
+ }
578
+ }
579
+
580
+ private fanOutTrace(event: TraceEvent): void {
581
+ // Update cached usage snapshot first so welcomes for late-connecting
582
+ // clients get a current value.
583
+ if (event.type === 'usage:updated') {
584
+ const totalsObj = (event as unknown as { totals?: unknown }).totals;
585
+ const parsed = parseUsageTotals(totalsObj);
586
+ if (parsed) sharedServer!.parentUsage = parsed;
587
+ sharedServer!.latestUsage = aggregateFleetUsage(sharedServer!);
588
+ // Re-derive the per-agent slice from the framework's snapshot. This
589
+ // is the only place we reach into framework internals on the trace
590
+ // hot path; the call is O(agents) and guarded by the cached snapshot.
591
+ sharedServer!.latestPerAgentCost = this.collectPerAgentCost();
592
+ }
593
+
594
+ // External-trigger surfacing — turn `message:added` traces from MCPL
595
+ // sources into a typed wire message so the SPA can show an attribution
596
+ // box. Fire-and-forget; lookup may fail mid-modification.
597
+ if (event.type === 'message:added') {
598
+ const e = event as unknown as { messageId: string; source: string };
599
+ void this.maybeEmitTrigger(e.messageId, e.source);
600
+ }
601
+
602
+ if (sharedServer!.clients.size === 0) return;
603
+ const traceMsg: WebUiServerMessage = {
604
+ type: 'trace',
605
+ event: event as unknown as { type: string; [k: string]: unknown },
606
+ };
607
+ const usageMsg: WebUiServerMessage | null = event.type === 'usage:updated'
608
+ ? {
609
+ type: 'usage',
610
+ usage: sharedServer!.latestUsage,
611
+ ...(sharedServer!.latestPerAgentCost.length > 0
612
+ ? { perAgentCost: sharedServer!.latestPerAgentCost }
613
+ : {}),
614
+ }
615
+ : null;
616
+ for (const client of sharedServer!.clients.values()) {
617
+ if (!client.welcomed) continue;
618
+ this.send(client, traceMsg);
619
+ if (usageMsg) this.send(client, usageMsg);
620
+ }
621
+ }
622
+
623
+ // -------------------------------------------------------------------------
624
+ /** Surface MCPL-sourced `message:added` traces as `inbound-trigger`
625
+ * envelopes so the SPA can show "incoming from zulip#X" boxes. WebUI-typed
626
+ * user messages are excluded — those are already optimistically rendered
627
+ * on the originating client. */
628
+ /** Load a child's recipe metadata (name, description, agent model) from
629
+ * its recipe file path. Cached per-path on the singleton; failures
630
+ * resolve to undefined so the SPA can fall back to displaying the child
631
+ * name only. */
632
+ private async loadChildRecipeInfo(
633
+ fleet: FleetModule,
634
+ childName: string,
635
+ ): Promise<{ name: string; description?: string; version?: string; agentModel?: string } | undefined> {
636
+ const child = fleet.getChildren().get(childName);
637
+ if (!child) return undefined;
638
+ const path = child.recipePath;
639
+ if (!path) return undefined;
640
+ const cache = sharedServer?.childRecipeCache;
641
+ if (cache?.has(path)) return cache.get(path);
642
+ try {
643
+ const recipe = await loadRecipe(path);
644
+ const info = {
645
+ name: recipe.name,
646
+ ...(recipe.description ? { description: recipe.description } : {}),
647
+ ...(recipe.version ? { version: recipe.version } : {}),
648
+ ...(recipe.agent?.model ? { agentModel: recipe.agent.model } : {}),
649
+ };
650
+ cache?.set(path, info);
651
+ return info;
652
+ } catch {
653
+ return undefined;
654
+ }
655
+ }
656
+
657
+ /** Pull a per-agent cost snapshot from the framework's UsageTracker.
658
+ * Returns [] if the framework isn't bound or no agents have been billed
659
+ * yet. Used by both the welcome payload and live UsageMessage frames. */
660
+ private collectPerAgentCost(): import('../web/protocol.js').PerAgentCost[] {
661
+ const snap = this.readSessionUsage();
662
+ if (!snap) return [];
663
+ const out: import('../web/protocol.js').PerAgentCost[] = [];
664
+ for (const agent of snap.byAgent) {
665
+ const c = agent.usage.estimatedCost;
666
+ if (!c) continue;
667
+ out.push({ name: agent.agentName, cost: { total: c.total, currency: c.currency }, inferenceCount: agent.inferenceCount });
668
+ }
669
+ return out;
670
+ }
671
+
672
+ /** Pull the parent UsageTracker's running totals so the header reflects a
673
+ * restored session's prior spend immediately on connect, instead of
674
+ * reading 0 until the next inference event fires. */
675
+ private snapshotParentUsage(): TokenUsage {
676
+ const empty: TokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
677
+ const snap = this.readSessionUsage();
678
+ return snap ? (parseUsageTotals(snap.totals) ?? empty) : empty;
679
+ }
680
+
681
+ /** Read a defensive snapshot of the bound framework's UsageTracker. Returns
682
+ * null if no app is bound or the call throws. The try/catch survives the
683
+ * trace hot path: a thrown UsageTracker shouldn't take the WebUI down. */
684
+ private readSessionUsage(): SessionUsageSnapshot | null {
685
+ if (!sharedServer?.app) return null;
686
+ try {
687
+ return sharedServer.app.framework.getSessionUsage();
688
+ } catch {
689
+ return null;
690
+ }
691
+ }
692
+
693
+ private async maybeEmitTrigger(messageId: string, source: string): Promise<void> {
694
+ if (!source.startsWith('mcpl:')) return;
695
+ if (!sharedServer?.app) return;
696
+ type Stored = { participant: string; content: ReadonlyArray<unknown>; metadata?: Record<string, unknown>; timestamp: Date };
697
+ let storedMsg: Stored | null;
698
+ try {
699
+ const cm = sharedServer.app.framework.getAllAgents()[0]?.getContextManager();
700
+ if (!cm) return;
701
+ storedMsg = (cm.getMessage(messageId) as Stored | null) ?? null;
702
+ } catch {
703
+ return;
704
+ }
705
+ if (!storedMsg) return;
706
+ if (storedMsg.participant !== 'user') return;
707
+
708
+ const md = storedMsg.metadata ?? {};
709
+ const origin = describeTriggerOrigin(source, md);
710
+ const author = extractAuthorName(md);
711
+ const text = extractText(storedMsg.content).slice(0, 500);
712
+ const triggered = Boolean(md.triggered);
713
+
714
+ const msg: WebUiServerMessage = {
715
+ type: 'inbound-trigger',
716
+ source,
717
+ origin,
718
+ triggered,
719
+ ...(author ? { author } : {}),
720
+ text,
721
+ timestamp: storedMsg.timestamp.getTime(),
722
+ };
723
+ for (const client of sharedServer.clients.values()) {
724
+ if (!client.welcomed) continue;
725
+ this.send(client, msg);
726
+ }
727
+ }
728
+
729
+ // -------------------------------------------------------------------------
730
+ // HTTP — static SPA + WS upgrade
731
+ // -------------------------------------------------------------------------
732
+
733
+ private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
734
+ const url = new URL(req.url);
735
+
736
+ // WebSocket upgrade
737
+ if (url.pathname === '/ws') {
738
+ // Origin check FIRST — drive-by CSRF on a localhost-bound WS is the
739
+ // failure mode this guards. Browsers do not enforce same-origin on
740
+ // `new WebSocket(...)` the way they do on fetch, so without an
741
+ // explicit check, any tab the operator opens could connect here.
742
+ if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
743
+ if (!this.checkAuth(req)) return this.unauthorized();
744
+ const id = sharedServer!.nextClientId++;
745
+ const ok = server.upgrade(req, { data: { id } });
746
+ if (!ok) return new Response('Upgrade failed', { status: 400 });
747
+ // Bun returns undefined on success; the response is taken over by the upgrade.
748
+ return new Response(null, { status: 101 });
749
+ }
750
+
751
+ if (!this.checkAuth(req)) return this.unauthorized();
752
+
753
+ // Debug: the membrane-normalized request that WOULD be emitted if the
754
+ // agent were activated right now — no inference, no state mutation.
755
+ // GET /debug/context[?agent=<name>][&hooks=false][&pretty=1]
756
+ if (url.pathname === '/debug/context/makeup') {
757
+ return this.handleContextMakeup(url);
758
+ }
759
+
760
+ // Context curve: per-entry provenance of the live compiled window —
761
+ // cumulative raw-history tokens vs rendered tokens, by fold level.
762
+ // JSON at /debug/context/curve; the visualization page at /curve.
763
+ if (url.pathname === '/debug/context/curve') {
764
+ return this.handleContextCurve(url);
765
+ }
766
+ if (url.pathname === '/curve') {
767
+ return new Response(CURVE_PAGE_HTML, {
768
+ headers: { 'content-type': 'text/html; charset=utf-8' },
769
+ });
770
+ }
771
+
772
+ if (url.pathname === '/debug/context') {
773
+ return this.handleDebugContext(url);
774
+ }
775
+
776
+ // Liveness/health JSON for connectome-doctor and the fleet hub. Behind
777
+ // the same basic auth as everything else (checked above).
778
+ if (url.pathname === '/healthz') {
779
+ const app = sharedServer?.app;
780
+ if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
781
+ const fw = app.framework as unknown as { healthSnapshot?: () => Record<string, unknown> };
782
+ if (typeof fw.healthSnapshot !== 'function') {
783
+ return Response.json({ error: 'framework lacks healthSnapshot()' }, { status: 501 });
784
+ }
785
+ return Response.json(fw.healthSnapshot());
786
+ }
787
+
788
+ // Workspace file passthrough: /files/<mount>/<path...>
789
+ // Resolves through WorkspaceModule.resolveAbsolutePath, which enforces
790
+ // mount-relative containment and the mount's read-permission. We never
791
+ // serve a path the agent framework's mount layer wouldn't itself serve.
792
+ if (url.pathname.startsWith('/files/')) {
793
+ return this.serveWorkspaceFile(url.pathname.slice('/files/'.length));
794
+ }
795
+
796
+ // Static SPA
797
+ const requested = url.pathname === '/' ? '/index.html' : url.pathname;
798
+ return this.serveStatic(requested);
799
+ }
800
+
801
+ /**
802
+ * Debug endpoint: return the membrane-normalized request the framework would
803
+ * hand to the model if the agent were activated right now. Delegates to
804
+ * `framework.previewActivation`. Auth is already enforced by the caller
805
+ * (`handleHttp`).
806
+ *
807
+ * Transparent by default: no inference, no Chronicle writes, no external
808
+ * MCPL calls — the preview leaves the system state untouched. This omits the
809
+ * dynamically-gathered injections (lessons/retrieval/MCPL context). Pass
810
+ * `?injections=1` to gather them for full fidelity, which is NOT transparent:
811
+ * it can run inference (e.g. RetrievalModule's Haiku calls) and fire MCPL
812
+ * `beforeInference` hooks (whose paired `afterInference` is never sent).
813
+ */
814
+ private async handleDebugContext(url: URL): Promise<Response> {
815
+ const app = sharedServer?.app;
816
+ if (!app) return new Response('Not ready', { status: 503 });
817
+
818
+ const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
819
+ // Default OFF: keep the preview transparent to system state. Opt in to
820
+ // dynamic injection gathering (and its side effects) with ?injections=1.
821
+ const injParam = url.searchParams.get('injections');
822
+ const injections = injParam !== null && injParam !== 'false' && injParam !== '0';
823
+ const pretty = url.searchParams.get('pretty') !== null && url.searchParams.get('pretty') !== '0';
824
+
825
+ if (!app.framework.getAgent(agentName)) {
826
+ return new Response(
827
+ JSON.stringify({ error: `Agent not found: ${agentName}` }),
828
+ { status: 404, headers: { 'content-type': 'application/json' } },
829
+ );
830
+ }
831
+
832
+ try {
833
+ const request = await app.framework.previewActivation(agentName, { injections });
834
+ // `transparent` reflects whether this call was side-effect-free.
835
+ const body = JSON.stringify(
836
+ { agent: agentName, injections, transparent: !injections, request },
837
+ null,
838
+ pretty ? 2 : undefined,
839
+ );
840
+ return new Response(body, { headers: { 'content-type': 'application/json' } });
841
+ } catch (error) {
842
+ const message = error instanceof Error ? error.message : String(error);
843
+ return new Response(
844
+ JSON.stringify({ error: message }),
845
+ { status: 500, headers: { 'content-type': 'application/json' } },
846
+ );
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Context curve (GET /debug/context/curve[?agent=<name>]): compile the
852
+ * agent's window and return one record per compiled entry with its
853
+ * provenance — kind (raw / L1..Ln summary), rendered token estimate, the
854
+ * raw-history tokens it covers (leaf messages, recursively through the
855
+ * summary tree), date span, and full text. The /curve page plots the
856
+ * cumulative raw→rendered curve from this; slope = local compression rate.
857
+ *
858
+ * Same side-effect class as previewActivation / makeup: the compile may
859
+ * commit resolution updates, exactly as the agent's own next turn would.
860
+ * No inference, no message writes.
861
+ */
862
+ private async handleContextCurve(url: URL): Promise<Response> {
863
+ const app = sharedServer?.app;
864
+ if (!app) return new Response('Not ready', { status: 503 });
865
+ const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
866
+ const agent = app.framework.getAgent(agentName);
867
+ if (!agent) {
868
+ return new Response(JSON.stringify({ error: `Agent not found: ${agentName}` }), {
869
+ status: 404, headers: { 'content-type': 'application/json' },
870
+ });
871
+ }
872
+ try {
873
+ const cm = (agent as unknown as { getContextManager: () => any }).getContextManager();
874
+ const maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
875
+ const reserveForResponse = app.recipe.agent.maxTokens ?? 16_384;
876
+ const compiled = await cm.compile({ maxTokens, reserveForResponse });
877
+
878
+ // Curve inspection only needs text and source metadata. Resolving every
879
+ // historical blob here re-inlines all base64 media and can expand a
880
+ // few-hundred-MB Chronicle into several GB of JS heap. Use the windowed
881
+ // reader with blob resolution disabled so production diagnostics stay
882
+ // bounded by text history rather than the media archive.
883
+ const messageCount = cm.getMessageCount();
884
+ const messages: Array<{ id: string; timestamp?: unknown; content?: unknown[] }> =
885
+ cm.getMessageWindow(0, messageCount, { resolveBlobs: false }).messages;
886
+ const msgById = new Map(messages.map((mm) => [mm.id, mm]));
887
+ const estimate = (mm: { content?: unknown[] }): number => {
888
+ let t = 0;
889
+ for (const b of (mm.content ?? []) as Array<Record<string, unknown>>) {
890
+ if (b?.type === 'text') t += Math.ceil(String(b.text ?? '').length / 4);
891
+ else if (b?.type === 'image') t += 1600;
892
+ else if (b?.type === 'tool_result') t += Math.ceil(JSON.stringify(b.content ?? '').length / 4);
893
+ else if (b?.type === 'tool_use') t += Math.ceil(JSON.stringify(b.input ?? {}).length / 4);
894
+ else if (b?.type === 'thinking') t += Math.ceil(String(b.thinking ?? '').length / 4);
895
+ }
896
+ return t;
897
+ };
898
+
899
+ type Summary = { id: string; level: number; content: string; sourceLevel: number; sourceIds: string[] };
900
+ const strategy = cm.getStrategy() as { summaries?: Summary[] };
901
+ const sums: Summary[] = strategy.summaries ?? [];
902
+ const sumById = new Map(sums.map((x) => [x.id, x]));
903
+ const headOf = (txt: string): string => txt.replace(/\s+/g, ' ').slice(0, 100);
904
+ const byHead = new Map(sums.map((x) => [headOf(x.content), x]));
905
+ const leaves = (x: Summary, seen = new Set<string>()): string[] => {
906
+ if (seen.has(x.id)) return [];
907
+ seen.add(x.id);
908
+ if (x.sourceLevel === 0) return x.sourceIds;
909
+ const out: string[] = [];
910
+ for (const cid of x.sourceIds) {
911
+ const c = sumById.get(cid);
912
+ if (c) out.push(...leaves(c, seen));
913
+ }
914
+ return out;
915
+ };
916
+
917
+ const entries = [];
918
+ let i = 0;
919
+ for (const e of compiled.messages as Array<{ participant: string; content?: unknown[]; sourceMessageId?: string }>) {
920
+ const blocks = (e.content ?? []) as Array<Record<string, unknown>>;
921
+ const text = blocks.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n');
922
+ const nImages = blocks.filter((b) => b?.type === 'image').length;
923
+ const rendered = Math.ceil(text.length / 4) + nImages * 1600 +
924
+ blocks.filter((b) => b?.type === 'tool_result' || b?.type === 'tool_use')
925
+ .reduce((a, b) => a + Math.ceil(JSON.stringify(b.input ?? b.content ?? '').length / 4), 0);
926
+ const sum = byHead.get(headOf(text));
927
+ if (sum) {
928
+ const leafIds = leaves(sum).filter((id) => msgById.has(id));
929
+ const rawCovered = leafIds.reduce((a, id) => a + estimate(msgById.get(id)!), 0);
930
+ const dates = leafIds.map((id) => msgById.get(id)!.timestamp).filter(Boolean).sort();
931
+ entries.push({
932
+ i: i++, kind: `L${sum.level}`, id: sum.id, participant: e.participant,
933
+ rendered, rawCovered, msgCount: leafIds.length, nImages,
934
+ dateFirst: dates[0] ?? null, dateLast: dates[dates.length - 1] ?? null, text,
935
+ });
936
+ } else {
937
+ const src = e.sourceMessageId ? msgById.get(e.sourceMessageId) : null;
938
+ entries.push({
939
+ i: i++, kind: 'raw', id: e.sourceMessageId ?? null, participant: e.participant,
940
+ rendered, rawCovered: src ? estimate(src) : rendered, msgCount: 1, nImages,
941
+ dateFirst: src?.timestamp ?? null, dateLast: src?.timestamp ?? null, text,
942
+ });
943
+ }
944
+ }
945
+ return Response.json({
946
+ agent: agentName,
947
+ generatedAt: new Date().toISOString(),
948
+ branch: cm.currentBranch().name,
949
+ budget: { maxTokens, reserveForResponse },
950
+ totals: {
951
+ entries: entries.length,
952
+ rendered: entries.reduce((a, e) => a + e.rendered, 0),
953
+ rawCovered: entries.reduce((a, e) => a + e.rawCovered, 0),
954
+ },
955
+ entries,
956
+ });
957
+ } catch (error) {
958
+ return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
959
+ status: 500, headers: { 'content-type': 'application/json' },
960
+ });
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Context makeup: the segment breakdown of the agent's current compiled
966
+ * context — head window, raw middle, summaries by level (L1/L2/L3), and the
967
+ * recent verbatim tail — from the strategy's RenderStats, plus an exact
968
+ * total token count via the model's count_tokens endpoint. Transparent:
969
+ * previewActivation + count_tokens only; no inference, no Chronicle writes.
970
+ *
971
+ * GET /debug/context/makeup[?agent=<name>]
972
+ */
973
+ private async handleContextMakeup(url: URL): Promise<Response> {
974
+ const app = sharedServer?.app;
975
+ if (!app) return new Response('Not ready', { status: 503 });
976
+ const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
977
+ const agent = app.framework.getAgent(agentName);
978
+ if (!agent) {
979
+ return new Response(JSON.stringify({ error: `Agent not found: ${agentName}` }), {
980
+ status: 404, headers: { 'content-type': 'application/json' },
981
+ });
982
+ }
983
+ try {
984
+ // Populates the strategy's render stats as a side-effect of compiling.
985
+ const request = await app.framework.previewActivation(agentName);
986
+ const cm = (agent as { getContextManager: () => { getRenderStats: () => unknown } }).getContextManager();
987
+ const stats = cm.getRenderStats();
988
+
989
+ // Build an Anthropic-faithful payload for an exact count_tokens: map
990
+ // participants to roles (the agent's own -> assistant, others -> user
991
+ // with a "Name:" prefix) and merge consecutive same-role runs, mirroring
992
+ // what the NativeFormatter sends.
993
+ const textOf = (c: unknown): string =>
994
+ Array.isArray(c)
995
+ ? c.map((b) => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? (b as { text: string }).text : '')).join('')
996
+ : String(c ?? '');
997
+ const merged: Array<{ role: 'user' | 'assistant'; text: string }> = [];
998
+ for (const m of ((request as { messages?: Array<{ participant?: string; role?: string; content: unknown }> }).messages ?? [])) {
999
+ const who = m.participant ?? m.role ?? 'user';
1000
+ const role: 'user' | 'assistant' = who === agentName ? 'assistant' : 'user';
1001
+ let t = textOf(m.content);
1002
+ if (role === 'user' && who && who !== 'user') t = `${who}: ${t}`;
1003
+ const last = merged[merged.length - 1];
1004
+ if (last && last.role === role) last.text += '\n' + t;
1005
+ else merged.push({ role, text: t });
1006
+ }
1007
+ const anthMessages = merged.filter((m) => m.text.trim().length > 0).map((m) => ({ role: m.role, content: m.text }));
1008
+ const sysRaw = (request as { system?: unknown }).system;
1009
+ const systemStr = Array.isArray(sysRaw)
1010
+ ? sysRaw.map((b) => (b && typeof b === 'object' ? (b as { text?: string }).text ?? '' : String(b))).join('\n')
1011
+ : (typeof sysRaw === 'string' ? sysRaw : undefined);
1012
+
1013
+ let exactTotalTokens: number | null = null;
1014
+ const countModel = process.env.COUNT_TOKENS_MODEL || 'anthropic/claude-opus-4.5';
1015
+ let countSource = 'count_tokens';
1016
+ try {
1017
+ const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
1018
+ const res = await fetch(base + '/v1/messages/count_tokens', {
1019
+ method: 'POST',
1020
+ headers: {
1021
+ // Mirror the main adapter's auth: OAuth Bearer (subscription) when
1022
+ // ANTHROPIC_AUTH_TOKEN is set, x-api-key otherwise.
1023
+ ...(process.env.ANTHROPIC_AUTH_TOKEN
1024
+ ? {
1025
+ authorization: `Bearer ${process.env.ANTHROPIC_AUTH_TOKEN}`,
1026
+ 'anthropic-beta': 'oauth-2025-04-20',
1027
+ }
1028
+ : { 'x-api-key': process.env.ANTHROPIC_API_KEY ?? '' }),
1029
+ 'anthropic-version': '2023-06-01',
1030
+ 'content-type': 'application/json',
1031
+ 'user-agent': 'conhost/1.0',
1032
+ },
1033
+ body: JSON.stringify({ model: countModel, ...(systemStr ? { system: systemStr } : {}), messages: anthMessages }),
1034
+ });
1035
+ if (res.ok) {
1036
+ const j = (await res.json()) as { input_tokens?: number };
1037
+ exactTotalTokens = j.input_tokens ?? null;
1038
+ } else {
1039
+ countSource = `count_tokens_failed_${res.status}`;
1040
+ }
1041
+ } catch {
1042
+ countSource = 'count_tokens_error';
1043
+ }
1044
+
1045
+ return new Response(
1046
+ JSON.stringify({ agent: agentName, stats, exactTotalTokens, countModel, countSource }, null, 2),
1047
+ { headers: { 'content-type': 'application/json' } },
1048
+ );
1049
+ } catch (error) {
1050
+ return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
1051
+ status: 500, headers: { 'content-type': 'application/json' },
1052
+ });
1053
+ }
1054
+ }
1055
+
1056
+ private async serveWorkspaceFile(rest: string): Promise<Response> {
1057
+ if (!sharedServer?.app) return new Response('Not ready', { status: 503 });
1058
+ const decoded = decodeURIComponent(rest);
1059
+ const slash = decoded.indexOf('/');
1060
+ if (slash < 0) return new Response('Bad request', { status: 400 });
1061
+ const mount = decoded.slice(0, slash);
1062
+ const inMountPath = decoded.slice(slash + 1);
1063
+ const mountPrefixed = `${mount}/${inMountPath}`;
1064
+
1065
+ const ws = sharedServer.app.framework.getModule('workspace');
1066
+ if (!ws || !('resolveAbsolutePath' in ws)) {
1067
+ return new Response('Workspace not mounted', { status: 503 });
1068
+ }
1069
+ const abs = (ws as { resolveAbsolutePath: (p: string) => string | null }).resolveAbsolutePath(mountPrefixed);
1070
+ if (!abs) return new Response('Forbidden', { status: 403 });
1071
+
1072
+ try {
1073
+ const data = await readFile(abs);
1074
+ return new Response(data, { headers: { 'content-type': mimeFor(abs) } });
1075
+ } catch {
1076
+ return new Response('Not found', { status: 404 });
1077
+ }
1078
+ }
1079
+
1080
+ private async serveStatic(requestedPath: string): Promise<Response> {
1081
+ // Path containment: resolve and verify the result is still under staticRoot.
1082
+ // Plain startsWith without a separator is unsafe — both `<root>` and
1083
+ // `<root>-evil/...` pass `startsWith('<root>')`. The current callers
1084
+ // pass relative paths so this is unreachable today, but a future
1085
+ // refactor that lets absolute paths slip through would turn it into a
1086
+ // real escape; require either an exact match or a trailing separator.
1087
+ const root = sharedServer!.staticRoot;
1088
+ const safePath = normalize(join(root, requestedPath));
1089
+ if (safePath !== root && !safePath.startsWith(root + pathSep)) {
1090
+ return new Response('Forbidden', { status: 403 });
1091
+ }
1092
+
1093
+ try {
1094
+ const s = await stat(safePath);
1095
+ if (s.isDirectory()) {
1096
+ return this.serveStatic(join(requestedPath, 'index.html'));
1097
+ }
1098
+ const data = await readFile(safePath);
1099
+ return new Response(data, { headers: { 'content-type': mimeFor(safePath) } });
1100
+ } catch {
1101
+ // Fall back to index.html so the SPA can handle client-side routing.
1102
+ try {
1103
+ const indexPath = join(sharedServer!.staticRoot, 'index.html');
1104
+ const data = await readFile(indexPath);
1105
+ return new Response(data, { headers: { 'content-type': 'text/html' } });
1106
+ } catch {
1107
+ return new Response(
1108
+ `WebUI bundle not found at ${sharedServer!.staticRoot}. Run \`npm run build:web\` (or postinstall) to produce it.`,
1109
+ { status: 503, headers: { 'content-type': 'text/plain' } },
1110
+ );
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ // -------------------------------------------------------------------------
1116
+ // WebSocket lifecycle
1117
+ // -------------------------------------------------------------------------
1118
+
1119
+ private onWsOpen(ws: ServerWebSocket<{ id: number }>): void {
1120
+ const id = ws.data.id;
1121
+ const client: ClientState = { id, ws, welcomed: false, peeks: new Map() };
1122
+ sharedServer!.clients.set(id, client);
1123
+
1124
+ if (sharedServer?.app) void this.sendWelcome(client);
1125
+ // Else: park until setApp() flushes welcomes.
1126
+ }
1127
+
1128
+ private onWsMessage(ws: ServerWebSocket<{ id: number }>, raw: string | Buffer): void {
1129
+ const id = ws.data.id;
1130
+ const client = sharedServer!.clients.get(id);
1131
+ if (!client) return;
1132
+
1133
+ let parsed: unknown;
1134
+ try {
1135
+ parsed = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
1136
+ } catch {
1137
+ this.send(client, { type: 'error', message: 'invalid JSON' });
1138
+ return;
1139
+ }
1140
+ if (!isClientMessage(parsed)) {
1141
+ this.send(client, { type: 'error', message: 'unknown message shape' });
1142
+ return;
1143
+ }
1144
+
1145
+ if (!sharedServer?.app) {
1146
+ this.send(client, { type: 'error', message: 'host not ready' });
1147
+ return;
1148
+ }
1149
+
1150
+ switch (parsed.type) {
1151
+ case 'ping':
1152
+ // No reply — round-trip already confirmed by the message arriving.
1153
+ return;
1154
+
1155
+ case 'user-message':
1156
+ sharedServer?.app.framework.pushEvent({
1157
+ type: 'external-message',
1158
+ source: 'tui',
1159
+ content: parsed.content,
1160
+ metadata: {},
1161
+ triggerInference: true,
1162
+ });
1163
+ return;
1164
+
1165
+ case 'command': {
1166
+ void this.dispatchCommand(client, parsed.command, parsed.corrId);
1167
+ return;
1168
+ }
1169
+
1170
+ case 'request-history': {
1171
+ this.handleRequestHistory(client, parsed);
1172
+ return;
1173
+ }
1174
+
1175
+ case 'route-to-child': {
1176
+ void this.handleRouteToChild(client, parsed.childName, parsed.content);
1177
+ return;
1178
+ }
1179
+
1180
+ case 'interrupt': {
1181
+ if (!sharedServer?.app) return;
1182
+ const fw = sharedServer.app.framework;
1183
+ // Cancel any in-process subagents so their results propagate.
1184
+ const subMod = fw.getAllModules().find((m) => m.name === 'subagent') as
1185
+ | { cancelAll(): number }
1186
+ | undefined;
1187
+ const cancelled = subMod?.cancelAll() ?? 0;
1188
+ for (const agent of fw.getAllAgents()) {
1189
+ try { agent.cancelStream(); } catch { /* idempotent */ }
1190
+ }
1191
+ this.send(client, {
1192
+ type: 'command-result',
1193
+ lines: [{
1194
+ text: cancelled > 0 ? `interrupted — ${cancelled} subagent(s) stopped` : 'interrupted',
1195
+ style: 'system',
1196
+ }],
1197
+ });
1198
+ return;
1199
+ }
1200
+
1201
+ case 'subscribe-peek':
1202
+ this.handleSubscribePeek(client, parsed.scope, parsed.active);
1203
+ return;
1204
+
1205
+ case 'cancel-subagent': {
1206
+ if (!sharedServer?.app) return;
1207
+ // Route to the owning fleet child if the WUI told us which one to
1208
+ // target. Subagents inside a fleet child (e.g. Clerk-spawned forks)
1209
+ // can't be cancelled locally — the conductor's framework doesn't have
1210
+ // their SubagentModule.
1211
+ if (parsed.childName) {
1212
+ this.routeFleetRequest(client, parsed.childName, 'cancel-subagent',
1213
+ (corrId, fleet) => fleet.cancelSubagentOnChild(parsed.childName!, parsed.name, corrId));
1214
+ return;
1215
+ }
1216
+ const subMod = sharedServer.app.framework
1217
+ .getAllModules()
1218
+ .find((m) => m.name === 'subagent') as
1219
+ | { cancelSubagent(name: string): boolean }
1220
+ | undefined;
1221
+ if (!subMod) {
1222
+ this.send(client, { type: 'error', message: 'subagent module not loaded' });
1223
+ return;
1224
+ }
1225
+ const ok = subMod.cancelSubagent(parsed.name);
1226
+ this.send(client, {
1227
+ type: 'command-result',
1228
+ lines: [{
1229
+ text: ok ? `cancelled subagent ${parsed.name}` : `subagent ${parsed.name} not running`,
1230
+ style: ok ? 'system' : 'tool',
1231
+ }],
1232
+ });
1233
+ return;
1234
+ }
1235
+
1236
+ case 'fleet-stop':
1237
+ case 'fleet-restart': {
1238
+ void this.handleFleetControl(client, parsed.type, parsed.name);
1239
+ return;
1240
+ }
1241
+
1242
+ case 'quit-confirm': {
1243
+ void this.handleQuitConfirm(parsed.action);
1244
+ return;
1245
+ }
1246
+
1247
+ case 'request-lessons': {
1248
+ if (parsed.scope && parsed.scope !== 'local') {
1249
+ this.routeFleetRequest(client, parsed.scope, 'lessons',
1250
+ (corrId, fleet) => fleet.requestLessons(parsed.scope!, corrId));
1251
+ } else {
1252
+ this.sendLessonsList(client);
1253
+ }
1254
+ return;
1255
+ }
1256
+
1257
+ case 'request-mcpl': {
1258
+ this.sendMcplList(client);
1259
+ return;
1260
+ }
1261
+
1262
+ case 'mcpl-add': {
1263
+ try {
1264
+ const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
1265
+ servers[parsed.id] = {
1266
+ command: parsed.command,
1267
+ ...(parsed.args && parsed.args.length > 0 ? { args: parsed.args } : {}),
1268
+ ...(parsed.env && Object.keys(parsed.env).length > 0 ? { env: parsed.env } : {}),
1269
+ ...(parsed.toolPrefix ? { toolPrefix: parsed.toolPrefix } : {}),
1270
+ };
1271
+ saveMcplServers(DEFAULT_CONFIG_PATH, servers);
1272
+ } catch (err) {
1273
+ this.send(client, { type: 'error', message: `mcpl-add failed: ${err instanceof Error ? err.message : String(err)}` });
1274
+ return;
1275
+ }
1276
+ this.sendMcplList(client);
1277
+ return;
1278
+ }
1279
+
1280
+ case 'mcpl-remove': {
1281
+ try {
1282
+ const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
1283
+ if (!(parsed.id in servers)) {
1284
+ this.send(client, { type: 'error', message: `server '${parsed.id}' not found` });
1285
+ return;
1286
+ }
1287
+ delete servers[parsed.id];
1288
+ saveMcplServers(DEFAULT_CONFIG_PATH, servers);
1289
+ } catch (err) {
1290
+ this.send(client, { type: 'error', message: `mcpl-remove failed: ${err instanceof Error ? err.message : String(err)}` });
1291
+ return;
1292
+ }
1293
+ this.sendMcplList(client);
1294
+ return;
1295
+ }
1296
+
1297
+ case 'mcpl-set-env': {
1298
+ try {
1299
+ const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
1300
+ const entry = servers[parsed.id];
1301
+ if (!entry) {
1302
+ this.send(client, { type: 'error', message: `server '${parsed.id}' not found` });
1303
+ return;
1304
+ }
1305
+ // Replace env wholesale; empty object clears it.
1306
+ if (Object.keys(parsed.env).length === 0) delete entry.env;
1307
+ else entry.env = parsed.env;
1308
+ saveMcplServers(DEFAULT_CONFIG_PATH, servers);
1309
+ } catch (err) {
1310
+ this.send(client, { type: 'error', message: `mcpl-set-env failed: ${err instanceof Error ? err.message : String(err)}` });
1311
+ return;
1312
+ }
1313
+ this.sendMcplList(client);
1314
+ return;
1315
+ }
1316
+
1317
+ case 'request-workspace-mounts': {
1318
+ if (parsed.scope && parsed.scope !== 'local') {
1319
+ this.routeFleetRequest(client, parsed.scope, 'workspace-mounts',
1320
+ (corrId, fleet) => fleet.requestWorkspaceMounts(parsed.scope!, corrId));
1321
+ } else {
1322
+ void this.sendWorkspaceMounts(client);
1323
+ }
1324
+ return;
1325
+ }
1326
+
1327
+ case 'request-workspace-tree': {
1328
+ if (parsed.scope && parsed.scope !== 'local') {
1329
+ this.routeFleetRequest(client, parsed.scope, 'workspace-tree',
1330
+ (corrId, fleet) => fleet.requestWorkspaceTree(parsed.scope!, parsed.mount, corrId));
1331
+ } else {
1332
+ void this.sendWorkspaceTree(client, parsed.mount);
1333
+ }
1334
+ return;
1335
+ }
1336
+
1337
+ case 'request-workspace-file': {
1338
+ if (parsed.scope && parsed.scope !== 'local') {
1339
+ this.routeFleetRequest(client, parsed.scope, 'workspace-file',
1340
+ (corrId, fleet) => fleet.requestWorkspaceFile(parsed.scope!, parsed.path, corrId));
1341
+ } else {
1342
+ void this.sendWorkspaceFileRead(client, parsed.path);
1343
+ }
1344
+ return;
1345
+ }
1346
+ }
1347
+ }
1348
+
1349
+ /** Generate a corrId, register the requesting client, and dispatch a
1350
+ * request to the fleet child. The reply lands in handleFleetEvent which
1351
+ * looks the corrId up in pendingFleetRequests to find the client. */
1352
+ private routeFleetRequest(
1353
+ client: ClientState,
1354
+ childName: string,
1355
+ kind: string,
1356
+ dispatch: (corrId: string, fleet: FleetModule) => boolean,
1357
+ ): void {
1358
+ if (!sharedServer?.app) return;
1359
+ // Sweep expired entries before we add a new one. Without this the map
1360
+ // grows unbounded whenever a child is wedged — every "refresh files"
1361
+ // click pins another corrId until process exit. The sweep notifies the
1362
+ // originating client of the timeout instead of swallowing it.
1363
+ this.pruneExpiredFleetRequests();
1364
+ const fleet = sharedServer.app.framework.getAllModules().find((m) => m.name === 'fleet') as
1365
+ | FleetModule | undefined;
1366
+ if (!fleet) {
1367
+ this.send(client, { type: 'error', message: `fleet module not loaded` });
1368
+ return;
1369
+ }
1370
+ const corrId = `webui-${kind}-${client.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1371
+ sharedServer.pendingFleetRequests.set(corrId, {
1372
+ clientId: client.id,
1373
+ kind,
1374
+ // 30s TTL — lessons/workspace queries are quick; if the child is wedged,
1375
+ // we don't want pending entries piling up forever.
1376
+ expiresAt: Date.now() + 30_000,
1377
+ });
1378
+ const ok = dispatch(corrId, fleet);
1379
+ if (!ok) {
1380
+ sharedServer.pendingFleetRequests.delete(corrId);
1381
+ this.send(client, { type: 'error', message: `child '${childName}' is not available` });
1382
+ }
1383
+ }
1384
+
1385
+ /** Drop expired entries from `pendingFleetRequests` and notify the
1386
+ * originating client of each one. Idempotent — handlers tolerate the
1387
+ * late-arriving real reply (entry just won't be in the map anymore). */
1388
+ private pruneExpiredFleetRequests(): void {
1389
+ if (!sharedServer) return;
1390
+ const now = Date.now();
1391
+ for (const [corrId, entry] of sharedServer.pendingFleetRequests) {
1392
+ if (entry.expiresAt > now) continue;
1393
+ sharedServer.pendingFleetRequests.delete(corrId);
1394
+ const client = sharedServer.clients.get(entry.clientId);
1395
+ if (!client) continue;
1396
+ this.send(client, {
1397
+ type: 'error',
1398
+ message: `${entry.kind} request timed out (child unresponsive after 30s)`,
1399
+ });
1400
+ }
1401
+ }
1402
+
1403
+ /** Workspace surface — three small wrappers over the WorkspaceModule's
1404
+ * public tools (`ls`, `read`). Going through tools instead of internals
1405
+ * keeps the SPA decoupled from module implementation details. */
1406
+
1407
+ private async workspaceMod(): Promise<
1408
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
1409
+ | undefined
1410
+ > {
1411
+ if (!sharedServer?.app) return undefined;
1412
+ return sharedServer.app.framework.getAllModules().find((m) => m.name === 'workspace') as
1413
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
1414
+ | undefined;
1415
+ }
1416
+
1417
+ private async sendWorkspaceMounts(client: ClientState): Promise<void> {
1418
+ const mod = await this.workspaceMod();
1419
+ if (!mod) {
1420
+ this.send(client, { type: 'workspace-mounts', loaded: false, mounts: [] });
1421
+ return;
1422
+ }
1423
+ try {
1424
+ const result = await mod.handleToolCall({ name: 'ls', input: {}, id: `webui-ls-${Date.now()}` });
1425
+ const data = (result.data ?? {}) as { mounts?: Array<{ name: string; path: string; mode: string }> };
1426
+ this.send(client, {
1427
+ type: 'workspace-mounts',
1428
+ loaded: true,
1429
+ mounts: data.mounts ?? [],
1430
+ });
1431
+ } catch (err) {
1432
+ this.send(client, { type: 'error', message: `workspace mounts failed: ${err instanceof Error ? err.message : String(err)}` });
1433
+ }
1434
+ }
1435
+
1436
+ private async sendWorkspaceTree(client: ClientState, mount: string): Promise<void> {
1437
+ const mod = await this.workspaceMod();
1438
+ if (!mod) {
1439
+ this.send(client, { type: 'error', message: 'workspace module not loaded' });
1440
+ return;
1441
+ }
1442
+ try {
1443
+ const result = await mod.handleToolCall({
1444
+ name: 'ls',
1445
+ input: { path: mount, recursive: true },
1446
+ id: `webui-tree-${Date.now()}`,
1447
+ });
1448
+ if (!result.success) {
1449
+ this.send(client, { type: 'error', message: `workspace ls failed: ${result.error ?? 'unknown'}` });
1450
+ return;
1451
+ }
1452
+ const data = (result.data ?? {}) as { entries?: Array<{ path: string; size: number }> };
1453
+ this.send(client, {
1454
+ type: 'workspace-tree',
1455
+ mount,
1456
+ entries: data.entries ?? [],
1457
+ });
1458
+ } catch (err) {
1459
+ this.send(client, { type: 'error', message: `workspace ls failed: ${err instanceof Error ? err.message : String(err)}` });
1460
+ }
1461
+ }
1462
+
1463
+ private async sendWorkspaceFileRead(client: ClientState, path: string): Promise<void> {
1464
+ const mod = await this.workspaceMod();
1465
+ if (!mod) {
1466
+ this.send(client, { type: 'error', message: 'workspace module not loaded' });
1467
+ return;
1468
+ }
1469
+ // Cap responses by both lines AND bytes. Lines alone don't bound the
1470
+ // wire frame: a minified bundle, JSON-on-one-line, or infolog.txt with
1471
+ // embedded base64 can run 5k lines and still be hundreds of MB. The
1472
+ // byte cap (256 KB) is the actual safety net — operators reading
1473
+ // larger files should drop into a shell on the host.
1474
+ const LINE_LIMIT = 5000;
1475
+ const BYTE_LIMIT = 256 * 1024;
1476
+ try {
1477
+ const result = await mod.handleToolCall({
1478
+ name: 'read',
1479
+ input: { path, limit: LINE_LIMIT },
1480
+ id: `webui-read-${Date.now()}`,
1481
+ });
1482
+ if (!result.success) {
1483
+ this.send(client, { type: 'error', message: `read ${path} failed: ${result.error ?? 'unknown'}` });
1484
+ return;
1485
+ }
1486
+ const data = (result.data ?? {}) as {
1487
+ path?: string;
1488
+ totalLines?: number;
1489
+ fromLine?: number;
1490
+ toLine?: number;
1491
+ content?: string;
1492
+ };
1493
+ const totalLines = data.totalLines ?? 0;
1494
+ const reportedToLine = data.toLine ?? totalLines;
1495
+ let content = data.content ?? '';
1496
+ let toLine = reportedToLine;
1497
+ let truncatedByBytes = false;
1498
+ if (Buffer.byteLength(content, 'utf-8') > BYTE_LIMIT) {
1499
+ // Truncate at a UTF-8 boundary at-or-before BYTE_LIMIT bytes, then
1500
+ // adjust toLine to the last full line in the truncated content so
1501
+ // the SPA doesn't draw a half-line at the bottom.
1502
+ const truncated = sliceUtf8(content, BYTE_LIMIT);
1503
+ const lastNl = truncated.lastIndexOf('\n');
1504
+ content = lastNl >= 0 ? truncated.slice(0, lastNl) : truncated;
1505
+ const fromLine = data.fromLine ?? 1;
1506
+ toLine = fromLine + content.split('\n').length - 1;
1507
+ truncatedByBytes = true;
1508
+ }
1509
+ this.send(client, {
1510
+ type: 'workspace-file',
1511
+ path: data.path ?? path,
1512
+ totalLines,
1513
+ fromLine: data.fromLine ?? 1,
1514
+ toLine,
1515
+ content,
1516
+ truncated: truncatedByBytes || toLine < totalLines,
1517
+ });
1518
+ } catch (err) {
1519
+ this.send(client, { type: 'error', message: `read ${path} failed: ${err instanceof Error ? err.message : String(err)}` });
1520
+ }
1521
+ }
1522
+
1523
+ /** Read mcpl-servers.json and ship the list to a single client. The bound
1524
+ * config path is whatever the host's mcpl-config module resolves at
1525
+ * module-load time — usually `<cwd>/mcpl-servers.json`. */
1526
+ private sendMcplList(client: ClientState): void {
1527
+ let servers: ReturnType<typeof readMcplServersFile> = {};
1528
+ try { servers = readMcplServersFile(DEFAULT_CONFIG_PATH); }
1529
+ catch { /* missing or malformed file → empty list */ }
1530
+ const out: McplListMessage = {
1531
+ type: 'mcpl-list',
1532
+ configPath: DEFAULT_CONFIG_PATH,
1533
+ servers: Object.entries(servers).map(([id, entry]) => ({
1534
+ id,
1535
+ command: entry.command,
1536
+ ...(entry.args ? { args: entry.args } : {}),
1537
+ ...(entry.env ? { env: entry.env } : {}),
1538
+ ...(entry.toolPrefix ? { toolPrefix: entry.toolPrefix } : {}),
1539
+ ...(entry.reconnect !== undefined ? { reconnect: entry.reconnect } : {}),
1540
+ ...(entry.enabledFeatureSets ? { enabledFeatureSets: entry.enabledFeatureSets } : {}),
1541
+ ...(entry.disabledFeatureSets ? { disabledFeatureSets: entry.disabledFeatureSets } : {}),
1542
+ })),
1543
+ };
1544
+ this.send(client, out);
1545
+ }
1546
+
1547
+ /** Build a LessonsListMessage from the bound LessonsModule, if present. */
1548
+ private sendLessonsList(client: ClientState): void {
1549
+ if (!sharedServer?.app) return;
1550
+ const lessonsMod = sharedServer.app.framework.getAllModules().find((m) => m.name === 'lessons') as
1551
+ | { getLessons(): Array<{ id: string; content: string; confidence: number; tags: string[]; deprecated: boolean; deprecationReason?: string; created?: number; updated?: number }> }
1552
+ | undefined;
1553
+ if (!lessonsMod) {
1554
+ this.send(client, { type: 'lessons-list', loaded: false, lessons: [] });
1555
+ return;
1556
+ }
1557
+ const lessons = lessonsMod.getLessons().map(l => ({
1558
+ id: l.id,
1559
+ content: l.content,
1560
+ confidence: l.confidence,
1561
+ tags: l.tags,
1562
+ deprecated: l.deprecated,
1563
+ ...(l.deprecationReason ? { deprecationReason: l.deprecationReason } : {}),
1564
+ ...(typeof l.created === 'number' ? { created: l.created } : {}),
1565
+ ...(typeof l.updated === 'number' ? { updated: l.updated } : {}),
1566
+ }));
1567
+ this.send(client, { type: 'lessons-list', loaded: true, lessons });
1568
+ }
1569
+
1570
+ /** Names of fleet children currently running. Empty when no fleet module
1571
+ * is mounted or every child has stopped. */
1572
+ private runningFleetChildren(): string[] {
1573
+ if (!sharedServer?.app) return [];
1574
+ const fleetMod = sharedServer.app.framework.getAllModules().find((m) => m.name === 'fleet') as
1575
+ | { getChildren(): ReadonlyMap<string, { status: string }> }
1576
+ | undefined;
1577
+ if (!fleetMod) return [];
1578
+ const out: string[] = [];
1579
+ for (const [name, child] of fleetMod.getChildren()) {
1580
+ if (child.status === 'ready' || child.status === 'starting') out.push(name);
1581
+ }
1582
+ return out;
1583
+ }
1584
+
1585
+ /** Defer SIGTERM so the WS frame flushes, then trigger the existing
1586
+ * graceful-shutdown handler. process.exit fallback covers the case where
1587
+ * no SIGTERM listener is registered (e.g. TUI mode). */
1588
+ private scheduleShutdown(): void {
1589
+ setTimeout(() => {
1590
+ try { process.kill(process.pid, 'SIGTERM'); }
1591
+ catch { process.exit(0); }
1592
+ }, 150);
1593
+ }
1594
+
1595
+ /** Honor the operator's response to a quit-confirm-required prompt.
1596
+ * kill-children: stop them gracefully, then SIGTERM. Detach: SIGTERM
1597
+ * immediately and let them orphan. Cancel: keep the host running. */
1598
+ private async handleQuitConfirm(action: 'kill-children' | 'detach' | 'cancel'): Promise<void> {
1599
+ if (action === 'cancel') return;
1600
+ if (action === 'detach') {
1601
+ this.scheduleShutdown();
1602
+ return;
1603
+ }
1604
+ // kill-children: dispatch fleet kills in parallel and wait briefly.
1605
+ const running = this.runningFleetChildren();
1606
+ const fleetMod = sharedServer?.app?.framework.getAllModules().find((m) => m.name === 'fleet') as
1607
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; error?: string }> }
1608
+ | undefined;
1609
+ if (fleetMod) {
1610
+ await Promise.allSettled(running.map(name => fleetMod.handleToolCall({
1611
+ name: 'kill',
1612
+ input: { name },
1613
+ id: `webui-quit-${Date.now()}-${name}`,
1614
+ })));
1615
+ }
1616
+ this.scheduleShutdown();
1617
+ }
1618
+
1619
+ private async handleFleetControl(client: ClientState, op: 'fleet-stop' | 'fleet-restart', name: string): Promise<void> {
1620
+ if (!sharedServer?.app) return;
1621
+ const fleetMod = sharedServer.app.framework
1622
+ .getAllModules()
1623
+ .find((m) => m.name === 'fleet') as
1624
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
1625
+ | undefined;
1626
+ if (!fleetMod) {
1627
+ this.send(client, { type: 'error', message: 'fleet module not loaded' });
1628
+ return;
1629
+ }
1630
+ const tool = op === 'fleet-stop' ? 'kill' : 'restart';
1631
+ try {
1632
+ const result = await fleetMod.handleToolCall({
1633
+ name: tool,
1634
+ input: { name },
1635
+ id: `webui-${op}-${Date.now()}`,
1636
+ });
1637
+ const text = result.success
1638
+ ? `${op === 'fleet-stop' ? 'stopped' : 'restarted'} ${name}`
1639
+ : `${op} ${name} failed: ${result.error ?? 'unknown'}`;
1640
+ this.send(client, {
1641
+ type: 'command-result',
1642
+ lines: [{ text, style: result.success ? 'system' : 'tool' }],
1643
+ });
1644
+ } catch (err) {
1645
+ this.send(client, {
1646
+ type: 'error',
1647
+ message: `${op} ${name} failed: ${err instanceof Error ? err.message : String(err)}`,
1648
+ });
1649
+ }
1650
+ }
1651
+
1652
+ /**
1653
+ * Open or close a peek window for a subagent or fleet child.
1654
+ *
1655
+ * For in-process subagents we hook SubagentModule.onPeekStream(name) and
1656
+ * forward each event as a `peek` message scoped to the subagent name.
1657
+ *
1658
+ * For fleet children we don't need a separate subscription — child events
1659
+ * already flow to all welcomed clients via handleFleetEvent. Returning
1660
+ * "fleet child" here is enough to confirm the panel can rely on the
1661
+ * existing stream.
1662
+ */
1663
+ private handleSubscribePeek(client: ClientState, scope: string, active: boolean): void {
1664
+ if (!active) {
1665
+ const detach = client.peeks.get(scope);
1666
+ if (detach) {
1667
+ try { detach(); } catch { /* ignore */ }
1668
+ client.peeks.delete(scope);
1669
+ }
1670
+ return;
1671
+ }
1672
+
1673
+ // Idempotent: re-subscribing to an already-open scope is a no-op.
1674
+ if (client.peeks.has(scope)) return;
1675
+
1676
+ if (!sharedServer?.app) return;
1677
+
1678
+ // Fleet child path — events already arrive via child-event; no separate
1679
+ // subscription is needed. Mark the slot so unsubscribe-peek symmetry
1680
+ // works without special-casing.
1681
+ const fleetMod = sharedServer.app.framework
1682
+ .getAllModules()
1683
+ .find((m) => m.name === 'fleet') as { getChildren(): ReadonlyMap<string, unknown> } | undefined;
1684
+ if (fleetMod && fleetMod.getChildren().has(scope)) {
1685
+ client.peeks.set(scope, () => { /* no-op detach */ });
1686
+ return;
1687
+ }
1688
+
1689
+ // In-process subagent path — register on SubagentModule.onPeekStream.
1690
+ const subMod = sharedServer.app.framework
1691
+ .getAllModules()
1692
+ .find((m) => m.name === 'subagent') as
1693
+ | {
1694
+ onPeekStream(name: string, cb: (ev: { type: string; [k: string]: unknown }) => void): () => void;
1695
+ peek(name?: string): Promise<Array<{
1696
+ name: string;
1697
+ status: string;
1698
+ messageCount: number;
1699
+ lastMessageSnippet: string;
1700
+ currentStream: string;
1701
+ pendingToolCalls: Array<{ name: string; input?: unknown }>;
1702
+ elapsedMs: number;
1703
+ isZombie: boolean;
1704
+ }>>;
1705
+ }
1706
+ | undefined;
1707
+ if (!subMod) {
1708
+ this.send(client, { type: 'error', message: `subscribe-peek: scope '${scope}' not found` });
1709
+ return;
1710
+ }
1711
+ // Backfill: send a one-shot summary derived from the subagent's current
1712
+ // peek snapshot before live events start. Operators opening a peek panel
1713
+ // shouldn't see "Waiting for events…" when the agent is mid-task — the
1714
+ // peek already knows what's in flight.
1715
+ void this.sendPeekBackfill(client, subMod, scope);
1716
+ const detach = subMod.onPeekStream(scope, (event) => {
1717
+ this.send(client, {
1718
+ type: 'peek',
1719
+ scope,
1720
+ event: event as { type: string; [k: string]: unknown },
1721
+ });
1722
+ });
1723
+ client.peeks.set(scope, detach);
1724
+ }
1725
+
1726
+ /** Push a synthetic backfill bundle for a subagent peek subscription so the
1727
+ * client renders something meaningful immediately rather than waiting for
1728
+ * the next live event. Best-effort — peek may fail mid-modification. */
1729
+ private async sendPeekBackfill(
1730
+ client: ClientState,
1731
+ subMod: {
1732
+ peek(name?: string): Promise<Array<{
1733
+ name: string;
1734
+ status: string;
1735
+ messageCount: number;
1736
+ lastMessageSnippet: string;
1737
+ currentStream: string;
1738
+ pendingToolCalls: Array<{ name: string; input?: unknown }>;
1739
+ elapsedMs: number;
1740
+ isZombie: boolean;
1741
+ }>>;
1742
+ },
1743
+ scope: string,
1744
+ ): Promise<void> {
1745
+ let snap: Awaited<ReturnType<typeof subMod.peek>>[number] | undefined;
1746
+ try {
1747
+ const snaps = await subMod.peek(scope);
1748
+ snap = snaps[0];
1749
+ } catch {
1750
+ return;
1751
+ }
1752
+ if (!snap) return;
1753
+
1754
+ // Header line — gives operators an at-a-glance read on what they're
1755
+ // looking at without scrolling for context.
1756
+ const headerBits: string[] = [
1757
+ `status=${snap.status}`,
1758
+ `msgs=${snap.messageCount}`,
1759
+ `elapsed=${Math.round(snap.elapsedMs / 1000)}s`,
1760
+ ];
1761
+ if (snap.isZombie) headerBits.push('zombie');
1762
+ this.send(client, {
1763
+ type: 'peek',
1764
+ scope,
1765
+ event: { type: 'lifecycle', phase: `peek opened — ${headerBits.join(' ')}` },
1766
+ });
1767
+
1768
+ if (snap.lastMessageSnippet) {
1769
+ this.send(client, {
1770
+ type: 'peek',
1771
+ scope,
1772
+ event: { type: 'lifecycle', phase: `last: ${snap.lastMessageSnippet.slice(-200)}` },
1773
+ });
1774
+ }
1775
+
1776
+ if (snap.currentStream) {
1777
+ // Replay accumulated stream tokens as a single tokens event; the
1778
+ // client folds tokens by newline so this renders as the most recent
1779
+ // few stream lines in cyan.
1780
+ this.send(client, {
1781
+ type: 'peek',
1782
+ scope,
1783
+ event: { type: 'tokens', content: snap.currentStream },
1784
+ });
1785
+ }
1786
+
1787
+ for (const call of snap.pendingToolCalls) {
1788
+ this.send(client, {
1789
+ type: 'peek',
1790
+ scope,
1791
+ event: { type: 'tool:started', tool: call.name },
1792
+ });
1793
+ }
1794
+ }
1795
+
1796
+ private async handleRouteToChild(client: ClientState, childName: string, content: string): Promise<void> {
1797
+ if (!sharedServer?.app) return;
1798
+ const fleetMod = sharedServer.app.framework
1799
+ .getAllModules()
1800
+ .find((m) => m.name === 'fleet') as
1801
+ | { handleToolCall(call: { name: string; input: unknown; id?: string }): Promise<{ success: boolean; data?: unknown; error?: string }> }
1802
+ | undefined;
1803
+ if (!fleetMod) {
1804
+ this.send(client, { type: 'error', message: 'fleet module not loaded' });
1805
+ return;
1806
+ }
1807
+ try {
1808
+ const result = await fleetMod.handleToolCall({
1809
+ name: 'send',
1810
+ input: { name: childName, content },
1811
+ id: `webui-route-${Date.now()}`,
1812
+ });
1813
+ const text = result.success
1814
+ ? `→ @${childName}: ${content}`
1815
+ : `route failed: ${result.error ?? 'unknown'}`;
1816
+ this.send(client, {
1817
+ type: 'command-result',
1818
+ lines: [{ text, style: result.success ? 'system' : 'tool' }],
1819
+ });
1820
+ } catch (err) {
1821
+ this.send(client, {
1822
+ type: 'error',
1823
+ message: `route to ${childName} failed: ${err instanceof Error ? err.message : String(err)}`,
1824
+ });
1825
+ }
1826
+ }
1827
+
1828
+ private onWsClose(ws: ServerWebSocket<{ id: number }>): void {
1829
+ const id = ws.data.id;
1830
+ const client = sharedServer?.clients.get(id);
1831
+ if (client) {
1832
+ for (const detach of client.peeks.values()) {
1833
+ try { detach(); } catch { /* ignore */ }
1834
+ }
1835
+ client.peeks.clear();
1836
+ }
1837
+ sharedServer?.clients.delete(id);
1838
+ }
1839
+
1840
+ /**
1841
+ * Run a slash command and surface its CommandResult plus any side effects
1842
+ * (workspace materialization on branch change, session switch on
1843
+ * switchToSessionId, async follow-up). All clients see fresh welcomes after
1844
+ * branch / session changes since those affect framework-wide state, not
1845
+ * just the issuing client.
1846
+ */
1847
+ private async dispatchCommand(client: ClientState, command: string, corrId?: string): Promise<void> {
1848
+ if (!sharedServer?.app) return;
1849
+ let result;
1850
+ try {
1851
+ result = handleCommand(command, sharedServer?.app);
1852
+ } catch (err) {
1853
+ this.send(client, {
1854
+ type: 'error',
1855
+ corrId,
1856
+ message: err instanceof Error ? err.message : String(err),
1857
+ });
1858
+ return;
1859
+ }
1860
+
1861
+ this.send(client, {
1862
+ type: 'command-result',
1863
+ corrId,
1864
+ lines: result.lines,
1865
+ quit: result.quit,
1866
+ branchChanged: result.branchChanged,
1867
+ switchToSessionId: result.switchToSessionId,
1868
+ pending: result.asyncWork !== undefined,
1869
+ });
1870
+
1871
+ // /quit handling. If the recipe has running fleet children, hold the
1872
+ // shutdown and ask the operator how to handle them — same three-way
1873
+ // prompt as the TUI. Otherwise fall through to immediate SIGTERM.
1874
+ if (result.quit) {
1875
+ const running = this.runningFleetChildren();
1876
+ if (running.length > 0) {
1877
+ this.send(client, { type: 'quit-confirm-required', children: running });
1878
+ return;
1879
+ }
1880
+ this.scheduleShutdown();
1881
+ }
1882
+
1883
+ // Branch-change side effects parity with TUI / runPiped: materialize the
1884
+ // _config mount so gate.json etc. stay in sync, then refresh every
1885
+ // welcomed client by re-sending welcome with the new branch's messages.
1886
+ if (result.branchChanged) {
1887
+ await this.materializeConfigMount();
1888
+ this.broadcastBranchChanged();
1889
+ this.refreshAllWelcomes();
1890
+ }
1891
+
1892
+ // Session switch — destroys + recreates the framework. setApp() is called
1893
+ // by index.ts after the switch lands, which re-welcomes everyone.
1894
+ if (result.switchToSessionId) {
1895
+ try {
1896
+ await sharedServer?.app.switchSession(result.switchToSessionId);
1897
+ // setApp() re-flushes welcomes; nothing more to do here.
1898
+ } catch (err) {
1899
+ this.send(client, {
1900
+ type: 'error',
1901
+ corrId,
1902
+ message: `session switch failed: ${err instanceof Error ? err.message : String(err)}`,
1903
+ });
1904
+ }
1905
+ }
1906
+
1907
+ // Async follow-up (e.g. /newtopic Haiku summarization).
1908
+ if (result.asyncWork) {
1909
+ try {
1910
+ const follow = await result.asyncWork;
1911
+ this.send(client, {
1912
+ type: 'command-result',
1913
+ corrId,
1914
+ lines: follow.lines,
1915
+ quit: follow.quit,
1916
+ branchChanged: follow.branchChanged,
1917
+ switchToSessionId: follow.switchToSessionId,
1918
+ });
1919
+ if (follow.branchChanged) {
1920
+ await this.materializeConfigMount();
1921
+ this.broadcastBranchChanged();
1922
+ this.refreshAllWelcomes();
1923
+ }
1924
+ } catch (err) {
1925
+ this.send(client, {
1926
+ type: 'error',
1927
+ corrId,
1928
+ message: err instanceof Error ? err.message : String(err),
1929
+ });
1930
+ }
1931
+ }
1932
+ }
1933
+
1934
+ private async materializeConfigMount(): Promise<void> {
1935
+ if (!sharedServer?.app) return;
1936
+ const ws = sharedServer?.app.framework.getModule('workspace');
1937
+ if (!ws || !('materializeMount' in ws)) return;
1938
+ try {
1939
+ await (ws as { materializeMount: (name: string) => Promise<unknown> }).materializeMount('_config');
1940
+ } catch {
1941
+ // Materialization is best-effort; failures here shouldn't break the UI.
1942
+ }
1943
+ }
1944
+
1945
+ private broadcastBranchChanged(): void {
1946
+ if (!sharedServer?.app) return;
1947
+ const cm = sharedServer?.app.framework.getAllAgents()[0]?.getContextManager();
1948
+ if (!cm) return;
1949
+ const branch = cm.currentBranch();
1950
+ const msg: WebUiServerMessage = {
1951
+ type: 'branch-changed',
1952
+ branch: { id: branch.id, name: branch.name },
1953
+ };
1954
+ for (const c of sharedServer!.clients.values()) {
1955
+ if (c.welcomed) this.send(c, msg);
1956
+ }
1957
+ }
1958
+
1959
+ private refreshAllWelcomes(): void {
1960
+ for (const c of sharedServer!.clients.values()) {
1961
+ c.welcomed = false;
1962
+ void this.sendWelcome(c);
1963
+ }
1964
+ }
1965
+
1966
+ // -------------------------------------------------------------------------
1967
+ // Outgoing — welcome, traces, etc.
1968
+ // -------------------------------------------------------------------------
1969
+
1970
+ private async sendWelcome(client: ClientState): Promise<void> {
1971
+ if (!sharedServer?.app) return;
1972
+
1973
+ const welcome = await this.buildWelcome();
1974
+ this.send(client, welcome);
1975
+ client.welcomed = true;
1976
+ // Live trace forwarding is driven by the single fan-out listener
1977
+ // installed in setApp(); membership is implicit in `sharedServer!.clients`.
1978
+ }
1979
+
1980
+ /**
1981
+ * Serve a page of older history ending just before `beforeIndex`. Windowed
1982
+ * read with bodyGroup alignment; the reply carries the same corrId so the
1983
+ * SPA can match it to its in-flight scroll request.
1984
+ */
1985
+ private handleRequestHistory(client: ClientState, req: RequestHistoryMessage): void {
1986
+ const cm = sharedServer?.app?.framework.getAllAgents()[0]?.getContextManager();
1987
+ if (!cm) {
1988
+ this.send(client, { type: 'error', corrId: req.corrId, message: 'no context manager' });
1989
+ return;
1990
+ }
1991
+ const cmw = cm as unknown as WindowCapableCm;
1992
+ if (typeof cmw.getMessageWindow !== 'function') {
1993
+ this.send(client, {
1994
+ type: 'error', corrId: req.corrId,
1995
+ message: 'history paging unavailable (context-manager without windowed reads)',
1996
+ });
1997
+ return;
1998
+ }
1999
+ const limit = Math.min(req.limit ?? HISTORY_PAGE_DEFAULT, HISTORY_PAGE_MAX);
2000
+ const beforeIndex = Math.min(req.beforeIndex, cmw.getMessageCount());
2001
+ const offset = Math.max(0, beforeIndex - limit);
2002
+ const win = cmw.getMessageWindow(offset, beforeIndex - offset, {
2003
+ resolveBlobs: false,
2004
+ alignToBodyGroups: true,
2005
+ });
2006
+ this.send(client, {
2007
+ type: 'history-page',
2008
+ corrId: req.corrId,
2009
+ entries: coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex),
2010
+ startIndex: win.startIndex,
2011
+ totalCount: win.totalCount,
2012
+ });
2013
+ }
2014
+
2015
+ private async buildWelcome(): Promise<WelcomeMessage> {
2016
+ const app = sharedServer?.app!;
2017
+ const fw = app.framework;
2018
+ const agents = fw.getAllAgents();
2019
+ const session = app.sessionManager.getActiveSession();
2020
+ if (!session) {
2021
+ throw new Error('cannot build welcome: no active session');
2022
+ }
2023
+
2024
+ // Conversation snapshot via the first agent's context manager — TAIL
2025
+ // WINDOW only. The full-history welcome was the 51–108s render incident
2026
+ // (lena, 2026-07-02): 4.6k messages materialized, flattened, and
2027
+ // JSON.stringify'd per client per (re)connect. Clients page older
2028
+ // history on demand via request-history.
2029
+ const cm = agents[0]?.getContextManager();
2030
+ let messages: WelcomeMessageEntry[] = [];
2031
+ let history = { startIndex: 0, totalCount: 0 };
2032
+ if (cm) {
2033
+ const cmw = cm as unknown as WindowCapableCm;
2034
+ if (typeof cmw.getMessageWindow === 'function' && typeof cmw.getMessageCount === 'function') {
2035
+ const total = cmw.getMessageCount();
2036
+ const start = Math.max(0, total - WELCOME_HISTORY_LIMIT);
2037
+ const win = cmw.getMessageWindow(start, total - start, {
2038
+ resolveBlobs: false,
2039
+ alignToBodyGroups: true,
2040
+ });
2041
+ messages = coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex);
2042
+ history = { startIndex: win.startIndex, totalCount: win.totalCount };
2043
+ } else {
2044
+ // Facade skew (older @animalabs/context-manager without windowed
2045
+ // reads): fall back to the historical full walk, sliced locally.
2046
+ const all = cm.getAllMessages() as unknown as MessageLike[];
2047
+ const start = Math.max(0, all.length - WELCOME_HISTORY_LIMIT);
2048
+ messages = coalesceAndFlatten(all.slice(start), start);
2049
+ history = { startIndex: start, totalCount: all.length };
2050
+ }
2051
+ }
2052
+
2053
+ // Parent-local snapshot via a transient reducer fed by the framework's
2054
+ // current trace history. We have no replayable past traces, so the
2055
+ // initial snapshot just registers the agents — the live trace stream
2056
+ // takes over from there. Future: persist a parent-local reducer if
2057
+ // cold-attach state recovery becomes important.
2058
+ const localReducer = new AgentTreeReducer();
2059
+ localReducer.seedFrameworkAgents(agents.map(a => a.name));
2060
+ const localSnap: AgentTreeSnapshot = localReducer.getSnapshot();
2061
+
2062
+ // Per-child snapshots from the FleetTreeAggregator (if mounted). Each
2063
+ // child's reducer was either freshly seeded by `describe` on the most
2064
+ // recent lifecycle:ready, or empty if the child hasn't responded yet —
2065
+ // either way the live event stream keeps it current.
2066
+ const childTrees: WelcomeMessage['childTrees'] = [];
2067
+ if (sharedServer?.treeAggregator) {
2068
+ const fleetMod = sharedServer.app?.framework.getAllModules().find((m) => m.name === 'fleet') as
2069
+ | FleetModule | undefined;
2070
+ for (const name of sharedServer?.treeAggregator.getAllChildNames()) {
2071
+ const nodes = sharedServer?.treeAggregator.getChildNodes(name);
2072
+ const recipeInfo = fleetMod ? await this.loadChildRecipeInfo(fleetMod, name) : undefined;
2073
+ childTrees.push({
2074
+ name,
2075
+ asOfTs: Date.now(),
2076
+ nodes: nodes as unknown as Array<Record<string, unknown>>,
2077
+ callIdIndex: {},
2078
+ ...(recipeInfo ? { recipe: recipeInfo } : {}),
2079
+ });
2080
+ }
2081
+ }
2082
+
2083
+ const branch = cm?.currentBranch();
2084
+
2085
+ return {
2086
+ type: 'welcome',
2087
+ protocolVersion: WEB_PROTOCOL_VERSION,
2088
+ recipe: {
2089
+ name: app.recipe.name,
2090
+ description: app.recipe.description,
2091
+ version: app.recipe.version,
2092
+ },
2093
+ agents: agents.map(a => ({ name: a.name, model: a.model })),
2094
+ session: {
2095
+ id: session.id,
2096
+ name: session.name,
2097
+ autoNamed: !session.manuallyNamed,
2098
+ },
2099
+ branch: {
2100
+ id: branch?.id ?? '',
2101
+ name: branch?.name ?? '',
2102
+ },
2103
+ messages,
2104
+ history,
2105
+ localTree: {
2106
+ asOfTs: localSnap.asOfTs,
2107
+ nodes: localSnap.nodes as unknown as Array<Record<string, unknown>>,
2108
+ callIdIndex: localSnap.callIdIndex,
2109
+ },
2110
+ childTrees,
2111
+ usage: sharedServer!.latestUsage,
2112
+ ...(sharedServer!.latestPerAgentCost.length > 0
2113
+ ? { perAgentCost: sharedServer!.latestPerAgentCost }
2114
+ : {}),
2115
+ };
2116
+ }
2117
+
2118
+ private send(client: ClientState, msg: WebUiServerMessage): void {
2119
+ try {
2120
+ client.ws.send(JSON.stringify(msg));
2121
+ } catch {
2122
+ // Connection dropped between send attempts; close handler will clean up.
2123
+ }
2124
+ }
2125
+
2126
+ // -------------------------------------------------------------------------
2127
+ // Auth / safety
2128
+ // -------------------------------------------------------------------------
2129
+
2130
+ private assertSafeBind(host: string): void {
2131
+ const isLoopback = host === '127.0.0.1' || host === '::1' || host === 'localhost';
2132
+ if (isLoopback) return;
2133
+ if (this.config.basicAuth) return;
2134
+ throw new Error(
2135
+ `WebUiModule refuses to bind ${host} without auth. The default bind is ` +
2136
+ `0.0.0.0, so any recipe that enables webui must supply basicAuth ` +
2137
+ `(username/password, ideally via \${ENV_VAR} substitution). Set ` +
2138
+ `host: '127.0.0.1' to bind loopback-only for local development, which ` +
2139
+ `skips the auth requirement.`,
2140
+ );
2141
+ }
2142
+
2143
+ /**
2144
+ * Validate the Origin header against the configured allowlist. An empty
2145
+ * allowlist means "no Origin check" — only sensible behind a proxy that
2146
+ * enforces it for us. Same-origin native clients (curl, custom MCP
2147
+ * tooling) typically send no Origin at all; we accept those as well, since
2148
+ * the threat model here is browsers cross-origin connecting from another
2149
+ * tab. Auth still gates anything sensitive.
2150
+ */
2151
+ private checkOrigin(req: Request): boolean {
2152
+ if (!sharedServer) return false;
2153
+ const allow = sharedServer.allowedOrigins;
2154
+ if (allow.length === 0) return true;
2155
+ const origin = req.headers.get('origin');
2156
+ // No Origin header → not a browser cross-origin attempt. (Browsers
2157
+ // always set Origin on WebSocket upgrades; non-browser clients usually
2158
+ // don't.)
2159
+ if (!origin) return true;
2160
+ if (allow.includes(origin)) return true;
2161
+ // Same-origin: the page making this upgrade was served by this very
2162
+ // server, just via a hostname the static default list can't predict —
2163
+ // Tailscale IP, MagicDNS name, LAN hostname. A hostile page on another
2164
+ // origin cannot forge its Origin header, so "Origin host equals the
2165
+ // request's Host header" is safe to allow and is exactly the case the
2166
+ // localhost-only default was wrongly rejecting (page loads over HTTP,
2167
+ // then every ws:// upgrade 403s).
2168
+ try {
2169
+ const originHost = new URL(origin).host;
2170
+ const host = req.headers.get('host');
2171
+ if (host && originHost === host) return true;
2172
+ } catch {
2173
+ // Malformed Origin — fall through to reject.
2174
+ }
2175
+ return false;
2176
+ }
2177
+
2178
+ private checkAuth(req: Request): boolean {
2179
+ if (!this.config.basicAuth) return true;
2180
+ const header = req.headers.get('authorization');
2181
+ if (!header || !header.toLowerCase().startsWith('basic ')) return false;
2182
+ let decoded: string;
2183
+ try {
2184
+ decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf-8');
2185
+ } catch {
2186
+ return false;
2187
+ }
2188
+ const idx = decoded.indexOf(':');
2189
+ if (idx < 0) return false;
2190
+ const user = decoded.slice(0, idx);
2191
+ const pass = decoded.slice(idx + 1);
2192
+ // Use SHA-256 digests so the timing-safe compare runs over fixed-length
2193
+ // buffers regardless of credential length, and a wrong-length input
2194
+ // doesn't bail early via the length-mismatch path. Both halves are
2195
+ // always compared so a mismatch in `user` doesn't short-circuit `pass`.
2196
+ const userOk = constantTimeStringEq(user, this.config.basicAuth.username);
2197
+ const passOk = constantTimeStringEq(pass, this.config.basicAuth.password);
2198
+ return userOk && passOk;
2199
+ }
2200
+
2201
+ private unauthorized(): Response {
2202
+ return new Response('Unauthorized', {
2203
+ status: 401,
2204
+ headers: { 'www-authenticate': 'Basic realm="connectome-host"' },
2205
+ });
2206
+ }
2207
+ }
2208
+
2209
+ // ---------------------------------------------------------------------------
2210
+ // Helpers
2211
+ // ---------------------------------------------------------------------------
2212
+
2213
+ /**
2214
+ * Default Origin allowlist for the WebSocket upgrade. Covers the recommended
2215
+ * deployment (loopback bind, page served from the same Bun.serve), plus the
2216
+ * `https://` form so a Caddy/nginx terminating TLS in front of this still
2217
+ * works without overriding `allowedOrigins` explicitly.
2218
+ */
2219
+ function defaultAllowedOrigins(port: number): string[] {
2220
+ return [
2221
+ `http://127.0.0.1:${port}`,
2222
+ `http://localhost:${port}`,
2223
+ `https://127.0.0.1:${port}`,
2224
+ `https://localhost:${port}`,
2225
+ ];
2226
+ }
2227
+
2228
+ /**
2229
+ * Constant-time string equality. Hashes both inputs with SHA-256 first so the
2230
+ * underlying compare runs on fixed-length 32-byte buffers — `timingSafeEqual`
2231
+ * itself throws on length mismatch, which leaks length, and direct buffer
2232
+ * compares of the raw strings would leak length too. Two HMAC-style
2233
+ * comparisons (same input through SHA-256 twice) is a standard pattern.
2234
+ */
2235
+ function constantTimeStringEq(a: string, b: string): boolean {
2236
+ const ha = createHash('sha256').update(a, 'utf-8').digest();
2237
+ const hb = createHash('sha256').update(b, 'utf-8').digest();
2238
+ return timingSafeEqual(ha, hb);
2239
+ }
2240
+
2241
+ /**
2242
+ * Slice a string to at most `maxBytes` UTF-8 bytes without splitting a
2243
+ * multi-byte sequence. Buffer.from + slice + toString is the standard idiom;
2244
+ * if the cut would land mid-codepoint, walk back to the last lead byte.
2245
+ */
2246
+ function sliceUtf8(s: string, maxBytes: number): string {
2247
+ const buf = Buffer.from(s, 'utf-8');
2248
+ if (buf.length <= maxBytes) return s;
2249
+ let end = maxBytes;
2250
+ // Continuation bytes match 10xxxxxx (0x80..0xbf). Step back until we land
2251
+ // on either ASCII (0x00..0x7f) or a lead byte (0xc0..0xff).
2252
+ while (end > 0 && (buf[end] !== undefined && (buf[end]! & 0xc0) === 0x80)) end--;
2253
+ return buf.subarray(0, end).toString('utf-8');
2254
+ }
2255
+
2256
+ interface MessageLike {
2257
+ id?: string;
2258
+ participant: string;
2259
+ content: ReadonlyArray<unknown>;
2260
+ timestamp?: number | Date;
2261
+ bodyGroupId?: string;
2262
+ shardIndex?: number;
2263
+ }
2264
+
2265
+ // Per-block size caps for wire frames. A single pathological tool result or
2266
+ // pasted document must not re-bloat the windowed welcome back into the
2267
+ // megaframe territory this protocol version exists to eliminate.
2268
+ const TEXT_CAP = 64 * 1024;
2269
+ const THINKING_CAP = 32 * 1024;
2270
+ const TOOL_INPUT_CAP = 16 * 1024;
2271
+ const TOOL_RESULT_CAP = 16 * 1024;
2272
+
2273
+ function capText(s: string, cap: number): { text: string; truncated?: boolean } {
2274
+ const sliced = sliceUtf8(s, cap);
2275
+ return sliced === s ? { text: s } : { text: sliced, truncated: true };
2276
+ }
2277
+
2278
+ /**
2279
+ * Project a stored message into its wire form: ordered MessageBlocks carrying
2280
+ * the full internal life (thinking, tool calls AND results), with media
2281
+ * reduced to type placeholders and per-block size caps applied.
2282
+ *
2283
+ * `index` is the message's store slot index — the client's paging cursor.
2284
+ */
2285
+ function toWireEntry(msg: MessageLike, index: number): WelcomeMessageEntry {
2286
+ const blocks: import('../web/protocol.js').MessageBlock[] = [];
2287
+ const textParts: string[] = [];
2288
+ let toolResults = 0;
2289
+ let conversational = 0; // text/thinking blocks — used for participant fixup
2290
+
2291
+ for (const block of msg.content) {
2292
+ const b = block as {
2293
+ type?: string; text?: unknown; thinking?: unknown; data?: unknown;
2294
+ id?: unknown; name?: unknown; input?: unknown;
2295
+ toolUseId?: unknown; content?: unknown; isError?: unknown;
2296
+ source?: { mediaType?: unknown }; mediaType?: unknown;
2297
+ ref?: { mediaType?: unknown };
2298
+ };
2299
+ switch (b.type) {
2300
+ case 'text':
2301
+ if (typeof b.text === 'string') {
2302
+ const capped = capText(b.text, TEXT_CAP);
2303
+ blocks.push({ kind: 'text', ...capped });
2304
+ textParts.push(capped.text);
2305
+ conversational++;
2306
+ }
2307
+ break;
2308
+ case 'thinking':
2309
+ if (typeof b.thinking === 'string') {
2310
+ blocks.push({ kind: 'thinking', ...capText(b.thinking, THINKING_CAP) });
2311
+ conversational++;
2312
+ }
2313
+ break;
2314
+ case 'redacted_thinking':
2315
+ blocks.push({
2316
+ kind: 'redacted_thinking',
2317
+ bytes: typeof b.data === 'string' ? b.data.length : 0,
2318
+ });
2319
+ conversational++;
2320
+ break;
2321
+ case 'tool_use':
2322
+ if (typeof b.id === 'string' && typeof b.name === 'string') {
2323
+ let inputJson: string;
2324
+ try {
2325
+ inputJson = JSON.stringify(b.input, null, 2) ?? 'null';
2326
+ } catch {
2327
+ inputJson = '[unserializable input]';
2328
+ }
2329
+ const capped = capText(inputJson, TOOL_INPUT_CAP);
2330
+ blocks.push({
2331
+ kind: 'tool_use', id: b.id, name: b.name,
2332
+ inputJson: capped.text,
2333
+ ...(capped.truncated ? { truncated: true } : {}),
2334
+ });
2335
+ }
2336
+ break;
2337
+ case 'tool_result':
2338
+ if (typeof b.toolUseId === 'string') {
2339
+ blocks.push({
2340
+ kind: 'tool_result',
2341
+ toolUseId: b.toolUseId,
2342
+ ...capText(flattenToolResultContent(b.content), TOOL_RESULT_CAP),
2343
+ ...(b.isError === true ? { isError: true } : {}),
2344
+ });
2345
+ toolResults++;
2346
+ }
2347
+ break;
2348
+ case 'image':
2349
+ case 'document':
2350
+ case 'audio':
2351
+ case 'video':
2352
+ blocks.push({
2353
+ kind: 'media',
2354
+ mediaType:
2355
+ typeof b.source?.mediaType === 'string' ? b.source.mediaType
2356
+ : typeof b.mediaType === 'string' ? b.mediaType
2357
+ : b.type,
2358
+ });
2359
+ break;
2360
+ case 'blob_ref':
2361
+ // Un-inflated media placeholder (welcome/history are read with
2362
+ // resolveBlobs: false so multi-MB base64 never hits the wire).
2363
+ blocks.push({
2364
+ kind: 'media',
2365
+ mediaType: typeof b.ref?.mediaType === 'string' ? b.ref.mediaType : 'blob',
2366
+ });
2367
+ break;
2368
+ default:
2369
+ break; // unknown block types are skipped, not errored
2370
+ }
2371
+ }
2372
+
2373
+ // Messages that are purely tool results are turn-plumbing, not prose;
2374
+ // surface them as participant 'tool' so the client can pair/hide them.
2375
+ const participant = toolResults > 0 && conversational === 0
2376
+ ? 'tool'
2377
+ : normalizeParticipant(msg.participant);
2378
+
2379
+ const entry: WelcomeMessageEntry = {
2380
+ index,
2381
+ participant,
2382
+ blocks,
2383
+ text: textParts.join('\n'),
2384
+ };
2385
+ if (msg.id) entry.id = msg.id;
2386
+ const ts = msg.timestamp instanceof Date ? msg.timestamp.getTime() : msg.timestamp;
2387
+ if (ts) entry.timestamp = ts;
2388
+ return entry;
2389
+ }
2390
+
2391
+ /** tool_result content is `string | ContentBlock[]` — flatten nested blocks
2392
+ * to text with placeholders for media. */
2393
+ function flattenToolResultContent(content: unknown): string {
2394
+ if (typeof content === 'string') return content;
2395
+ if (!Array.isArray(content)) return content == null ? '' : String(content);
2396
+ const parts: string[] = [];
2397
+ for (const block of content) {
2398
+ const b = block as { type?: string; text?: unknown };
2399
+ if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
2400
+ else if (b.type === 'image') parts.push('[image]');
2401
+ else if (typeof b.type === 'string') parts.push(`[${b.type}]`);
2402
+ }
2403
+ return parts.join('\n');
2404
+ }
2405
+
2406
+ /**
2407
+ * Coalesce consecutive shards of a bodyGroup (one large message chunked at
2408
+ * ingestion) into a single wire entry, then flatten everything. Shards are
2409
+ * contiguous by construction; within a run they're ordered by shardIndex.
2410
+ * The coalesced entry's `index` is the FIRST shard's slot index so paging
2411
+ * cursors stay aligned with store slots.
2412
+ */
2413
+ function coalesceAndFlatten(messages: readonly MessageLike[], startIndex: number): WelcomeMessageEntry[] {
2414
+ const out: WelcomeMessageEntry[] = [];
2415
+ let i = 0;
2416
+ while (i < messages.length) {
2417
+ const m = messages[i]!;
2418
+ if (!m.bodyGroupId) {
2419
+ out.push(toWireEntry(m, startIndex + i));
2420
+ i++;
2421
+ continue;
2422
+ }
2423
+ const runStart = i;
2424
+ while (i < messages.length && messages[i]!.bodyGroupId === m.bodyGroupId) i++;
2425
+ const shards = messages.slice(runStart, i)
2426
+ .map((shard, k) => ({ shard, k }))
2427
+ .sort((a, b) => (a.shard.shardIndex ?? a.k) - (b.shard.shardIndex ?? b.k))
2428
+ .map(({ shard }) => shard);
2429
+ // Merge adjacent text blocks with NO separator — shard cuts land
2430
+ // mid-text, so reassembly must be byte-faithful concatenation.
2431
+ const mergedContent: unknown[] = [];
2432
+ for (const blk of shards.flatMap((s) => [...s.content])) {
2433
+ const prev = mergedContent[mergedContent.length - 1] as { type?: string; text?: string } | undefined;
2434
+ const cur = blk as { type?: string; text?: unknown };
2435
+ if (prev?.type === 'text' && cur.type === 'text'
2436
+ && typeof prev.text === 'string' && typeof cur.text === 'string') {
2437
+ mergedContent[mergedContent.length - 1] = { type: 'text', text: prev.text + cur.text };
2438
+ } else {
2439
+ mergedContent.push(blk);
2440
+ }
2441
+ }
2442
+ const merged: MessageLike = { ...shards[0]!, content: mergedContent };
2443
+ out.push(toWireEntry(merged, startIndex + runStart));
2444
+ }
2445
+ return out;
2446
+ }
2447
+
2448
+ /** The framework stores assistant turns under the agent's name (e.g.
2449
+ * "commander", "miner") rather than the literal "assistant" string. The TUI
2450
+ * treats anything not 'user' as agent output; mirror that here so the WebUI
2451
+ * doesn't render restored agent turns as user messages on session resume. */
2452
+ function normalizeParticipant(raw: string): WelcomeMessageEntry['participant'] {
2453
+ if (raw === 'user' || raw === 'system' || raw === 'tool') return raw;
2454
+ return 'assistant';
2455
+ }
2456
+
2457
+ /** Build a human label for an MCPL trigger origin. The exact metadata shape
2458
+ * varies by MCPL flavor (channel-incoming carries channelId; push-event has
2459
+ * serverId + featureSet) — surface what's most informative without
2460
+ * over-fitting to one server's schema. */
2461
+ function describeTriggerOrigin(source: string, md: Record<string, unknown>): string {
2462
+ const serverId = typeof md.serverId === 'string' ? md.serverId : '?';
2463
+ if (source === 'mcpl:channel-incoming') {
2464
+ const channelId = typeof md.channelId === 'string' ? md.channelId : '';
2465
+ return channelId ? `${serverId}#${channelId}` : serverId;
2466
+ }
2467
+ if (source === 'mcpl:push-event') {
2468
+ const featureSet = typeof md.featureSet === 'string' ? md.featureSet : '';
2469
+ return featureSet ? `${serverId}/${featureSet}` : serverId;
2470
+ }
2471
+ return source;
2472
+ }
2473
+
2474
+ /** MCPL channel-incoming carries `author: { id, name }` in metadata; push
2475
+ * events sometimes do via origin spread. Best-effort extraction. */
2476
+ function extractAuthorName(md: Record<string, unknown>): string | undefined {
2477
+ const author = md.author;
2478
+ if (author && typeof author === 'object' && 'name' in author) {
2479
+ const name = (author as { name?: unknown }).name;
2480
+ if (typeof name === 'string') return name;
2481
+ }
2482
+ return undefined;
2483
+ }
2484
+
2485
+ /** Pull a flat-text excerpt out of a content-block array. Mirrors what
2486
+ * flattenMessage does for assistant turns, scoped down to a single string. */
2487
+ function extractText(content: ReadonlyArray<unknown>): string {
2488
+ const parts: string[] = [];
2489
+ for (const block of content) {
2490
+ const b = block as { type?: unknown; text?: unknown };
2491
+ if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
2492
+ }
2493
+ return parts.join('\n');
2494
+ }
2495
+
2496
+ /** Parse a `usage:updated` `totals` payload into the wire `TokenUsage` shape.
2497
+ * Returns null if the input doesn't look like a SessionUsage object — the
2498
+ * caller leaves cached state untouched in that case, which is safer than
2499
+ * zeroing on every malformed frame. */
2500
+ function parseUsageTotals(raw: unknown): TokenUsage | null {
2501
+ if (!raw || typeof raw !== 'object') return null;
2502
+ const t = raw as {
2503
+ inputTokens?: unknown;
2504
+ outputTokens?: unknown;
2505
+ cacheReadTokens?: unknown;
2506
+ cacheCreationTokens?: unknown;
2507
+ estimatedCost?: unknown;
2508
+ };
2509
+ const usage: TokenUsage = {
2510
+ input: typeof t.inputTokens === 'number' ? t.inputTokens : 0,
2511
+ output: typeof t.outputTokens === 'number' ? t.outputTokens : 0,
2512
+ cacheRead: typeof t.cacheReadTokens === 'number' ? t.cacheReadTokens : 0,
2513
+ cacheWrite: typeof t.cacheCreationTokens === 'number' ? t.cacheCreationTokens : 0,
2514
+ };
2515
+ const cost = t.estimatedCost as { total?: unknown; currency?: unknown } | undefined;
2516
+ if (cost && typeof cost.total === 'number' && typeof cost.currency === 'string') {
2517
+ usage.cost = { total: cost.total, currency: cost.currency };
2518
+ }
2519
+ return usage;
2520
+ }
2521
+
2522
+ /** Combine parent + per-child session totals into the single number shown in
2523
+ * the header. Cost folds when every contributor reports the same currency;
2524
+ * mismatched currencies drop cost entirely (better than silently summing
2525
+ * USD + EUR). */
2526
+ function aggregateFleetUsage(ss: SharedServerState): TokenUsage {
2527
+ const out: TokenUsage = {
2528
+ input: ss.parentUsage.input,
2529
+ output: ss.parentUsage.output,
2530
+ cacheRead: ss.parentUsage.cacheRead,
2531
+ cacheWrite: ss.parentUsage.cacheWrite,
2532
+ };
2533
+ let costTotal = 0;
2534
+ let costCurrency: string | null = null;
2535
+ let costAbandoned = false;
2536
+ const accumulateCost = (c: { total: number; currency: string } | undefined): void => {
2537
+ if (costAbandoned) return;
2538
+ if (!c) return;
2539
+ if (costCurrency === null) { costCurrency = c.currency; costTotal = c.total; return; }
2540
+ if (costCurrency !== c.currency) { costAbandoned = true; return; }
2541
+ costTotal += c.total;
2542
+ };
2543
+ accumulateCost(ss.parentUsage.cost);
2544
+ for (const u of ss.childUsage.values()) {
2545
+ out.input += u.input;
2546
+ out.output += u.output;
2547
+ out.cacheRead += u.cacheRead;
2548
+ out.cacheWrite += u.cacheWrite;
2549
+ accumulateCost(u.cost);
2550
+ }
2551
+ if (!costAbandoned && costCurrency !== null) {
2552
+ out.cost = { total: costTotal, currency: costCurrency };
2553
+ }
2554
+ return out;
2555
+ }
2556
+
2557
+ function mimeFor(path: string): string {
2558
+ if (path.endsWith('.html')) return 'text/html; charset=utf-8';
2559
+ if (path.endsWith('.js')) return 'text/javascript; charset=utf-8';
2560
+ if (path.endsWith('.mjs')) return 'text/javascript; charset=utf-8';
2561
+ if (path.endsWith('.css')) return 'text/css; charset=utf-8';
2562
+ if (path.endsWith('.json')) return 'application/json; charset=utf-8';
2563
+ if (path.endsWith('.svg')) return 'image/svg+xml';
2564
+ if (path.endsWith('.png')) return 'image/png';
2565
+ if (path.endsWith('.ico')) return 'image/x-icon';
2566
+ if (path.endsWith('.woff2')) return 'font/woff2';
2567
+ if (path.endsWith('.woff')) return 'font/woff';
2568
+ return 'application/octet-stream';
2569
+ }
2570
+
2571
+ // ---------------------------------------------------------------------------
2572
+ // Test helpers
2573
+ // ---------------------------------------------------------------------------
2574
+ //
2575
+ // The HTTP server lives at module scope (process-level singleton). Tests need
2576
+ // to read the actual bound port when they pass `port: 0`, and they need to
2577
+ // shut the server down between files even though normal lifecycle keeps it
2578
+ // running across session switches. These helpers exist solely for tests; they
2579
+ // are not part of the public module API.
2580
+
2581
+ /** Return the bound listener port, or null if the singleton hasn't started. */
2582
+ export function __getSharedServerPortForTests(): number | null {
2583
+ return sharedServer?.port ?? null;
2584
+ }
2585
+
2586
+ /** Forcibly tear down the shared HTTP server and clear the singleton, so a
2587
+ * subsequent `start()` boots a fresh one. Tests only. */
2588
+ export async function __resetSharedServerForTests(): Promise<void> {
2589
+ if (!sharedServer) return;
2590
+ try { sharedServer.server.stop(true); } catch { /* ignore */ }
2591
+ // Detach any fleet listener / aggregator so the next start runs clean.
2592
+ sharedServer.fleetEventDetacher?.();
2593
+ sharedServer.treeAggregator?.dispose();
2594
+ sharedServer = null;
2595
+ }