@animalabs/connectome-host 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.env.example +7 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
  3. package/.github/workflows/changelog.yml +32 -0
  4. package/.github/workflows/publish.yml +46 -0
  5. package/CHANGELOG.md +140 -0
  6. package/CONTRIBUTING.md +126 -0
  7. package/HEADLESS-FLEET-PLAN.md +1 -1
  8. package/README.md +52 -5
  9. package/UNIFIED-TREE-PLAN.md +2 -0
  10. package/docs/AGENT-ONBOARDING.md +9 -8
  11. package/docs/DEPLOYMENTS.md +2 -2
  12. package/docs/DEV-ENVIRONMENT.md +28 -26
  13. package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
  14. package/package.json +5 -4
  15. package/scripts/release-changelog.ts +32 -0
  16. package/src/codex-subscription-adapter.ts +938 -0
  17. package/src/commands.ts +95 -4
  18. package/src/extensions.ts +201 -0
  19. package/src/framework-agent-config.ts +20 -9
  20. package/src/framework-strategy.ts +24 -0
  21. package/src/index.ts +111 -19
  22. package/src/logging-bedrock-adapter.ts +145 -0
  23. package/src/modules/channel-mode-module.ts +9 -6
  24. package/src/modules/subscription-gc-module.ts +163 -34
  25. package/src/modules/tts-relay-module.ts +481 -0
  26. package/src/modules/web-ui-module.ts +45 -1
  27. package/src/recipe.ts +196 -10
  28. package/src/tui.ts +527 -137
  29. package/src/web/protocol.ts +35 -0
  30. package/test/codex-subscription-adapter.test.ts +226 -0
  31. package/test/commands-checkpoint-restore.test.ts +118 -0
  32. package/test/fast-command.test.ts +39 -0
  33. package/test/recipe-extensions.test.ts +295 -0
  34. package/test/recipe-provider.test.ts +14 -1
  35. package/test/subscription-gc-module.test.ts +156 -1
  36. package/test/tui-quit-confirm.test.ts +42 -0
  37. package/test/tui-short-agent-name.test.ts +36 -0
  38. package/test/web-ui-observers.test.ts +14 -0
  39. package/test/web-ui-protocol.test.ts +0 -0
  40. package/web/src/App.tsx +235 -20
  41. package/web/src/Branches.tsx +150 -0
  42. package/web/src/Context.tsx +21 -13
  43. package/web/src/Health.tsx +274 -0
  44. package/web/src/Lessons.tsx +2 -1
