@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,6 @@
1
+ // Shim for bun:ffi types — OpenTUI's .d.ts files import from "bun:ffi"
2
+ // but we run type-checking with tsc which doesn't know Bun builtins.
3
+ declare module "bun:ffi" {
4
+ type Pointer = number;
5
+ type FFIType = unknown;
6
+ }
@@ -0,0 +1,648 @@
1
+ /**
2
+ * WebUI wire protocol — JSON-over-WebSocket envelope for the web admin client.
3
+ *
4
+ * Both ends import these types so the messages stay in lockstep. The shape
5
+ * deliberately mirrors the fleet IPC (`fleet-types.ts`) so a future external
6
+ * aggregator can reuse the same parsers.
7
+ *
8
+ * Versioning: v1. Breaking changes bump `WEB_PROTOCOL_VERSION` and clients
9
+ * refuse to connect on mismatch.
10
+ *
11
+ * v1 (2026-07): windowed history (welcome carries only a tail window plus
12
+ * `history` metadata; older pages via request-history/history-page), ordered
13
+ * `blocks` on message entries (thinking / tool_use / tool_result / media
14
+ * surface in full), and `message-appended` live pushes of stored messages.
15
+ */
16
+
17
+ import type { Line } from '../commands.js';
18
+
19
+ export const WEB_PROTOCOL_VERSION = 1;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Server → Client
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Cold-start payload sent immediately after a client connects. Contains
27
+ * everything needed to render a usable view without any further roundtrips.
28
+ * Subsequent live updates arrive as `trace` / `child-event` / etc.
29
+ */
30
+ export interface WelcomeMessage {
31
+ type: 'welcome';
32
+ protocolVersion: number;
33
+ recipe: {
34
+ name: string;
35
+ description?: string;
36
+ version?: string;
37
+ };
38
+ agents: Array<{ name: string; model: string }>;
39
+ session: {
40
+ id: string;
41
+ name: string;
42
+ autoNamed: boolean;
43
+ };
44
+ branch: {
45
+ id: string;
46
+ name: string;
47
+ };
48
+ /** Conversation history snapshot — the TAIL WINDOW only (see `history`).
49
+ * Clients fold live `trace` / `message-appended` events on top and page
50
+ * older messages via `request-history`. */
51
+ messages: WelcomeMessageEntry[];
52
+ /** Paging metadata for `messages`. `startIndex` is the store slot index of
53
+ * the first entry (== the number of older messages not shipped);
54
+ * `totalCount` is the store size at snapshot time. */
55
+ history: {
56
+ startIndex: number;
57
+ totalCount: number;
58
+ };
59
+ /**
60
+ * Parent-local agent tree snapshot. Shape matches AgentTreeSnapshot from
61
+ * `state/agent-tree-reducer.ts` — kept structurally typed here to avoid
62
+ * a cross-package import cycle and to let the client embed the same
63
+ * reducer with no transitive dependency on framework internals.
64
+ */
65
+ localTree: {
66
+ asOfTs: number;
67
+ nodes: Array<Record<string, unknown>>;
68
+ callIdIndex: Record<string, string>;
69
+ };
70
+ /**
71
+ * Per-child snapshots when FleetModule + FleetTreeAggregator are mounted.
72
+ * Each entry is a child name plus its current AgentTreeSnapshot. Empty
73
+ * array when no fleet is configured.
74
+ */
75
+ childTrees: Array<{
76
+ name: string;
77
+ asOfTs: number;
78
+ nodes: Array<Record<string, unknown>>;
79
+ callIdIndex: Record<string, string>;
80
+ /** Recipe summary for the child, parsed from its recipe file at fleet
81
+ * registration. Undefined when the recipe couldn't be loaded (e.g. URL
82
+ * recipe not yet fetched, or read error); SPA falls back to showing
83
+ * the child name only. */
84
+ recipe?: {
85
+ name: string;
86
+ description?: string;
87
+ version?: string;
88
+ agentModel?: string;
89
+ };
90
+ }>;
91
+ /** Cumulative session-wide token usage at connect time. */
92
+ usage: TokenUsage;
93
+ /** Per-agent cost breakdown for the parent process, present when the
94
+ * framework's usage tracker has data. Empty during cold start. */
95
+ perAgentCost?: PerAgentCost[];
96
+ }
97
+
98
+ /**
99
+ * One content block of a stored message, in original order. This is the
100
+ * viewer-facing projection of the membrane ContentBlock union: full
101
+ * interiority (thinking, tool calls AND their results) survives, while
102
+ * payloads are size-capped server-side (`truncated` flags) and media is
103
+ * reduced to a type placeholder.
104
+ */
105
+ export type MessageBlock =
106
+ | { kind: 'text'; text: string; truncated?: boolean }
107
+ | { kind: 'thinking'; text: string; truncated?: boolean }
108
+ /** Provider-encrypted reasoning; only the payload size is surfaced. */
109
+ | { kind: 'redacted_thinking'; bytes: number }
110
+ | { kind: 'tool_use'; id: string; name: string; inputJson: string; truncated?: boolean }
111
+ | { kind: 'tool_result'; toolUseId: string; text: string; isError?: boolean; truncated?: boolean }
112
+ /** Image/document/audio/video or un-inflated blob_ref placeholder. */
113
+ | { kind: 'media'; mediaType: string };
114
+
115
+ export interface WelcomeMessageEntry {
116
+ /** Stable id from the message store; clients can use this for keys. */
117
+ id?: string;
118
+ /** Store slot index — the paging cursor. For a coalesced shard run this is
119
+ * the first shard's index. */
120
+ index: number;
121
+ participant: 'user' | 'assistant' | 'system' | 'tool';
122
+ /** Ordered content blocks — the full internal life of the message. */
123
+ blocks: MessageBlock[];
124
+ /** Flattened text content (text blocks only), kept as a convenience and
125
+ * for degraded rendering by stale clients. */
126
+ text: string;
127
+ /** Epoch-millis timestamp if available. */
128
+ timestamp?: number;
129
+ }
130
+
131
+ /** Verbatim framework TraceEvent. Clients typically pipe these into the
132
+ * same `AgentTreeReducer` the server uses. */
133
+ export interface TraceMessage {
134
+ type: 'trace';
135
+ /** TraceEvent shape — discriminated by inner `type` field. */
136
+ event: { type: string; [k: string]: unknown };
137
+ }
138
+
139
+ /** Per-child WireEvent passthrough. Scoped by child name so the client
140
+ * can route into the right reducer. */
141
+ export interface ChildEventMessage {
142
+ type: 'child-event';
143
+ childName: string;
144
+ event: { type: string; [k: string]: unknown };
145
+ }
146
+
147
+ /** Output from a slash command — mirrors CommandResult from commands.ts. */
148
+ export interface CommandResultMessage {
149
+ type: 'command-result';
150
+ /** Echo of the corrId from the originating client `command` message. */
151
+ corrId?: string;
152
+ lines: Line[];
153
+ quit?: boolean;
154
+ branchChanged?: boolean;
155
+ switchToSessionId?: string;
156
+ /** True when an asyncWork follow-up is incoming as a separate command-result. */
157
+ pending?: boolean;
158
+ }
159
+
160
+ /** Periodic token-usage update; mirrors TUI's right-side meter. */
161
+ export interface UsageMessage {
162
+ type: 'usage';
163
+ usage: TokenUsage;
164
+ perAgentCost?: PerAgentCost[];
165
+ }
166
+
167
+ export interface TokenUsage {
168
+ input: number;
169
+ output: number;
170
+ cacheRead: number;
171
+ cacheWrite: number;
172
+ /** Estimated cost for this scope. Currency is provider-derived (USD for
173
+ * Anthropic). Optional because not every adapter / cached usage frame
174
+ * carries pricing data. */
175
+ cost?: { total: number; currency: string };
176
+ }
177
+
178
+ /** Per-agent cost slice used by the WebUI usage panel to label rows. Only
179
+ * surfaces parent-process agents — fleet-child agents track their own
180
+ * usage in their own UsageTracker and aren't aggregated cross-process. */
181
+ export interface PerAgentCost {
182
+ name: string;
183
+ cost: { total: number; currency: string };
184
+ inferenceCount: number;
185
+ }
186
+
187
+ /** Sent when the active branch changes (undo/redo/checkout). Clients should
188
+ * re-fetch their conversation by reconnecting or treating subsequent traces
189
+ * as authoritative. */
190
+ export interface BranchChangedMessage {
191
+ type: 'branch-changed';
192
+ branch: { id: string; name: string };
193
+ }
194
+
195
+ /** Sent when the active session changes. Clients should soft-reconnect. */
196
+ export interface SessionChangedMessage {
197
+ type: 'session-changed';
198
+ session: { id: string; name: string };
199
+ }
200
+
201
+ /** Live peek stream for one subagent (Phase 5). Multiplexed by `scope`. */
202
+ export interface PeekMessage {
203
+ type: 'peek';
204
+ scope: string;
205
+ event: { type: string; [k: string]: unknown };
206
+ }
207
+
208
+ /** Lesson library snapshot — response to RequestLessonsMessage. Empty array
209
+ * when LessonsModule isn't loaded, with `loaded: false` so the SPA can
210
+ * surface a "module not loaded" hint rather than appearing broken. */
211
+ export interface LessonsListMessage {
212
+ type: 'lessons-list';
213
+ loaded: boolean;
214
+ lessons: Array<{
215
+ id: string;
216
+ content: string;
217
+ confidence: number;
218
+ tags: string[];
219
+ deprecated: boolean;
220
+ deprecationReason?: string;
221
+ created?: number;
222
+ updated?: number;
223
+ }>;
224
+ }
225
+
226
+ /** Workspace mount summary — response to request-workspace-mounts. */
227
+ export interface WorkspaceMountsMessage {
228
+ type: 'workspace-mounts';
229
+ /** True iff WorkspaceModule is loaded in the recipe. */
230
+ loaded: boolean;
231
+ mounts: Array<{
232
+ name: string;
233
+ /** Filesystem path the mount maps to. */
234
+ path: string;
235
+ /** 'read-only' | 'read-write' (or any other future mode). */
236
+ mode: string;
237
+ }>;
238
+ }
239
+
240
+ /** Recursive file listing for one mount — response to request-workspace-tree. */
241
+ export interface WorkspaceTreeMessage {
242
+ type: 'workspace-tree';
243
+ mount: string;
244
+ entries: Array<{ path: string; size: number }>;
245
+ }
246
+
247
+ /** File content — response to request-workspace-file. Content is
248
+ * line-numbered (cat -n style) as returned by the workspace `read` tool. */
249
+ export interface WorkspaceFileMessage {
250
+ type: 'workspace-file';
251
+ path: string;
252
+ totalLines: number;
253
+ fromLine: number;
254
+ toLine: number;
255
+ content: string;
256
+ /** True if the response was capped at the line limit; the SPA can warn
257
+ * that the file is larger than what's shown. */
258
+ truncated: boolean;
259
+ }
260
+
261
+ /** MCPL server config snapshot — response to request-mcpl, mcpl-add, etc.
262
+ * Sent only to the requesting client; mutations don't broadcast since
263
+ * changes are file-only and require restart anyway. */
264
+ export interface McplListMessage {
265
+ type: 'mcpl-list';
266
+ /** Path to the config file (informational — operator may want to grep
267
+ * for it locally). */
268
+ configPath: string;
269
+ servers: Array<{
270
+ id: string;
271
+ command: string;
272
+ args?: string[];
273
+ env?: Record<string, string>;
274
+ toolPrefix?: string;
275
+ reconnect?: boolean;
276
+ enabledFeatureSets?: string[];
277
+ disabledFeatureSets?: string[];
278
+ }>;
279
+ }
280
+
281
+ /** A page of older history — response to `request-history`, routed only to
282
+ * the requesting client and correlated by `corrId`. */
283
+ export interface HistoryPageMessage {
284
+ type: 'history-page';
285
+ corrId: string;
286
+ /** Entries in store order (oldest first), ending just before the
287
+ * requested `beforeIndex`. */
288
+ entries: WelcomeMessageEntry[];
289
+ /** Slot index of the first entry — the next page's `beforeIndex`. */
290
+ startIndex: number;
291
+ /** Store size at read time. */
292
+ totalCount: number;
293
+ }
294
+
295
+ /** A message was appended to the store (any participant — including
296
+ * assistant turns and tool results, which never surface as trace
297
+ * `message:added` events). Clients dedupe by `entry.id` against messages
298
+ * they already rendered optimistically or via streaming. */
299
+ export interface MessageAppendedMessage {
300
+ type: 'message-appended';
301
+ entry: WelcomeMessageEntry;
302
+ }
303
+
304
+ /** Server-side error response. Non-fatal; the client stays connected. */
305
+ export interface ErrorMessage {
306
+ type: 'error';
307
+ /** corrId echo if the error was triggered by a specific request. */
308
+ corrId?: string;
309
+ message: string;
310
+ }
311
+
312
+ /**
313
+ * /quit was issued while one or more fleet children are still running.
314
+ * The server holds the shutdown until the operator confirms what to do
315
+ * with them. Mirrors the TUI's three-way prompt — kill/cancel/detach.
316
+ */
317
+ export interface QuitConfirmRequiredMessage {
318
+ type: 'quit-confirm-required';
319
+ /** Names of fleet children currently in 'ready' or 'starting' state. */
320
+ children: string[];
321
+ }
322
+
323
+ /**
324
+ * An external message just landed in the agent's context — e.g. an MCPL push
325
+ * (zulip notification, etc.) or channel incoming. The TUI quietly switches to
326
+ * "thinking" when this happens; the WebUI surfaces it as a labeled box so the
327
+ * operator can see *why* the agent is suddenly active.
328
+ *
329
+ * Not emitted for inputs typed in the WebUI itself (those are already
330
+ * optimistically rendered as a user message).
331
+ */
332
+ export interface InboundTriggerMessage {
333
+ type: 'inbound-trigger';
334
+ /** Trace-event source field — e.g. 'mcpl:channel-incoming', 'mcpl:push-event'. */
335
+ source: string;
336
+ /** Human-readable origin label like 'zulip#general' or 'discord/myserver'. */
337
+ origin: string;
338
+ /** Did this message wake the agent up? When false the message landed but
339
+ * the gate filtered it out (no inference triggered). */
340
+ triggered: boolean;
341
+ /** Author display name where applicable. */
342
+ author?: string;
343
+ /** Brief text excerpt — capped server-side to keep the wire frame small. */
344
+ text: string;
345
+ /** Server time when the message was added. */
346
+ timestamp: number;
347
+ }
348
+
349
+ export type WebUiServerMessage =
350
+ | WelcomeMessage
351
+ | TraceMessage
352
+ | ChildEventMessage
353
+ | CommandResultMessage
354
+ | UsageMessage
355
+ | BranchChangedMessage
356
+ | SessionChangedMessage
357
+ | PeekMessage
358
+ | InboundTriggerMessage
359
+ | QuitConfirmRequiredMessage
360
+ | LessonsListMessage
361
+ | McplListMessage
362
+ | WorkspaceMountsMessage
363
+ | WorkspaceTreeMessage
364
+ | WorkspaceFileMessage
365
+ | HistoryPageMessage
366
+ | MessageAppendedMessage
367
+ | ErrorMessage;
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Client → Server
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /** Plain user message; equivalent to typing in the TUI. Triggers inference. */
374
+ export interface UserMessageMessage {
375
+ type: 'user-message';
376
+ content: string;
377
+ }
378
+
379
+ /** Slash command. Server runs `handleCommand()` and replies with a
380
+ * `command-result` carrying the same `corrId`. */
381
+ export interface CommandMessage {
382
+ type: 'command';
383
+ command: string;
384
+ corrId?: string;
385
+ }
386
+
387
+ /** @childname routing for fleet children; bypasses the conductor agent. */
388
+ export interface RouteToChildMessage {
389
+ type: 'route-to-child';
390
+ childName: string;
391
+ content: string;
392
+ }
393
+
394
+ /** Cancel any in-flight inference (Esc parity in the TUI). */
395
+ export interface InterruptMessage {
396
+ type: 'interrupt';
397
+ }
398
+
399
+ /** Cancel one specific in-process subagent by display name. If `childName`
400
+ * is set, the cancel is forwarded to that fleet child's headless runtime;
401
+ * otherwise the conductor's local SubagentModule is used. Required for
402
+ * subagents that live inside a fleet child (e.g. Clerk-spawned subagents) —
403
+ * the conductor typically has no SubagentModule loaded, so a non-routed
404
+ * cancel would hit "subagent module not loaded". */
405
+ export interface CancelSubagentMessage {
406
+ type: 'cancel-subagent';
407
+ name: string;
408
+ childName?: string;
409
+ }
410
+
411
+ /** Stop a fleet child gracefully. */
412
+ export interface FleetStopMessage {
413
+ type: 'fleet-stop';
414
+ name: string;
415
+ }
416
+
417
+ /** Restart a fleet child. */
418
+ export interface FleetRestartMessage {
419
+ type: 'fleet-restart';
420
+ name: string;
421
+ }
422
+
423
+ /** Open or close a peek window for a specific subagent or fleet child. */
424
+ export interface SubscribePeekMessage {
425
+ type: 'subscribe-peek';
426
+ scope: string;
427
+ /** True to open, false to close. */
428
+ active: boolean;
429
+ }
430
+
431
+ /** Keepalive. Server replies with nothing; the round-trip is the proof. */
432
+ export interface PingMessage {
433
+ type: 'ping';
434
+ }
435
+
436
+ /**
437
+ * Operator's response to a quit-confirm-required prompt. The action mirrors
438
+ * the TUI's [Y/n/d] choices: kill the children gracefully and exit, leave
439
+ * them running and exit anyway, or cancel quit altogether.
440
+ */
441
+ export interface QuitConfirmMessage {
442
+ type: 'quit-confirm';
443
+ action: 'kill-children' | 'detach' | 'cancel';
444
+ }
445
+
446
+ /** Pull a lesson library — defaults to the parent process; pass a fleet
447
+ * child name in `scope` to query that child instead. The response is a
448
+ * `lessons-list` envelope routed only to the requesting client (children
449
+ * don't broadcast). */
450
+ export interface RequestLessonsMessage {
451
+ type: 'request-lessons';
452
+ /** Fleet child name, or 'local'/undefined for the parent process. */
453
+ scope?: string;
454
+ }
455
+
456
+ /** Pull the configured MCPL servers from mcpl-servers.json. Response is an
457
+ * `mcpl-list` envelope. Recipe-defined servers are excluded — those live in
458
+ * the recipe file and aren't editable from here. */
459
+ export interface RequestMcplMessage {
460
+ type: 'request-mcpl';
461
+ }
462
+
463
+ /** Add or overwrite an MCPL server entry in mcpl-servers.json. Restart is
464
+ * required for the host to pick up the change; the response is a fresh
465
+ * `mcpl-list` so the SPA reflects the new state. */
466
+ export interface McplAddMessage {
467
+ type: 'mcpl-add';
468
+ id: string;
469
+ command: string;
470
+ args?: string[];
471
+ env?: Record<string, string>;
472
+ toolPrefix?: string;
473
+ }
474
+
475
+ export interface McplRemoveMessage {
476
+ type: 'mcpl-remove';
477
+ id: string;
478
+ }
479
+
480
+ export interface McplSetEnvMessage {
481
+ type: 'mcpl-set-env';
482
+ id: string;
483
+ /** Replaces the existing env block in full. Empty object clears it. */
484
+ env: Record<string, string>;
485
+ }
486
+
487
+ /** Pull the list of workspace mounts. Response is `workspace-mounts`.
488
+ * Optional `scope` selects a fleet child instead of the parent. */
489
+ export interface RequestWorkspaceMountsMessage {
490
+ type: 'request-workspace-mounts';
491
+ scope?: string;
492
+ }
493
+
494
+ /** Pull a recursive flat listing of files in one mount. Response is
495
+ * `workspace-tree`. The flat shape mirrors what WorkspaceModule's `ls`
496
+ * tool returns; the SPA folds it into a hierarchy locally. */
497
+ export interface RequestWorkspaceTreeMessage {
498
+ type: 'request-workspace-tree';
499
+ mount: string;
500
+ scope?: string;
501
+ }
502
+
503
+ /** Read a workspace file, capped to N lines so the wire frame stays small.
504
+ * Response is `workspace-file`. */
505
+ export interface RequestWorkspaceFileMessage {
506
+ type: 'request-workspace-file';
507
+ /** Mount-prefixed path (e.g. "tickets/2026-05-06-foo.md"). */
508
+ path: string;
509
+ scope?: string;
510
+ }
511
+
512
+ /** Page older conversation history. Response is a `history-page` with the
513
+ * same `corrId` (or an `error`). Entries returned end just before
514
+ * `beforeIndex`; pass the previous page's `startIndex` to keep walking
515
+ * backward. */
516
+ export interface RequestHistoryMessage {
517
+ type: 'request-history';
518
+ corrId: string;
519
+ /** Fetch messages strictly before this store slot index. */
520
+ beforeIndex: number;
521
+ /** Page size; server clamps to its maximum (500). */
522
+ limit?: number;
523
+ }
524
+
525
+ export type WebUiClientMessage =
526
+ | UserMessageMessage
527
+ | RequestHistoryMessage
528
+ | CommandMessage
529
+ | RouteToChildMessage
530
+ | InterruptMessage
531
+ | CancelSubagentMessage
532
+ | FleetStopMessage
533
+ | FleetRestartMessage
534
+ | SubscribePeekMessage
535
+ | QuitConfirmMessage
536
+ | RequestLessonsMessage
537
+ | RequestMcplMessage
538
+ | McplAddMessage
539
+ | McplRemoveMessage
540
+ | McplSetEnvMessage
541
+ | RequestWorkspaceMountsMessage
542
+ | RequestWorkspaceTreeMessage
543
+ | RequestWorkspaceFileMessage
544
+ | PingMessage;
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Helpers
548
+ // ---------------------------------------------------------------------------
549
+
550
+ /**
551
+ * Type guard for narrowing parsed JSON to a known client message. Per-variant
552
+ * payload validation lives here so handlers downstream can trust field
553
+ * shapes — without this, a malformed payload like `{type:'mcpl-add', id: 42,
554
+ * command: null}` would slip through and propagate into disk writes / file
555
+ * paths / spawn() args, where the eventual failure is far from the cause.
556
+ */
557
+ export function isClientMessage(value: unknown): value is WebUiClientMessage {
558
+ if (!value || typeof value !== 'object') return false;
559
+ const v = value as Record<string, unknown>;
560
+ if (typeof v.type !== 'string') return false;
561
+ switch (v.type) {
562
+ case 'ping':
563
+ case 'interrupt':
564
+ case 'request-mcpl':
565
+ return true;
566
+ case 'user-message':
567
+ return typeof v.content === 'string';
568
+ case 'command':
569
+ return typeof v.command === 'string'
570
+ && (v.corrId === undefined || typeof v.corrId === 'string');
571
+ case 'route-to-child':
572
+ return isNonEmptyString(v.childName) && typeof v.content === 'string';
573
+ case 'cancel-subagent':
574
+ return isNonEmptyString(v.name)
575
+ && (v.childName === undefined || isNonEmptyString(v.childName));
576
+ case 'fleet-stop':
577
+ case 'fleet-restart':
578
+ return isNonEmptyString(v.name);
579
+ case 'subscribe-peek':
580
+ return typeof v.scope === 'string' && typeof v.active === 'boolean';
581
+ case 'quit-confirm':
582
+ return v.action === 'kill-children' || v.action === 'detach' || v.action === 'cancel';
583
+ case 'request-lessons':
584
+ return v.scope === undefined || typeof v.scope === 'string';
585
+ case 'mcpl-add':
586
+ return isValidMcplId(v.id)
587
+ && isNonEmptyString(v.command)
588
+ && isOptionalStringArray(v.args)
589
+ && isOptionalStringMap(v.env)
590
+ && (v.toolPrefix === undefined || isNonEmptyString(v.toolPrefix));
591
+ case 'mcpl-remove':
592
+ return isValidMcplId(v.id);
593
+ case 'mcpl-set-env':
594
+ return isValidMcplId(v.id) && isStringMap(v.env);
595
+ case 'request-workspace-mounts':
596
+ return v.scope === undefined || typeof v.scope === 'string';
597
+ case 'request-workspace-tree':
598
+ return isNonEmptyString(v.mount)
599
+ && (v.scope === undefined || typeof v.scope === 'string');
600
+ case 'request-workspace-file':
601
+ return isNonEmptyString(v.path)
602
+ && (v.scope === undefined || typeof v.scope === 'string');
603
+ case 'request-history':
604
+ return isNonEmptyString(v.corrId)
605
+ && Number.isInteger(v.beforeIndex) && (v.beforeIndex as number) >= 0
606
+ && (v.limit === undefined
607
+ || (Number.isInteger(v.limit) && (v.limit as number) >= 1 && (v.limit as number) <= 500));
608
+ default:
609
+ return false;
610
+ }
611
+ }
612
+
613
+ function isNonEmptyString(v: unknown): v is string {
614
+ return typeof v === 'string' && v.length > 0;
615
+ }
616
+
617
+ /** MCPL config keys end up as filesystem-adjacent identifiers (the spec
618
+ * treats them as opaque strings, but they're surfaced in error paths and
619
+ * may flow into other tooling). Reject path separators, control bytes, and
620
+ * anything that looks like it would escape JSON-key territory. */
621
+ function isValidMcplId(v: unknown): v is string {
622
+ if (typeof v !== 'string' || v.length === 0 || v.length > 128) return false;
623
+ if (v.includes('/') || v.includes('\\') || v.includes('\0')) return false;
624
+ // Control chars are a robustness hole — reject 0x00-0x1f and 0x7f.
625
+ for (let i = 0; i < v.length; i++) {
626
+ const c = v.charCodeAt(i);
627
+ if (c < 0x20 || c === 0x7f) return false;
628
+ }
629
+ return true;
630
+ }
631
+
632
+ function isStringMap(v: unknown): v is Record<string, string> {
633
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
634
+ for (const val of Object.values(v as Record<string, unknown>)) {
635
+ if (typeof val !== 'string') return false;
636
+ }
637
+ return true;
638
+ }
639
+
640
+ function isOptionalStringMap(v: unknown): v is Record<string, string> | undefined {
641
+ return v === undefined || isStringMap(v);
642
+ }
643
+
644
+ function isOptionalStringArray(v: unknown): v is string[] | undefined {
645
+ if (v === undefined) return true;
646
+ if (!Array.isArray(v)) return false;
647
+ return v.every((x) => typeof x === 'string');
648
+ }