@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,1074 @@
1
+ /**
2
+ * Slash command handler for Chronicle-backed reversibility.
3
+ *
4
+ * Commands:
5
+ * /undo — Revert to state before last agent turn
6
+ * /redo — Re-apply last undone action
7
+ * /checkpoint N — Save current state as named checkpoint
8
+ * /restore N — Branch from checkpoint, switch to it
9
+ * /branches — List all Chronicle branches
10
+ * /checkout N — Switch to named branch
11
+ * /history — Show recent state transitions
12
+ * /lessons — Show current lesson library
13
+ * /status — Show agent/module status
14
+ * /clear — Clear conversation display
15
+ * /mcp list|add|remove|env — Manage MCPL server config
16
+ * /budget [N] — Show/set stream token budget (e.g. /budget 1m)
17
+ * /session — Session management (list, new, switch, rename, delete)
18
+ * /recipe — Show current recipe info
19
+ * /newtopic — Reset head window (auto-summarize or with user context)
20
+ * /export — Export lessons to ./output/ (JSON + markdown)
21
+ * /usage — Show session token usage and costs
22
+ * /help — List commands
23
+ */
24
+
25
+ import { writeFileSync, mkdirSync } from 'node:fs';
26
+ import { resolve } from 'node:path';
27
+ import type { AgentFramework } from '@animalabs/agent-framework';
28
+ import type { ContextManager } from '@animalabs/context-manager';
29
+ import type { Recipe } from './recipe.js';
30
+ import { readMcplServersFile, saveMcplServers, DEFAULT_CONFIG_PATH } from './mcpl-config.js';
31
+ import { fmtTokens } from './tui.js';
32
+ import { type FleetModule, formatChildRow } from './modules/fleet-module.js';
33
+
34
+ /** Imported lazily to avoid circular deps — index.ts re-exports the type. */
35
+ interface AppContext {
36
+ framework: AgentFramework;
37
+ sessionManager: import('./session-manager.js').SessionManager;
38
+ recipe: Recipe;
39
+ branchState: BranchState;
40
+ switchSession(id: string): Promise<void>;
41
+ }
42
+
43
+ export type Line = { text: string; style?: 'user' | 'agent' | 'tool' | 'system' };
44
+
45
+ // Undo/redo stacks: track (branchName, messageId) pairs for time-travel.
46
+ //
47
+ // Earlier shape had a separate `branchId: string` field alongside
48
+ // `branchName`. After the fix for `/redo` / `/restore` / `/checkout` (which
49
+ // required these handlers to pass a NAME to `switchBranch`), every write
50
+ // stored a name in both fields and every read preferred branchName. The
51
+ // field name was actively lying: the next reader naturally fills
52
+ // `branchId` with `branch.id` and reintroduces the exact bug the rename
53
+ // is meant to prevent. Collapsed to a single `branchName` so the type
54
+ // system enforces the actual contract — `switchBranch` only accepts
55
+ // names; nothing else is a valid identifier here.
56
+ export interface StatePoint {
57
+ branchName: string;
58
+ messageId?: string;
59
+ }
60
+
61
+ /**
62
+ * Mutable branch-related state. Lives on AppContext so it can be
63
+ * reset consistently on session switch, MCPL branch operations, etc.
64
+ */
65
+ export interface BranchState {
66
+ undoStack: StatePoint[];
67
+ redoStack: StatePoint[];
68
+ checkpoints: Map<string, StatePoint>;
69
+ }
70
+
71
+ export function createBranchState(): BranchState {
72
+ return {
73
+ undoStack: [],
74
+ redoStack: [],
75
+ checkpoints: new Map(),
76
+ };
77
+ }
78
+
79
+ export function resetBranchState(bs: BranchState): void {
80
+ bs.undoStack.length = 0;
81
+ bs.redoStack.length = 0;
82
+ bs.checkpoints.clear();
83
+ }
84
+
85
+ export interface CommandResult {
86
+ lines: Line[];
87
+ quit?: boolean;
88
+ /** Session ID to switch to — caller performs the async switch. */
89
+ switchToSessionId?: string;
90
+ /** True when a Chronicle branch switch occurred — TUI should refreshFromStore(). */
91
+ branchChanged?: boolean;
92
+ /** Async follow-up work — TUI shows status while awaiting, then displays result lines. */
93
+ asyncWork?: Promise<CommandResult>;
94
+ /** Child name to peek at — TUI should switch to peek-proc view. */
95
+ switchToFleetPeek?: string;
96
+ /** When set, TUI should switch to the cross-process fleet view. */
97
+ switchToFleetView?: boolean;
98
+ }
99
+
100
+ /**
101
+ * Get the context manager for the main agent.
102
+ * Falls back to the first registered agent if the named one isn't found.
103
+ */
104
+ function getAgentCM(framework: AgentFramework, agentName?: string): ContextManager | null {
105
+ if (agentName) {
106
+ const agent = framework.getAgent(agentName);
107
+ if (agent) return agent.getContextManager() ?? null;
108
+ }
109
+ // Fallback: first agent
110
+ const all = framework.getAllAgents();
111
+ return all[0]?.getContextManager() ?? null;
112
+ }
113
+
114
+ export function handleCommand(command: string, app: AppContext): CommandResult {
115
+ const parts = command.slice(1).split(/\s+/);
116
+ const cmd = parts[0]!;
117
+ const args = parts.slice(1);
118
+ const framework = app.framework;
119
+
120
+ switch (cmd) {
121
+ case 'quit':
122
+ case 'q': {
123
+ // When lessons aren't loaded, skip the export call entirely so quit
124
+ // doesn't surface a misleading "module not loaded" line. /export still
125
+ // reports the absence on its own when invoked explicitly.
126
+ const hasLessons = framework.getAllModules().some(m => m.name === 'lessons');
127
+ if (!hasLessons) {
128
+ return { lines: [{ text: 'Goodbye.', style: 'system' }], quit: true };
129
+ }
130
+ const exportResult = handleExport(app);
131
+ return { lines: exportResult.lines, quit: true };
132
+ }
133
+
134
+ case 'help':
135
+ return {
136
+ lines: [
137
+ { text: '--- Commands ---', style: 'system' },
138
+ { text: ' /quit, /q Exit the app', style: 'system' },
139
+ { text: ' /status Show agent status', style: 'system' },
140
+ { text: ' /clear Clear conversation', style: 'system' },
141
+ { text: ' /lessons Show lesson library', style: 'system' },
142
+ { text: ' /export Export lessons to ./output/ (JSON + markdown)', style: 'system' },
143
+ { text: ' /undo Revert last agent turn', style: 'system' },
144
+ { text: ' /redo Re-apply undone action', style: 'system' },
145
+ { text: ' /checkpoint <name> Save current state', style: 'system' },
146
+ { text: ' /restore <name> Restore to checkpoint', style: 'system' },
147
+ { text: ' /branches List Chronicle branches', style: 'system' },
148
+ { text: ' /checkout <name> Switch to branch', style: 'system' },
149
+ { text: ' /history Show state transitions', style: 'system' },
150
+ { text: ' /mcp list List MCPL servers', style: 'system' },
151
+ { text: ' /mcp add <id> <cmd> Add/overwrite server', style: 'system' },
152
+ { text: ' /mcp remove <id> Remove a server', style: 'system' },
153
+ { text: ' /mcp env <id> K=V ... Set env vars on server', style: 'system' },
154
+ { text: ' /budget [tokens] Show/set stream token budget', style: 'system' },
155
+ { text: ' /session Show current session', style: 'system' },
156
+ { text: ' /session list List all sessions', style: 'system' },
157
+ { text: ' /session new [name] Create new session', style: 'system' },
158
+ { text: ' /session switch <name> Switch to session', style: 'system' },
159
+ { text: ' /session rename <name> Rename current session', style: 'system' },
160
+ { text: ' /session delete <name> Delete a session', style: 'system' },
161
+ { text: ' /recipe Show current recipe info', style: 'system' },
162
+ { text: ' /newtopic [context] Reset head window (auto-summarize if empty)', style: 'system' },
163
+ { text: ' /usage Show session token usage and costs', style: 'system' },
164
+ { text: ' /fleet Show / peek / stop / restart cross-process children', style: 'system' },
165
+ { text: ' (subcommands: list | status [name] | view | peek <name> | stop <name> | restart <name>)', style: 'system' },
166
+ ],
167
+ };
168
+
169
+ case 'clear':
170
+ return { lines: [{ text: '(cleared)', style: 'system' }] };
171
+
172
+ case 'status':
173
+ return handleStatus(framework);
174
+
175
+ case 'lessons':
176
+ return handleLessons(framework);
177
+
178
+ case 'export':
179
+ return handleExport(app);
180
+
181
+ case 'undo':
182
+ return handleUndo(app);
183
+
184
+ case 'redo':
185
+ return handleRedo(app);
186
+
187
+ case 'checkpoint':
188
+ return handleCheckpoint(app, args[0]);
189
+
190
+ case 'restore':
191
+ return handleRestore(app, args[0]);
192
+
193
+ case 'branches':
194
+ return handleBranches(framework);
195
+
196
+ case 'checkout':
197
+ return handleCheckout(framework, args[0]);
198
+
199
+ case 'history':
200
+ return handleHistory(framework);
201
+
202
+ case 'mcp':
203
+ return handleMcp(args);
204
+
205
+ case 'budget':
206
+ return handleBudget(framework, args[0]);
207
+
208
+ case 'session':
209
+ return handleSession(app, args);
210
+
211
+ case 'recipe':
212
+ return handleRecipe(app);
213
+
214
+ case 'newtopic':
215
+ return handleNewTopic(app, args);
216
+
217
+ case 'usage':
218
+ return handleUsage(app);
219
+
220
+ case 'fleet':
221
+ return handleFleet(app, args);
222
+
223
+ default:
224
+ return {
225
+ lines: [{ text: `Unknown command: /${cmd}. Type /help.`, style: 'system' }],
226
+ };
227
+ }
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // /session subcommands
232
+ // ---------------------------------------------------------------------------
233
+
234
+ function handleSession(app: AppContext, args: string[]): CommandResult {
235
+ const sub = args[0];
236
+ switch (sub) {
237
+ case undefined:
238
+ return handleSessionInfo(app);
239
+ case 'list':
240
+ case 'ls':
241
+ return handleSessionList(app);
242
+ case 'new':
243
+ case 'create':
244
+ return handleSessionNew(app, args.slice(1).join(' ') || undefined);
245
+ case 'switch':
246
+ case 'sw':
247
+ return handleSessionSwitch(app, args[1]);
248
+ case 'rename':
249
+ return handleSessionRename(app, args.slice(1).join(' ') || undefined);
250
+ case 'delete':
251
+ case 'rm':
252
+ return handleSessionDelete(app, args[1]);
253
+ default:
254
+ return { lines: [{ text: `Unknown /session subcommand: ${sub}. Try /session list.`, style: 'system' }] };
255
+ }
256
+ }
257
+
258
+ function handleSessionInfo(app: AppContext): CommandResult {
259
+ const session = app.sessionManager.getActiveSession();
260
+ if (!session) {
261
+ return { lines: [{ text: 'No active session.', style: 'system' }] };
262
+ }
263
+
264
+ const lines: Line[] = [
265
+ { text: '--- Current Session ---', style: 'system' },
266
+ { text: ` Name: ${session.name}${session.manuallyNamed ? '' : ' (auto)'}`, style: 'system' },
267
+ { text: ` ID: ${session.id}`, style: 'system' },
268
+ { text: ` Created: ${session.createdAt}`, style: 'system' },
269
+ { text: ` Last accessed: ${session.lastAccessedAt}`, style: 'system' },
270
+ ];
271
+
272
+ if (session.messageCount !== undefined) {
273
+ lines.push({ text: ` Messages: ${session.messageCount}`, style: 'system' });
274
+ }
275
+
276
+ return { lines };
277
+ }
278
+
279
+ function handleSessionList(app: AppContext): CommandResult {
280
+ const sessions = app.sessionManager.listSessions();
281
+ const active = app.sessionManager.getActiveSession();
282
+
283
+ if (sessions.length === 0) {
284
+ return { lines: [{ text: 'No sessions.', style: 'system' }] };
285
+ }
286
+
287
+ const lines: Line[] = [{ text: `--- Sessions (${sessions.length}) ---`, style: 'system' }];
288
+ for (const s of sessions) {
289
+ const marker = s.id === active?.id ? ' *' : '';
290
+ const msgs = s.messageCount !== undefined ? ` (${s.messageCount} msgs)` : '';
291
+ const naming = s.manuallyNamed ? '' : ' (auto)';
292
+ lines.push({
293
+ text: ` ${s.name}${naming} [${s.id}]${msgs}${marker}`,
294
+ style: 'system',
295
+ });
296
+ }
297
+
298
+ return { lines };
299
+ }
300
+
301
+ function handleSessionNew(app: AppContext, name?: string): CommandResult {
302
+ const session = app.sessionManager.createSession(name);
303
+ resetBranchState(app.branchState);
304
+
305
+ return {
306
+ lines: [{ text: `Switching to new session: ${session.name} [${session.id}]...`, style: 'system' }],
307
+ switchToSessionId: session.id,
308
+ };
309
+ }
310
+
311
+ function handleSessionSwitch(app: AppContext, nameOrId?: string): CommandResult {
312
+ if (!nameOrId) {
313
+ return { lines: [{ text: 'Usage: /session switch <name or id>', style: 'system' }] };
314
+ }
315
+
316
+ const session = app.sessionManager.findSession(nameOrId);
317
+ if (!session) {
318
+ return { lines: [{ text: `Session "${nameOrId}" not found. Use /session list.`, style: 'system' }] };
319
+ }
320
+
321
+ const active = app.sessionManager.getActiveSession();
322
+ if (active && session.id === active.id) {
323
+ return { lines: [{ text: `Already on session "${session.name}".`, style: 'system' }] };
324
+ }
325
+
326
+ resetBranchState(app.branchState);
327
+
328
+ return {
329
+ lines: [{ text: `Switching to session: ${session.name} [${session.id}]...`, style: 'system' }],
330
+ switchToSessionId: session.id,
331
+ };
332
+ }
333
+
334
+ function handleSessionRename(app: AppContext, name?: string): CommandResult {
335
+ if (!name) {
336
+ return { lines: [{ text: 'Usage: /session rename <name>', style: 'system' }] };
337
+ }
338
+
339
+ const session = app.sessionManager.getActiveSession();
340
+ if (!session) {
341
+ return { lines: [{ text: 'No active session.', style: 'system' }] };
342
+ }
343
+
344
+ app.sessionManager.renameSession(session.id, name);
345
+ return { lines: [{ text: `Session renamed to "${name}".`, style: 'system' }] };
346
+ }
347
+
348
+ function handleSessionDelete(app: AppContext, nameOrId?: string): CommandResult {
349
+ if (!nameOrId) {
350
+ return { lines: [{ text: 'Usage: /session delete <name or id>', style: 'system' }] };
351
+ }
352
+
353
+ const session = app.sessionManager.findSession(nameOrId);
354
+ if (!session) {
355
+ return { lines: [{ text: `Session "${nameOrId}" not found.`, style: 'system' }] };
356
+ }
357
+
358
+ try {
359
+ app.sessionManager.deleteSession(session.id);
360
+ return { lines: [{ text: `Deleted session "${session.name}" [${session.id}].`, style: 'system' }] };
361
+ } catch (err) {
362
+ return { lines: [{ text: `Delete failed: ${err instanceof Error ? err.message : err}`, style: 'system' }] };
363
+ }
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // /recipe
368
+ // ---------------------------------------------------------------------------
369
+
370
+ function handleRecipe(app: AppContext): CommandResult {
371
+ const r = app.recipe;
372
+ const lines: Line[] = [
373
+ { text: '--- Recipe ---', style: 'system' },
374
+ { text: ` Name: ${r.name}`, style: 'system' },
375
+ ];
376
+ if (r.description) {
377
+ lines.push({ text: ` Description: ${r.description}`, style: 'system' });
378
+ }
379
+ if (r.version) {
380
+ lines.push({ text: ` Version: ${r.version}`, style: 'system' });
381
+ }
382
+ lines.push({ text: ` Agent: ${r.agent.name || 'agent'} (${r.agent.model || 'default'})`, style: 'system' });
383
+
384
+ const mods = r.modules ?? {};
385
+ const enabled = Object.entries(mods)
386
+ .filter(([, v]) => v !== false)
387
+ .map(([k]) => k);
388
+ lines.push({ text: ` Modules: ${enabled.join(', ') || 'none'}`, style: 'system' });
389
+
390
+ const mcpCount = r.mcpServers ? Object.keys(r.mcpServers).length : 0;
391
+ lines.push({ text: ` MCP servers (recipe): ${mcpCount}`, style: 'system' });
392
+
393
+ return { lines };
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // /usage — session token usage and cost breakdown
398
+ // ---------------------------------------------------------------------------
399
+
400
+ function handleUsage(app: AppContext): CommandResult {
401
+ const snapshot = app.framework.getSessionUsage();
402
+ const lines: Line[] = [];
403
+ const fmt = fmtTokens;
404
+
405
+ const fmtCost = (cost?: { total: number; currency: string }) => {
406
+ if (!cost) return '';
407
+ return ` $${cost.total.toFixed(4)}`;
408
+ };
409
+
410
+ const { totals } = snapshot;
411
+ lines.push({ text: '--- Session Usage ---', style: 'system' });
412
+ lines.push({
413
+ text: ` Total: ${fmt(totals.inputTokens)} in ${fmt(totals.outputTokens)} out ${fmt(totals.cacheReadTokens)} cache read ${fmt(totals.cacheCreationTokens)} cache write${fmtCost(totals.estimatedCost)}`,
414
+ style: 'system',
415
+ });
416
+ lines.push({ text: ` Inferences: ${snapshot.inferenceCount}`, style: 'system' });
417
+
418
+ if (snapshot.byAgent.length > 0) {
419
+ lines.push({ text: '', style: 'system' });
420
+ lines.push({ text: '--- Per Agent ---', style: 'system' });
421
+ for (const agent of snapshot.byAgent) {
422
+ const u = agent.usage;
423
+ lines.push({
424
+ text: ` ${agent.agentName} ${fmt(u.inputTokens)} in ${fmt(u.outputTokens)} out ${fmt(u.cacheReadTokens)} cache read ${fmt(u.cacheCreationTokens)} cache write${fmtCost(u.estimatedCost)} (${agent.inferenceCount} inf)`,
425
+ style: 'system',
426
+ });
427
+ }
428
+ }
429
+
430
+ return { lines };
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Existing handlers (unchanged logic, take framework directly)
435
+ // ---------------------------------------------------------------------------
436
+
437
+ function handleStatus(framework: AgentFramework): CommandResult {
438
+ const agents = framework.getAllAgents();
439
+ const lines: Line[] = [{ text: '--- Status ---', style: 'system' }];
440
+
441
+ for (const agent of agents) {
442
+ lines.push({ text: ` ${agent.name}: ${agent.state.status} (${agent.model})`, style: 'system' });
443
+ }
444
+
445
+ const cm = getAgentCM(framework);
446
+ if (cm) {
447
+ const branch = cm.currentBranch();
448
+ lines.push({ text: ` Branch: ${branch.name} (head: ${branch.head})`, style: 'system' });
449
+ }
450
+
451
+ lines.push({ text: ` Queue depth: ${framework.getQueueDepth()}`, style: 'system' });
452
+
453
+ return { lines };
454
+ }
455
+
456
+ function handleBudget(framework: AgentFramework, arg?: string): CommandResult {
457
+ const agents = framework.getAllAgents();
458
+ if (agents.length === 0) {
459
+ return { lines: [{ text: 'No agents.', style: 'system' }] };
460
+ }
461
+
462
+ if (!arg) {
463
+ // Show current budgets
464
+ const lines: Line[] = [{ text: '--- Stream Token Budgets ---', style: 'system' }];
465
+ for (const agent of agents) {
466
+ const budget = agent.maxStreamTokens;
467
+ const last = agent.lastStreamInputTokens;
468
+ const pct = budget > 0 ? ((last / budget) * 100).toFixed(0) : '—';
469
+ lines.push({
470
+ text: ` ${agent.name}: ${(budget / 1000).toFixed(0)}k (last: ${(last / 1000).toFixed(0)}k, ${pct}%)`,
471
+ style: 'system',
472
+ });
473
+ }
474
+ return { lines };
475
+ }
476
+
477
+ // Parse token count — accept "150k", "150000", "1m", etc.
478
+ let tokens: number;
479
+ const lower = arg.toLowerCase();
480
+ if (lower.endsWith('m')) {
481
+ tokens = parseFloat(lower.slice(0, -1)) * 1_000_000;
482
+ } else if (lower.endsWith('k')) {
483
+ tokens = parseFloat(lower.slice(0, -1)) * 1_000;
484
+ } else {
485
+ tokens = parseInt(arg, 10);
486
+ }
487
+
488
+ if (isNaN(tokens) || tokens <= 0) {
489
+ return { lines: [{ text: `Invalid token count: "${arg}". Examples: 150k, 1m, 200000`, style: 'system' }] };
490
+ }
491
+
492
+ for (const agent of agents) {
493
+ agent.maxStreamTokens = tokens;
494
+ }
495
+
496
+ const display = tokens >= 1_000_000
497
+ ? `${(tokens / 1_000_000).toFixed(1)}m`
498
+ : `${(tokens / 1_000).toFixed(0)}k`;
499
+
500
+ return {
501
+ lines: [{ text: `Stream budget set to ${display} tokens for all agents.`, style: 'system' }],
502
+ };
503
+ }
504
+
505
+ function handleLessons(framework: AgentFramework): CommandResult {
506
+ const modules = framework.getAllModules();
507
+ const lessonsModule = modules.find(m => m.name === 'lessons') as
508
+ { getLessons(): Array<{ id: string; content: string; confidence: number; tags: string[]; deprecated: boolean }> } | undefined;
509
+
510
+ if (!lessonsModule) {
511
+ return { lines: [{ text: 'Lessons module not loaded.', style: 'system' }] };
512
+ }
513
+
514
+ const lessons = lessonsModule.getLessons();
515
+ const active = lessons.filter(l => !l.deprecated);
516
+
517
+ if (active.length === 0) {
518
+ return { lines: [{ text: 'No lessons yet. The agent will create them during analysis.', style: 'system' }] };
519
+ }
520
+
521
+ const lines: Line[] = [{ text: `--- Lessons (${active.length}) ---`, style: 'system' }];
522
+ for (const l of active.sort((a, b) => b.confidence - a.confidence)) {
523
+ const conf = (l.confidence * 100).toFixed(0);
524
+ lines.push({
525
+ text: ` [${conf}%] ${l.id}: ${l.content.slice(0, 80)}${l.content.length > 80 ? '...' : ''} (${l.tags.join(', ')})`,
526
+ style: 'system',
527
+ });
528
+ }
529
+
530
+ return { lines };
531
+ }
532
+
533
+ /** Export lessons to ./output/ as JSON and markdown. Called by /export and on quit. */
534
+ export function handleExport(app: AppContext): CommandResult {
535
+ const modules = app.framework.getAllModules();
536
+ const lessonsModule = modules.find(m => m.name === 'lessons') as
537
+ { getLessons(): Array<{ id: string; content: string; confidence: number; tags: string[]; evidence: string[]; created: number; updated: number; deprecated: boolean; deprecationReason?: string }> } | undefined;
538
+
539
+ if (!lessonsModule) {
540
+ return { lines: [{ text: 'Lessons module not loaded — nothing to export.', style: 'system' }] };
541
+ }
542
+
543
+ const lessons = lessonsModule.getLessons();
544
+ if (lessons.length === 0) {
545
+ return { lines: [{ text: 'No lessons to export.', style: 'system' }] };
546
+ }
547
+
548
+ const active = lessons.filter(l => !l.deprecated);
549
+ const outDir = resolve('./output');
550
+ mkdirSync(outDir, { recursive: true });
551
+
552
+ // JSON export with metadata envelope
553
+ const exportData = {
554
+ exportedAt: new Date().toISOString(),
555
+ sessionName: app.sessionManager.getActiveSession()?.name ?? 'unknown',
556
+ recipeName: app.recipe.name,
557
+ lessonCount: lessons.length,
558
+ activeCount: active.length,
559
+ lessons,
560
+ };
561
+ const jsonPath = resolve(outDir, 'lessons-export.json');
562
+ writeFileSync(jsonPath, JSON.stringify(exportData, null, 2));
563
+
564
+ // Markdown summary grouped by tag
565
+ const tagMap = new Map<string, typeof active>();
566
+ for (const l of active) {
567
+ for (const tag of l.tags.length ? l.tags : ['untagged']) {
568
+ if (!tagMap.has(tag)) tagMap.set(tag, []);
569
+ tagMap.get(tag)!.push(l);
570
+ }
571
+ }
572
+
573
+ let md = `# Lessons Export\n\n`;
574
+ md += `**Exported:** ${new Date().toISOString()} \n`;
575
+ md += `**Recipe:** ${app.recipe.name} \n`;
576
+ md += `**Active lessons:** ${active.length} (${lessons.length - active.length} deprecated) \n\n`;
577
+
578
+ for (const [tag, tagLessons] of [...tagMap.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
579
+ md += `## ${tag}\n\n`;
580
+ for (const l of tagLessons.sort((a, b) => b.confidence - a.confidence)) {
581
+ const conf = (l.confidence * 100).toFixed(0);
582
+ md += `- **[${conf}%]** ${l.content}`;
583
+ if (l.evidence.length) md += ` \n _Sources: ${l.evidence.join(', ')}_`;
584
+ md += '\n';
585
+ }
586
+ md += '\n';
587
+ }
588
+
589
+ const mdPath = resolve(outDir, 'lessons-export.md');
590
+ writeFileSync(mdPath, md);
591
+
592
+ return {
593
+ lines: [
594
+ { text: `Exported ${active.length} lessons (${lessons.length - active.length} deprecated) to:`, style: 'system' },
595
+ { text: ` ${jsonPath}`, style: 'system' },
596
+ { text: ` ${mdPath}`, style: 'system' },
597
+ ],
598
+ };
599
+ }
600
+
601
+ function handleUndo(app: AppContext): CommandResult {
602
+ const { framework, branchState: bs } = app;
603
+ const cm = getAgentCM(framework);
604
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
605
+
606
+ const { messages } = cm.queryMessages({});
607
+ if (messages.length === 0) {
608
+ return { lines: [{ text: 'Nothing to undo.', style: 'system' }] };
609
+ }
610
+
611
+ // Find the last agent message (working backwards)
612
+ let undoPoint: string | undefined;
613
+ for (let i = messages.length - 1; i >= 0; i--) {
614
+ if (messages[i]!.participant !== 'user') {
615
+ // Found an agent message — undo to the message before it
616
+ undoPoint = i > 0 ? messages[i - 1]!.id : undefined;
617
+ break;
618
+ }
619
+ }
620
+
621
+ if (!undoPoint) {
622
+ return { lines: [{ text: 'Nothing to undo (no agent messages found).', style: 'system' }] };
623
+ }
624
+
625
+ // Snapshot the current branch's NAME so /redo can return here. Chronicle's
626
+ // switchBranch is keyed by name, not the internal numeric id.
627
+ const currentBranch = cm.currentBranch();
628
+ const newBranchName = `undo-${Date.now()}`;
629
+
630
+ let createdBranchName: string;
631
+ try {
632
+ // branchAt returns the new branch's NAME (the only thing switchBranch accepts).
633
+ createdBranchName = cm.branchAt(undoPoint, newBranchName);
634
+ } catch (err) {
635
+ return { lines: [{ text: `Undo failed (branchAt): ${err}`, style: 'system' }] };
636
+ }
637
+
638
+ bs.redoStack.push({ branchName: currentBranch.name });
639
+ bs.undoStack.push({
640
+ branchName: createdBranchName,
641
+ messageId: undoPoint,
642
+ });
643
+
644
+ // switchBranch is async — it re-initializes the strategy on the new branch
645
+ // (autobio reloads its persisted summaries / pins / mergeQueue). Without
646
+ // awaiting, the next /compile or send-message could see stale strategy
647
+ // state from the prior branch.
648
+ const asyncWork = Promise.resolve(cm.switchBranch(createdBranchName))
649
+ .then(() => ({
650
+ lines: [{ text: `Undone. Switched to branch "${createdBranchName}".`, style: 'system' as const }],
651
+ branchChanged: true,
652
+ }))
653
+ .catch(err => ({
654
+ lines: [{ text: `Undo failed (switchBranch): ${err}`, style: 'system' as const }],
655
+ }));
656
+
657
+ return {
658
+ lines: [{ text: `Undoing → branch "${createdBranchName}"...`, style: 'system' }],
659
+ asyncWork,
660
+ };
661
+ }
662
+
663
+ function handleRedo(app: AppContext): CommandResult {
664
+ const { framework, branchState: bs } = app;
665
+ const cm = getAgentCM(framework);
666
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
667
+
668
+ if (bs.redoStack.length === 0) {
669
+ return { lines: [{ text: 'Nothing to redo.', style: 'system' }] };
670
+ }
671
+
672
+ const point = bs.redoStack.pop()!;
673
+ const target = point.branchName;
674
+
675
+ const asyncWork = Promise.resolve(cm.switchBranch(target))
676
+ .then(() => ({
677
+ lines: [{ text: `Redone. Switched to branch "${target}".`, style: 'system' as const }],
678
+ branchChanged: true,
679
+ }))
680
+ .catch(err => ({
681
+ lines: [{ text: `Redo failed: ${err}`, style: 'system' as const }],
682
+ }));
683
+
684
+ return {
685
+ lines: [{ text: `Redoing → branch "${target}"...`, style: 'system' }],
686
+ asyncWork,
687
+ };
688
+ }
689
+
690
+ function handleCheckpoint(app: AppContext, name?: string): CommandResult {
691
+ if (!name) {
692
+ return { lines: [{ text: 'Usage: /checkpoint <name>', style: 'system' }] };
693
+ }
694
+
695
+ const cm = getAgentCM(app.framework);
696
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
697
+
698
+ const branch = cm.currentBranch();
699
+ app.branchState.checkpoints.set(name, { branchName: branch.name });
700
+
701
+ return { lines: [{ text: `Checkpoint "${name}" saved at branch ${branch.name} (head: ${branch.head}).`, style: 'system' }] };
702
+ }
703
+
704
+ function handleRestore(app: AppContext, name?: string): CommandResult {
705
+ if (!name) {
706
+ const names = [...app.branchState.checkpoints.keys()];
707
+ if (names.length === 0) {
708
+ return { lines: [{ text: 'No checkpoints saved. Use /checkpoint <name> to create one.', style: 'system' }] };
709
+ }
710
+ return {
711
+ lines: [
712
+ { text: 'Available checkpoints:', style: 'system' },
713
+ ...names.map(n => ({ text: ` ${n}`, style: 'system' as const })),
714
+ ],
715
+ };
716
+ }
717
+
718
+ const point = app.branchState.checkpoints.get(name);
719
+ if (!point) {
720
+ return { lines: [{ text: `Checkpoint "${name}" not found.`, style: 'system' }] };
721
+ }
722
+
723
+ const cm = getAgentCM(app.framework);
724
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
725
+
726
+ const target = point.branchName;
727
+
728
+ // switchBranch is async — strategy reinit needs to complete before next op.
729
+ const asyncWork = Promise.resolve(cm.switchBranch(target))
730
+ .then(() => ({
731
+ lines: [{ text: `Restored to checkpoint "${name}" (branch: ${target}).`, style: 'system' as const }],
732
+ branchChanged: true,
733
+ }))
734
+ .catch(err => ({
735
+ lines: [{ text: `Restore failed: ${err}`, style: 'system' as const }],
736
+ }));
737
+
738
+ return {
739
+ lines: [{ text: `Restoring → branch "${target}"...`, style: 'system' }],
740
+ asyncWork,
741
+ };
742
+ }
743
+
744
+ function handleBranches(framework: AgentFramework): CommandResult {
745
+ const cm = getAgentCM(framework);
746
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
747
+
748
+ const branches = cm.listBranches();
749
+ const current = cm.currentBranch();
750
+
751
+ const lines: Line[] = [{ text: `--- Branches (${branches.length}) ---`, style: 'system' }];
752
+ for (const b of branches) {
753
+ const marker = b.id === current.id ? ' *' : '';
754
+ lines.push({
755
+ text: ` ${b.name} (head: ${b.head})${marker}`,
756
+ style: 'system',
757
+ });
758
+ }
759
+
760
+ return { lines };
761
+ }
762
+
763
+ function handleCheckout(framework: AgentFramework, name?: string): CommandResult {
764
+ if (!name) {
765
+ return { lines: [{ text: 'Usage: /checkout <branch-name>', style: 'system' }] };
766
+ }
767
+
768
+ const cm = getAgentCM(framework);
769
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
770
+
771
+ const branches = cm.listBranches();
772
+ const target = branches.find(b => b.name === name || b.id === name);
773
+ if (!target) {
774
+ return { lines: [{ text: `Branch "${name}" not found. Use /branches to list.`, style: 'system' }] };
775
+ }
776
+
777
+ // switchBranch only accepts the branch name (chronicle is name-keyed).
778
+ // It's also async — re-initializes the strategy on the new branch so any
779
+ // persistent strategy state (autobio summaries / pins / mergeQueue) is
780
+ // reloaded for the destination branch's chronicle view.
781
+ const asyncWork = Promise.resolve(cm.switchBranch(target.name))
782
+ .then(() => ({
783
+ lines: [{ text: `Switched to branch ${target.name}.`, style: 'system' as const }],
784
+ branchChanged: true,
785
+ }))
786
+ .catch(err => ({
787
+ lines: [{ text: `Checkout failed: ${err}`, style: 'system' as const }],
788
+ }));
789
+
790
+ return {
791
+ lines: [{ text: `Switching → branch ${target.name}...`, style: 'system' }],
792
+ asyncWork,
793
+ };
794
+ }
795
+
796
+ function handleHistory(framework: AgentFramework): CommandResult {
797
+ const cm = getAgentCM(framework);
798
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
799
+
800
+ const { messages } = cm.queryMessages({});
801
+ const lines: Line[] = [{ text: `--- History (${messages.length} messages) ---`, style: 'system' }];
802
+
803
+ // Show the last 20 messages in summary form
804
+ const recent = messages.slice(-20);
805
+ for (const msg of recent) {
806
+ const text = msg.content
807
+ .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
808
+ .map(b => b.text)
809
+ .join(' ')
810
+ .slice(0, 60);
811
+ const suffix = text.length >= 60 ? '...' : '';
812
+ lines.push({
813
+ text: ` [${msg.id}] ${msg.participant}: ${text}${suffix}`,
814
+ style: 'system',
815
+ });
816
+ }
817
+
818
+ return { lines };
819
+ }
820
+
821
+ // ---------------------------------------------------------------------------
822
+ // /newtopic — reset head window with topic transition
823
+ // ---------------------------------------------------------------------------
824
+
825
+ function handleNewTopic(app: AppContext, args: string[]): CommandResult {
826
+ const cm = getAgentCM(app.framework);
827
+ if (!cm) return { lines: [{ text: 'No context manager.', style: 'system' }] };
828
+
829
+ const userContext = args.join(' ').trim() || undefined;
830
+
831
+ const asyncWork = cm.resetHeadWindow(userContext).then(summary => ({
832
+ lines: [
833
+ { text: 'Topic reset. Transition summary:', style: 'system' as const },
834
+ { text: summary.slice(0, 300) + (summary.length > 300 ? '...' : ''), style: 'system' as const },
835
+ ],
836
+ })).catch(err => ({
837
+ lines: [{ text: `Topic reset failed: ${err instanceof Error ? err.message : err}`, style: 'system' as const }],
838
+ }));
839
+
840
+ return {
841
+ lines: [{ text: userContext
842
+ ? 'Resetting topic with provided context...'
843
+ : 'Generating transition summary...', style: 'system' }],
844
+ asyncWork,
845
+ };
846
+ }
847
+
848
+ // ---------------------------------------------------------------------------
849
+ // /mcp subcommands
850
+ // ---------------------------------------------------------------------------
851
+
852
+ function handleMcp(args: string[]): CommandResult {
853
+ const sub = args[0];
854
+ switch (sub) {
855
+ case 'list':
856
+ case undefined:
857
+ return handleMcpList();
858
+ case 'add':
859
+ return handleMcpAdd(args.slice(1));
860
+ case 'remove':
861
+ return handleMcpRemove(args[1]);
862
+ case 'env':
863
+ return handleMcpEnv(args[1], args.slice(2));
864
+ default:
865
+ return { lines: [{ text: `Unknown /mcp subcommand: ${sub}. Try /mcp list.`, style: 'system' }] };
866
+ }
867
+ }
868
+
869
+ function handleMcpList(): CommandResult {
870
+ const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
871
+ const entries = Object.entries(servers);
872
+
873
+ if (entries.length === 0) {
874
+ return { lines: [{ text: 'No MCPL servers configured. Use /mcp add <id> <command> [args...].', style: 'system' }] };
875
+ }
876
+
877
+ const lines: Line[] = [{ text: `--- MCPL Servers (${entries.length}) ---`, style: 'system' }];
878
+ for (const [id, entry] of entries) {
879
+ const cmdLine = [entry.command, ...(entry.args ?? [])].join(' ');
880
+ lines.push({ text: ` ${id}: ${cmdLine}`, style: 'system' });
881
+ if (entry.env && Object.keys(entry.env).length > 0) {
882
+ const envStr = Object.entries(entry.env).map(([k, v]) => `${k}=${v}`).join(' ');
883
+ lines.push({ text: ` env: ${envStr}`, style: 'system' });
884
+ }
885
+ if (entry.toolPrefix) {
886
+ lines.push({ text: ` toolPrefix: ${entry.toolPrefix}`, style: 'system' });
887
+ }
888
+ }
889
+ return { lines };
890
+ }
891
+
892
+ function handleMcpAdd(args: string[]): CommandResult {
893
+ if (args.length < 2) {
894
+ return { lines: [{ text: 'Usage: /mcp add <id> <command> [args...]', style: 'system' }] };
895
+ }
896
+
897
+ const [id, command, ...cmdArgs] = args;
898
+ const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
899
+ const isOverwrite = id! in servers;
900
+ servers[id!] = { command: command!, ...(cmdArgs.length > 0 ? { args: cmdArgs } : {}) };
901
+ saveMcplServers(DEFAULT_CONFIG_PATH, servers);
902
+
903
+ return {
904
+ lines: [
905
+ { text: `${isOverwrite ? 'Updated' : 'Added'} server "${id}". Restart to apply.`, style: 'system' },
906
+ ],
907
+ };
908
+ }
909
+
910
+ function handleMcpRemove(id?: string): CommandResult {
911
+ if (!id) {
912
+ return { lines: [{ text: 'Usage: /mcp remove <id>', style: 'system' }] };
913
+ }
914
+
915
+ const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
916
+ if (!(id in servers)) {
917
+ return { lines: [{ text: `Server "${id}" not found.`, style: 'system' }] };
918
+ }
919
+
920
+ delete servers[id];
921
+ saveMcplServers(DEFAULT_CONFIG_PATH, servers);
922
+ return { lines: [{ text: `Removed server "${id}". Restart to apply.`, style: 'system' }] };
923
+ }
924
+
925
+ function handleMcpEnv(id: string | undefined, pairs: string[]): CommandResult {
926
+ if (!id || pairs.length === 0) {
927
+ return { lines: [{ text: 'Usage: /mcp env <id> KEY=VALUE [KEY=VALUE ...]', style: 'system' }] };
928
+ }
929
+
930
+ const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
931
+ if (!(id in servers)) {
932
+ return { lines: [{ text: `Server "${id}" not found.`, style: 'system' }] };
933
+ }
934
+
935
+ const entry = servers[id]!;
936
+ if (!entry.env) entry.env = {};
937
+
938
+ const set: string[] = [];
939
+ for (const pair of pairs) {
940
+ const eqIdx = pair.indexOf('=');
941
+ if (eqIdx < 1) {
942
+ return { lines: [{ text: `Invalid env pair: "${pair}". Expected KEY=VALUE.`, style: 'system' }] };
943
+ }
944
+ const key = pair.slice(0, eqIdx);
945
+ const value = pair.slice(eqIdx + 1);
946
+ entry.env[key] = value;
947
+ set.push(key);
948
+ }
949
+
950
+ saveMcplServers(DEFAULT_CONFIG_PATH, servers);
951
+ return { lines: [{ text: `Set ${set.join(', ')} on "${id}". Restart to apply.`, style: 'system' }] };
952
+ }
953
+
954
+ // ---------------------------------------------------------------------------
955
+ // /fleet subcommands
956
+ // ---------------------------------------------------------------------------
957
+
958
+ function getFleet(app: AppContext): FleetModule | null {
959
+ const mod = app.framework.getAllModules().find((m) => m.name === 'fleet');
960
+ return (mod as FleetModule | undefined) ?? null;
961
+ }
962
+
963
+ function handleFleet(app: AppContext, args: string[]): CommandResult {
964
+ const fleet = getFleet(app);
965
+ if (!fleet) {
966
+ return {
967
+ lines: [
968
+ { text: 'Fleet module is not enabled in this recipe.', style: 'system' },
969
+ { text: 'Add `"modules": { "fleet": true }` to your recipe JSON to enable.', style: 'system' },
970
+ ],
971
+ };
972
+ }
973
+
974
+ const sub = args[0];
975
+ switch (sub) {
976
+ case undefined:
977
+ case 'list':
978
+ case 'ls':
979
+ return handleFleetList(fleet);
980
+ case 'status':
981
+ return handleFleetStatus(fleet, args[1]);
982
+ case 'view':
983
+ return { lines: [{ text: 'Opening fleet view. Press Tab to return.', style: 'system' }], switchToFleetView: true };
984
+ case 'peek':
985
+ if (!args[1]) return { lines: [{ text: 'Usage: /fleet peek <name>', style: 'system' }] };
986
+ return handleFleetPeek(fleet, args[1]);
987
+ case 'stop':
988
+ case 'kill':
989
+ if (!args[1]) return { lines: [{ text: 'Usage: /fleet stop <name>', style: 'system' }] };
990
+ return handleFleetKill(fleet, args[1]);
991
+ case 'restart':
992
+ if (!args[1]) return { lines: [{ text: 'Usage: /fleet restart <name>', style: 'system' }] };
993
+ return handleFleetRestart(fleet, args[1]);
994
+ default:
995
+ return {
996
+ lines: [{ text: `Unknown /fleet subcommand: ${sub}. Try /fleet list.`, style: 'system' }],
997
+ };
998
+ }
999
+ }
1000
+
1001
+ function handleFleetList(fleet: FleetModule): CommandResult {
1002
+ const children = [...fleet.getChildren().values()];
1003
+ if (children.length === 0) {
1004
+ return { lines: [{ text: '(no children spawned)', style: 'system' }] };
1005
+ }
1006
+ const lines: Line[] = [{ text: '--- Fleet ---', style: 'system' }];
1007
+ for (const c of children) {
1008
+ lines.push({ text: ` ${formatChildRow(c)}`, style: 'system' });
1009
+ }
1010
+ return { lines };
1011
+ }
1012
+
1013
+ function handleFleetStatus(fleet: FleetModule, name: string | undefined): CommandResult {
1014
+ if (!name) return handleFleetList(fleet);
1015
+ const c = fleet.getChildren().get(name);
1016
+ if (!c) return { lines: [{ text: `Unknown child: ${name}`, style: 'system' }] };
1017
+ const lines: Line[] = [
1018
+ { text: `--- ${c.name} ---`, style: 'system' },
1019
+ { text: ` recipe: ${c.recipePath}`, style: 'system' },
1020
+ { text: ` dataDir: ${c.dataDir}`, style: 'system' },
1021
+ { text: ` socket: ${c.socketPath}`, style: 'system' },
1022
+ { text: ` pid: ${c.pid ?? '-'}`, style: 'system' },
1023
+ { text: ` status: ${c.status}`, style: 'system' },
1024
+ { text: ` startedAt: ${new Date(c.startedAt).toISOString()}`, style: 'system' },
1025
+ { text: ` lastEvent: ${c.lastEventAt ? new Date(c.lastEventAt).toISOString() : '-'}`, style: 'system' },
1026
+ { text: ` events: ${c.events.length}`, style: 'system' },
1027
+ { text: ` subscribe: ${c.subscription.join(', ') || '-'}`, style: 'system' },
1028
+ ];
1029
+ if (c.exitedAt) {
1030
+ lines.push({ text: ` exitedAt: ${new Date(c.exitedAt).toISOString()}`, style: 'system' });
1031
+ lines.push({ text: ` exitCode: ${c.exitCode ?? '-'} (${c.exitReason ?? '-'})`, style: 'system' });
1032
+ }
1033
+ return { lines };
1034
+ }
1035
+
1036
+ function handleFleetPeek(fleet: FleetModule, name: string): CommandResult {
1037
+ const c = fleet.getChildren().get(name);
1038
+ if (!c) return { lines: [{ text: `Unknown child: ${name}`, style: 'system' }] };
1039
+ return {
1040
+ lines: [{ text: `Peeking ${name}. Press Esc to return.`, style: 'system' }],
1041
+ switchToFleetPeek: name,
1042
+ };
1043
+ }
1044
+
1045
+ function handleFleetKill(fleet: FleetModule, name: string): CommandResult {
1046
+ const asyncWork = (async (): Promise<CommandResult> => {
1047
+ const res = await fleet.handleToolCall({ id: `slash-kill-${Date.now()}`, name: 'kill', input: { name } });
1048
+ if (!res.success) {
1049
+ return { lines: [{ text: `kill ${name} failed: ${res.error ?? 'unknown'}`, style: 'system' }] };
1050
+ }
1051
+ const data = res.data as { status?: string; exitCode?: number | null } | undefined;
1052
+ return {
1053
+ lines: [{ text: `kill ${name}: ${data?.status ?? 'done'} (exit ${data?.exitCode ?? '?'})`, style: 'system' }],
1054
+ };
1055
+ })();
1056
+ return {
1057
+ lines: [{ text: `Stopping ${name}...`, style: 'system' }],
1058
+ asyncWork,
1059
+ };
1060
+ }
1061
+
1062
+ function handleFleetRestart(fleet: FleetModule, name: string): CommandResult {
1063
+ const asyncWork = (async (): Promise<CommandResult> => {
1064
+ const res = await fleet.handleToolCall({ id: `slash-restart-${Date.now()}`, name: 'restart', input: { name } });
1065
+ if (!res.success) {
1066
+ return { lines: [{ text: `restart ${name} failed: ${res.error ?? 'unknown'}`, style: 'system' }] };
1067
+ }
1068
+ return { lines: [{ text: `${name} restarted.`, style: 'system' }] };
1069
+ })();
1070
+ return {
1071
+ lines: [{ text: `Restarting ${name}...`, style: 'system' }],
1072
+ asyncWork,
1073
+ };
1074
+ }