@@ -0,0 +1,481 @@
1
+ /**
2
+ * TtsRelayModule — streams the agent's live generation to a melodeus-tts-relay
3
+ * server so voice clients (Melodeus, the iOS app) can speak it as it's born.
4
+ *
5
+ * Pure trace-bus tap (docs/observability.md spirit: traces are the outbound
6
+ * wire, they never drive agent logic). The module subscribes to the framework's
7
+ * existing streaming traces and re-emits the relay's bot-side protocol
8
+ * (melodeus-tts-relay/PROTOCOL.md) over one outbound WebSocket:
9
+ *
10
+ * inference:started → activation_start
11
+ * inference:tokens → chunk (visible = blockType 'text')
12
+ * inference:content_block → block_start / block_complete
13
+ * inference:completed → activation_end reason 'complete'
14
+ * inference:aborted → activation_end reason 'abort'
15
+ * inference:failed / :exhausted → activation_end reason 'error'
16
+ *
17
+ * Channel tagging rides the turn-frozen locus the framework already stamps on
18
+ * those traces (`channelId`, the MCPL composite id). The relay speaks raw
19
+ * Discord snowflakes, so the composite's last segment is what goes on the wire.
20
+ * `block_complete.content` is reconstructed here by accumulating that block's
21
+ * chunks — the trace deliberately doesn't duplicate block content.
22
+ *
23
+ * Interruptions (the one inbound message): a voice client reports the words
24
+ * actually voiced before the user spoke over them. Unlike ChapterX — which
25
+ * aborts the in-flight stream and substitutes spokenText as the completion —
26
+ * connectome posts prose at tool-round boundaries, so the message is usually
27
+ * already on Discord. We therefore EDIT it down to the voiced words (matching
28
+ * the interruption against recent `mcpl:speech-routed` traces, which carry
29
+ * channelId + messageId + text) via the discord MCPL's edit_message tool, and
30
+ * drop a note into the agent's context so she knows she was cut off — the
31
+ * next turn shouldn't believe the full paragraph landed.
32
+ *
33
+ * Nothing here touches the host loop: remove the recipe block and the tap is
34
+ * gone. No tools are exposed to the agent (v1).
35
+ */
36
+
37
+ import type {
38
+ Module,
39
+ ModuleContext,
40
+ ProcessEvent,
41
+ ProcessState,
42
+ EventResponse,
43
+ ToolDefinition,
44
+ ToolCall,
45
+ ToolResult,
46
+ TraceEvent,
47
+ } from '@animalabs/agent-framework';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Config
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export interface TtsRelayModuleConfig {
54
+ /** Relay bot endpoint, e.g. "ws://localhost:8800/bot". */
55
+ url: string;
56
+ /** Shared secret for the relay's /bot auth (BOT_TOKENS). `${VAR}` it. */
57
+ token: string;
58
+ /** Relay-side bot identifier. Defaults to the agent's name at first trace. */
59
+ botId?: string;
60
+ /** Discord user id to stamp on stream events (voice-config mapping key on
61
+ * the client side). Defaults to botId. */
62
+ userId?: string;
63
+ /** Display name stamped on stream events. Defaults to the agent's name. */
64
+ username?: string;
65
+ /** MCPL server id owning edit_message for interruption edits. Default 'discord'. */
66
+ editServerId?: string;
67
+ reconnectIntervalMs?: number;
68
+ /** Drop a context note when an interruption truncates a posted message.
69
+ * Default true. */
70
+ notifyOnInterruption?: boolean;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Relay wire types (bot side of melodeus-tts-relay/PROTOCOL.md)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ type BlockType = 'text' | 'thinking' | 'tool_call' | 'tool_result';
78
+
79
+ interface InterruptionEvent {
80
+ channelId: string; // raw Discord snowflake
81
+ spokenText: string;
82
+ reason: 'user_speech' | 'manual' | 'timeout';
83
+ timestamp: number;
84
+ }
85
+
86
+ /**
87
+ * Minimal WebSocket client for the relay's /bot endpoint. Ported from
88
+ * chatperx/src/tts/relay-client.ts, WHATWG-ified (Bun's global WebSocket —
89
+ * no `ws` dependency). Auto-reconnects except after an auth failure; every
90
+ * send is fire-and-forget and drops silently while disconnected (the relay
91
+ * spec's own semantics: no queueing).
92
+ */
93
+ class RelayBotClient {
94
+ private ws: WebSocket | null = null;
95
+ private authenticated = false;
96
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
97
+ private shouldReconnect = true;
98
+ private onInterruptionHandler: ((event: InterruptionEvent) => void) | null = null;
99
+
100
+ constructor(
101
+ private readonly url: string,
102
+ private readonly token: string,
103
+ private readonly botId: string,
104
+ private readonly reconnectIntervalMs: number,
105
+ ) {}
106
+
107
+ connect(): void {
108
+ this.shouldReconnect = true;
109
+ this.doConnect();
110
+ }
111
+
112
+ disconnect(): void {
113
+ this.shouldReconnect = false;
114
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
115
+ this.authenticated = false;
116
+ if (this.ws) {
117
+ try { this.ws.close(1000, 'module stopping'); } catch { /* already closed */ }
118
+ this.ws = null;
119
+ }
120
+ }
121
+
122
+ isConnected(): boolean {
123
+ return this.ws?.readyState === WebSocket.OPEN && this.authenticated;
124
+ }
125
+
126
+ onInterruption(handler: (event: InterruptionEvent) => void): void {
127
+ this.onInterruptionHandler = handler;
128
+ }
129
+
130
+ /** Send a typed relay message; botId + timestamp are stamped here. */
131
+ emit(type: string, payload: Record<string, unknown>): void {
132
+ if (!this.isConnected()) return;
133
+ try {
134
+ this.ws!.send(JSON.stringify({ type, botId: this.botId, ...payload, timestamp: Date.now() }));
135
+ } catch (err) {
136
+ console.error('[tts-relay] send failed:', err);
137
+ }
138
+ }
139
+
140
+ private doConnect(): void {
141
+ let ws: WebSocket;
142
+ try {
143
+ ws = new WebSocket(this.url);
144
+ } catch (err) {
145
+ console.error(`[tts-relay] cannot open ${this.url}:`, err);
146
+ this.scheduleReconnect();
147
+ return;
148
+ }
149
+ this.ws = ws;
150
+
151
+ ws.onopen = () => {
152
+ try { ws.send(JSON.stringify({ type: 'auth', botId: this.botId, token: this.token })); }
153
+ catch (err) { console.error('[tts-relay] auth send failed:', err); }
154
+ };
155
+
156
+ ws.onmessage = (ev: MessageEvent) => {
157
+ let msg: { type?: string; error?: string } & Record<string, unknown>;
158
+ try { msg = JSON.parse(String(ev.data)); } catch { return; }
159
+ switch (msg.type) {
160
+ case 'auth_ok':
161
+ this.authenticated = true;
162
+ console.error(`[tts-relay] authenticated with ${this.url} as ${this.botId}`);
163
+ break;
164
+ case 'auth_error':
165
+ // Bad token won't heal by retrying — stop, loudly.
166
+ console.error(`[tts-relay] AUTH FAILED (${msg.error}) — relay tap disabled until restart`);
167
+ this.shouldReconnect = false;
168
+ try { ws.close(); } catch { /* noop */ }
169
+ break;
170
+ case 'interruption':
171
+ this.onInterruptionHandler?.({
172
+ channelId: String(msg.channelId ?? ''),
173
+ spokenText: String(msg.spokenText ?? ''),
174
+ reason: (msg.reason as InterruptionEvent['reason']) ?? 'manual',
175
+ timestamp: Number(msg.timestamp ?? Date.now()),
176
+ });
177
+ break;
178
+ default:
179
+ break; // unknown relay message — ignore
180
+ }
181
+ };
182
+
183
+ ws.onclose = () => {
184
+ this.authenticated = false;
185
+ this.scheduleReconnect();
186
+ };
187
+
188
+ ws.onerror = () => {
189
+ // onclose follows and owns the reconnect; nothing to do here.
190
+ };
191
+ }
192
+
193
+ private scheduleReconnect(): void {
194
+ if (!this.shouldReconnect || this.reconnectTimer) return;
195
+ this.reconnectTimer = setTimeout(() => {
196
+ this.reconnectTimer = null;
197
+ if (this.shouldReconnect) this.doConnect();
198
+ }, this.reconnectIntervalMs);
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Module
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** Per-agent state for the activation currently streaming. */
207
+ interface ActivationState {
208
+ /** MCPL composite channel id from the traces (e.g. discord:guild:123). */
209
+ mcplChannelId?: string;
210
+ /** Raw surface id sent to the relay (last composite segment). */
211
+ rawChannelId?: string;
212
+ /** Whether activation_start has been emitted for this turn. */
213
+ announced: boolean;
214
+ /** Accumulated text per blockIndex, for block_complete.content. */
215
+ blocks: Map<number, string>;
216
+ }
217
+
218
+ /** A recently routed prose segment — the edit target for an interruption. */
219
+ interface RoutedMessage {
220
+ rawChannelId: string;
221
+ mcplChannelId: string;
222
+ messageId?: string;
223
+ text: string;
224
+ at: number;
225
+ }
226
+
227
+ const ROUTED_RING_MAX = 32;
228
+
229
+ /** `discord:<guild>:<id>` / `portal:<id>` / raw → the raw surface id. */
230
+ function rawChannelId(mcplId: string): string {
231
+ const ix = mcplId.lastIndexOf(':');
232
+ return ix === -1 ? mcplId : mcplId.slice(ix + 1);
233
+ }
234
+
235
+ const collapseWs = (s: string): string => s.replace(/\s+/g, ' ').trim();
236
+
237
+ export class TtsRelayModule implements Module {
238
+ readonly name = 'tts-relay';
239
+
240
+ private ctx: ModuleContext | null = null;
241
+ private client: RelayBotClient | null = null;
242
+ private offTrace: (() => void) | null = null;
243
+
244
+ private activations = new Map<string, ActivationState>();
245
+ private routed: RoutedMessage[] = [];
246
+ /** Resolved lazily from the first trace when config omits botId/username. */
247
+ private agentName: string | null = null;
248
+
249
+ constructor(private readonly config: TtsRelayModuleConfig) {}
250
+
251
+ async start(ctx: ModuleContext): Promise<void> {
252
+ this.ctx = ctx;
253
+
254
+ const botId = this.config.botId ?? ctx.getAgents()[0]?.name ?? 'connectome';
255
+ this.client = new RelayBotClient(
256
+ this.config.url,
257
+ this.config.token,
258
+ botId,
259
+ this.config.reconnectIntervalMs ?? 5000,
260
+ );
261
+ this.client.onInterruption((ev) => {
262
+ void this.handleInterruption(ev).catch((err) =>
263
+ console.error('[tts-relay] interruption handling failed:', err));
264
+ });
265
+ this.client.connect();
266
+
267
+ this.offTrace = ctx.onTrace((event) => {
268
+ // A trace listener must never throw into the framework's emit path.
269
+ try { this.onTraceEvent(event); }
270
+ catch (err) { console.error('[tts-relay] trace handler error:', err); }
271
+ });
272
+ }
273
+
274
+ async stop(): Promise<void> {
275
+ this.offTrace?.();
276
+ this.offTrace = null;
277
+ this.client?.disconnect();
278
+ this.client = null;
279
+ this.activations.clear();
280
+ this.routed = [];
281
+ this.ctx = null;
282
+ }
283
+
284
+ getTools(): ToolDefinition[] { return []; }
285
+
286
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
287
+ return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
288
+ }
289
+
290
+ async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
291
+ return {};
292
+ }
293
+
294
+ // -------------------------------------------------------------------------
295
+ // Trace → relay
296
+ // -------------------------------------------------------------------------
297
+
298
+ private identity(agentName: string): { userId: string; username: string } {
299
+ this.agentName ??= agentName;
300
+ const username = this.config.username ?? agentName;
301
+ const userId = this.config.userId ?? this.config.botId ?? agentName;
302
+ return { userId, username };
303
+ }
304
+
305
+ private activation(agentName: string): ActivationState {
306
+ let st = this.activations.get(agentName);
307
+ if (!st) {
308
+ st = { announced: false, blocks: new Map() };
309
+ this.activations.set(agentName, st);
310
+ }
311
+ return st;
312
+ }
313
+
314
+ /** Adopt a channel from a trace and emit activation_start once we have one. */
315
+ private adoptChannel(st: ActivationState, agentName: string, mcplChannelId?: string): void {
316
+ if (mcplChannelId && !st.mcplChannelId) {
317
+ st.mcplChannelId = mcplChannelId;
318
+ st.rawChannelId = rawChannelId(mcplChannelId);
319
+ }
320
+ if (!st.announced && st.rawChannelId) {
321
+ st.announced = true;
322
+ this.client?.emit('activation_start', {
323
+ channelId: st.rawChannelId,
324
+ ...this.identity(agentName),
325
+ });
326
+ }
327
+ }
328
+
329
+ private endActivation(agentName: string, reason: 'complete' | 'abort' | 'error'): void {
330
+ const st = this.activations.get(agentName);
331
+ this.activations.delete(agentName);
332
+ if (!st?.announced || !st.rawChannelId) return;
333
+ this.client?.emit('activation_end', {
334
+ channelId: st.rawChannelId,
335
+ ...this.identity(agentName),
336
+ reason,
337
+ });
338
+ }
339
+
340
+ private onTraceEvent(event: TraceEvent): void {
341
+ switch (event.type) {
342
+ case 'inference:started': {
343
+ // A context-budget restart re-emits inference:started mid-turn; the
344
+ // existing activation (announced or not) simply carries on.
345
+ const st = this.activation(event.agentName);
346
+ this.adoptChannel(st, event.agentName, event.channelId);
347
+ break;
348
+ }
349
+
350
+ case 'inference:tokens': {
351
+ const st = this.activation(event.agentName);
352
+ this.adoptChannel(st, event.agentName, event.channelId);
353
+ if (!st.rawChannelId) break; // no locus — nothing to voice into
354
+ st.blocks.set(event.blockIndex, (st.blocks.get(event.blockIndex) ?? '') + event.content);
355
+ this.client?.emit('chunk', {
356
+ channelId: st.rawChannelId,
357
+ ...this.identity(event.agentName),
358
+ text: event.content,
359
+ blockIndex: event.blockIndex,
360
+ blockType: event.blockType as BlockType,
361
+ // Voice only spoken prose. Thinking/tool blocks still stream (the
362
+ // protocol carries them for UI), but flagged invisible.
363
+ visible: event.blockType === 'text',
364
+ });
365
+ break;
366
+ }
367
+
368
+ case 'inference:content_block': {
369
+ const st = this.activation(event.agentName);
370
+ this.adoptChannel(st, event.agentName, event.channelId);
371
+ if (!st.rawChannelId) break;
372
+ const base = {
373
+ channelId: st.rawChannelId,
374
+ ...this.identity(event.agentName),
375
+ blockIndex: event.blockIndex,
376
+ blockType: event.blockType as BlockType,
377
+ };
378
+ if (event.phase === 'block_start') {
379
+ this.client?.emit('block_start', base);
380
+ } else {
381
+ // Reconstructed from this block's chunks — the trace itself
382
+ // deliberately omits block content.
383
+ const content = st.blocks.get(event.blockIndex) ?? '';
384
+ st.blocks.delete(event.blockIndex);
385
+ this.client?.emit('block_complete', { ...base, content });
386
+ }
387
+ break;
388
+ }
389
+
390
+ case 'inference:completed':
391
+ this.endActivation(event.agentName, 'complete');
392
+ break;
393
+ case 'inference:aborted':
394
+ this.endActivation(event.agentName, 'abort');
395
+ break;
396
+ case 'inference:failed':
397
+ case 'inference:exhausted':
398
+ this.endActivation(event.agentName, 'error');
399
+ break;
400
+
401
+ case 'mcpl:speech-routed': {
402
+ // Remember what landed where, so an interruption can find its edit
403
+ // target: (channelId, messageId, text) per posted segment.
404
+ this.routed.push({
405
+ rawChannelId: rawChannelId(event.channelId),
406
+ mcplChannelId: event.channelId,
407
+ messageId: event.messageId,
408
+ text: event.text,
409
+ at: event.timestamp,
410
+ });
411
+ if (this.routed.length > ROUTED_RING_MAX) {
412
+ this.routed.splice(0, this.routed.length - ROUTED_RING_MAX);
413
+ }
414
+ break;
415
+ }
416
+
417
+ default:
418
+ break;
419
+ }
420
+ }
421
+
422
+ // -------------------------------------------------------------------------
423
+ // Interruption → edit the posted message down to the voiced words
424
+ // -------------------------------------------------------------------------
425
+
426
+ private async handleInterruption(ev: InterruptionEvent): Promise<void> {
427
+ console.error(
428
+ `[tts-relay] interruption in ${ev.channelId} (${ev.reason}): ${ev.spokenText.length} chars voiced`,
429
+ );
430
+
431
+ const target = this.findInterruptedMessage(ev);
432
+ let edited = false;
433
+
434
+ if (target?.messageId && ev.spokenText.trim().length > 0) {
435
+ const result = await this.ctx!.callTool({
436
+ id: `tts-relay-interrupt-${Date.now()}`,
437
+ name: `mcpl--${this.config.editServerId ?? 'discord'}--edit_message`,
438
+ input: {
439
+ channelId: target.rawChannelId,
440
+ messageId: target.messageId,
441
+ content: ev.spokenText,
442
+ },
443
+ });
444
+ edited = result.success === true;
445
+ if (!edited) {
446
+ console.error(`[tts-relay] edit_message failed for ${target.messageId}:`, result.error);
447
+ }
448
+ } else if (!target?.messageId) {
449
+ console.error('[tts-relay] no routed message matched the interruption — nothing edited');
450
+ }
451
+
452
+ // Tell the agent she was cut off — otherwise her next turn believes the
453
+ // whole paragraph was heard. Plain context note, no inference request:
454
+ // the voice client's transcript of what the human said is the activation.
455
+ if (this.config.notifyOnInterruption !== false) {
456
+ const note = edited
457
+ ? `[voice] Your message was interrupted by ${ev.reason.replace('_', ' ')} — only this much was voiced, and the posted message was trimmed to match: "${ev.spokenText}"`
458
+ : `[voice] Your reply was interrupted by ${ev.reason.replace('_', ' ')} after: "${ev.spokenText}" (the posted message could not be trimmed)`;
459
+ this.ctx?.addMessage('user', [{ type: 'text', text: note }]);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Match the interruption to the posted segment being voiced when it hit:
465
+ * newest-first, same channel, and the voiced words prefix-match the
466
+ * segment's text (whitespace-collapsed — TTS clients normalize). Falls back
467
+ * to the newest message in the channel: with ROUTED_RING_MAX recency that's
468
+ * almost always the one being read aloud.
469
+ */
470
+ private findInterruptedMessage(ev: InterruptionEvent): RoutedMessage | null {
471
+ const inChannel = this.routed.filter((r) => r.rawChannelId === ev.channelId).reverse();
472
+ if (inChannel.length === 0) return null;
473
+ const spoken = collapseWs(ev.spokenText);
474
+ if (spoken.length > 0) {
475
+ const probe = spoken.slice(0, 80);
476
+ const match = inChannel.find((r) => collapseWs(r.text).startsWith(probe));
477
+ if (match) return match;
478
+ }
479
+ return inChannel[0];
480
+ }
481
+ }
@@ -16,7 +16,8 @@
16
16
  * fan-out across many VMs are the reverse-proxy's job; an optional Basic-Auth
17
17
  * check is available as defense-in-depth.
18
18
  *
19
- * See WEBUI-PLAN.md and src/web/protocol.ts for the wire shape.
19
+ * See src/web/protocol.ts for the wire shape and docs/webui-deployment.md
20
+ * for the deployment story.
20
21
  */
21
22
 
22
23
  import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
@@ -57,6 +58,7 @@ import {
57
58
  type TokenUsage,
58
59
  type CallLedgerSnapshot,
59
60
  type McplListMessage,
61
+ type BranchesListMessage,
60
62
  type LessonsListMessage,
61
63
  } from '../web/protocol.js';
62
64
  import {
@@ -1547,6 +1549,9 @@ export class WebUiModule implements Module {
1547
1549
  if (client.auth !== 'observer') return false;
1548
1550
  if (type === 'ping') return true;
1549
1551
  if (type === 'request-history') return client.scopes?.has('messages') ?? false;
1552
+ // Branch listing is conversation-shape metadata (names, fork points) —
1553
+ // same sensitivity tier as the message window, so same scope.
1554
+ if (type === 'request-branches') return client.scopes?.has('messages') ?? false;
1550
1555
  return false; // observers are read-only: no user-message/command/mcpl/fleet
1551
1556
  }
1552
1557
 
@@ -1698,6 +1703,11 @@ export class WebUiModule implements Module {
1698
1703
  return;
1699
1704
  }
1700
1705
 
1706
+ case 'request-branches': {
1707
+ this.sendBranchesList(client);
1708
+ return;
1709
+ }
1710
+
1701
1711
  case 'mcpl-add': {
1702
1712
  try {
1703
1713
  const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
@@ -1983,6 +1993,40 @@ export class WebUiModule implements Module {
1983
1993
  this.send(client, out);
1984
1994
  }
1985
1995
 
1996
+ /** Build a BranchesListMessage from the agent's context manager. Lineage
1997
+ * (parentId + branchPoint) comes straight from Chronicle's branch records;
1998
+ * the SPA folds it into a tree. */
1999
+ private sendBranchesList(client: ClientState): void {
2000
+ if (!sharedServer?.app) return;
2001
+ const cm = sharedServer.app.framework.getAllAgents()[0]?.getContextManager();
2002
+ if (!cm) {
2003
+ this.send(client, { type: 'error', message: 'no agent context manager' });
2004
+ return;
2005
+ }
2006
+ try {
2007
+ const branches = cm.listBranches();
2008
+ const current = cm.currentBranch();
2009
+ const out: BranchesListMessage = {
2010
+ type: 'branches-list',
2011
+ branches: branches.map((b) => ({
2012
+ id: b.id,
2013
+ name: b.name,
2014
+ head: b.head,
2015
+ ...(b.parentId !== undefined ? { parentId: b.parentId } : {}),
2016
+ ...(b.branchPoint !== undefined ? { branchPoint: b.branchPoint } : {}),
2017
+ created: b.created instanceof Date ? b.created.getTime() : Number(b.created),
2018
+ })),
2019
+ currentId: current.id,
2020
+ };
2021
+ this.send(client, out);
2022
+ } catch (err) {
2023
+ this.send(client, {
2024
+ type: 'error',
2025
+ message: `branch listing failed: ${err instanceof Error ? err.message : String(err)}`,
2026
+ });
2027
+ }
2028
+ }
2029
+
1986
2030
  /** Build a LessonsListMessage from the bound LessonsModule, if present. */
1987
2031
  private sendLessonsList(client: ClientState): void {
1988
2032
  if (!sharedServer?.app) return;