@kinqs/brainrouter-cli 0.3.4

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 (87) hide show
  1. package/.env.example +109 -0
  2. package/README.md +185 -0
  3. package/dist/agent/agent.d.ts +765 -0
  4. package/dist/agent/agent.js +1977 -0
  5. package/dist/cli/cliPrompt.d.ts +15 -0
  6. package/dist/cli/cliPrompt.js +62 -0
  7. package/dist/cli/commands/_context.d.ts +53 -0
  8. package/dist/cli/commands/_context.js +14 -0
  9. package/dist/cli/commands/_helpers.d.ts +45 -0
  10. package/dist/cli/commands/_helpers.js +140 -0
  11. package/dist/cli/commands/guard.d.ts +6 -0
  12. package/dist/cli/commands/guard.js +292 -0
  13. package/dist/cli/commands/memory.d.ts +12 -0
  14. package/dist/cli/commands/memory.js +263 -0
  15. package/dist/cli/commands/obs.d.ts +6 -0
  16. package/dist/cli/commands/obs.js +208 -0
  17. package/dist/cli/commands/orchestration.d.ts +6 -0
  18. package/dist/cli/commands/orchestration.js +218 -0
  19. package/dist/cli/commands/session.d.ts +6 -0
  20. package/dist/cli/commands/session.js +191 -0
  21. package/dist/cli/commands/ui.d.ts +6 -0
  22. package/dist/cli/commands/ui.js +477 -0
  23. package/dist/cli/commands/workflow.d.ts +6 -0
  24. package/dist/cli/commands/workflow.js +691 -0
  25. package/dist/cli/repl.d.ts +12 -0
  26. package/dist/cli/repl.js +894 -0
  27. package/dist/config/config.d.ts +22 -0
  28. package/dist/config/config.js +105 -0
  29. package/dist/config/workspace.d.ts +7 -0
  30. package/dist/config/workspace.js +62 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +610 -0
  33. package/dist/memory/briefing.d.ts +46 -0
  34. package/dist/memory/briefing.js +152 -0
  35. package/dist/memory/consolidation.d.ts +60 -0
  36. package/dist/memory/consolidation.js +208 -0
  37. package/dist/memory/formatters.d.ts +38 -0
  38. package/dist/memory/formatters.js +102 -0
  39. package/dist/memory/mentions.d.ts +10 -0
  40. package/dist/memory/mentions.js +72 -0
  41. package/dist/orchestration/orchestrator.d.ts +36 -0
  42. package/dist/orchestration/orchestrator.js +71 -0
  43. package/dist/orchestration/roles.d.ts +11 -0
  44. package/dist/orchestration/roles.js +117 -0
  45. package/dist/orchestration/tools.d.ts +244 -0
  46. package/dist/orchestration/tools.js +528 -0
  47. package/dist/prompt/breadthHint.d.ts +48 -0
  48. package/dist/prompt/breadthHint.js +93 -0
  49. package/dist/prompt/compactor.d.ts +31 -0
  50. package/dist/prompt/compactor.js +112 -0
  51. package/dist/prompt/initAgentMd.d.ts +13 -0
  52. package/dist/prompt/initAgentMd.js +194 -0
  53. package/dist/prompt/skillRunner.d.ts +34 -0
  54. package/dist/prompt/skillRunner.js +146 -0
  55. package/dist/prompt/systemPrompt.d.ts +10 -0
  56. package/dist/prompt/systemPrompt.js +171 -0
  57. package/dist/runtime/clipboard.d.ts +17 -0
  58. package/dist/runtime/clipboard.js +52 -0
  59. package/dist/runtime/llmSemaphore.d.ts +30 -0
  60. package/dist/runtime/llmSemaphore.js +67 -0
  61. package/dist/runtime/loopRunner.d.ts +25 -0
  62. package/dist/runtime/loopRunner.js +79 -0
  63. package/dist/runtime/mcpClient.d.ts +156 -0
  64. package/dist/runtime/mcpClient.js +234 -0
  65. package/dist/runtime/mcpUtils.d.ts +36 -0
  66. package/dist/runtime/mcpUtils.js +64 -0
  67. package/dist/runtime/sandbox.d.ts +48 -0
  68. package/dist/runtime/sandbox.js +156 -0
  69. package/dist/runtime/tracing.d.ts +25 -0
  70. package/dist/runtime/tracing.js +91 -0
  71. package/dist/state/cliState.d.ts +59 -0
  72. package/dist/state/cliState.js +311 -0
  73. package/dist/state/goalStore.d.ts +174 -0
  74. package/dist/state/goalStore.js +410 -0
  75. package/dist/state/hookifyStore.d.ts +80 -0
  76. package/dist/state/hookifyStore.js +237 -0
  77. package/dist/state/hooksStore.d.ts +42 -0
  78. package/dist/state/hooksStore.js +71 -0
  79. package/dist/state/preferencesStore.d.ts +41 -0
  80. package/dist/state/preferencesStore.js +25 -0
  81. package/dist/state/sessionStore.d.ts +42 -0
  82. package/dist/state/sessionStore.js +193 -0
  83. package/dist/state/taskStore.d.ts +23 -0
  84. package/dist/state/taskStore.js +80 -0
  85. package/dist/state/workflowArtifacts.d.ts +33 -0
  86. package/dist/state/workflowArtifacts.js +139 -0
  87. package/package.json +71 -0
@@ -0,0 +1,894 @@
1
+ import readline from 'node:readline';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { exec } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { marked } from 'marked';
9
+ import { markedTerminal } from 'marked-terminal';
10
+ import { expandMentions } from '../memory/mentions.js';
11
+ import { addGoalTokens, buildBudgetSteeringMessage, goalHasBudgetLeft, goalIsOnFinalBudgetTurn, readGoal, tickGoalIteration, usageLimitGoal } from '../state/goalStore.js';
12
+ import { readPreferences } from '../state/preferencesStore.js';
13
+ import { execSync } from 'node:child_process';
14
+ import { listSessions } from '../orchestration/orchestrator.js';
15
+ import { setActiveReadline } from './cliPrompt.js';
16
+ // Category dispatch — extracted slash-command handlers. Each module exports
17
+ // a tryHandleX(ctx) that returns true iff it matched the command. Walked
18
+ // in order; first match wins, no match falls through to the legacy switch.
19
+ import { tryHandleMemoryCommand } from './commands/memory.js';
20
+ import { tryHandleUiCommand } from './commands/ui.js';
21
+ import { tryHandleWorkflowCommand } from './commands/workflow.js';
22
+ import { tryHandleObsCommand } from './commands/obs.js';
23
+ import { tryHandleOrchestrationCommand } from './commands/orchestration.js';
24
+ import { tryHandleSessionCommand } from './commands/session.js';
25
+ import { tryHandleGuardCommand } from './commands/guard.js';
26
+ const execPromise = promisify(exec);
27
+ // Setup marked terminal rendering
28
+ marked.use(markedTerminal({
29
+ showSectionPrefix: false,
30
+ }));
31
+ /**
32
+ * All slash commands the REPL recognizes. Used for tab autocomplete and for
33
+ * the readline completer. Keep alphabetically grouped roughly by surface area.
34
+ */
35
+ const SLASH_COMMANDS = [
36
+ '/help', '/status', '/workspace', '/tools', '/skills', '/plan', '/transcript',
37
+ '/doctor', '/config', '/diff', '/commit', '/clear', '/compact', '/exit', '/quit',
38
+ '/roles', '/agents', '/agent', '/spawn', '/wait',
39
+ '/spec', '/feature-dev', '/review', '/implement-plan', '/skill', '/workflows', '/approve',
40
+ '/memory', '/recall', '/briefing', '/scenes', '/working', '/forget',
41
+ '/init', '/sessions', '/resume', '/model', '/mcp',
42
+ '/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop',
43
+ '/continue', '/auto-review', '/vim', '/statusline',
44
+ '/handover', '/explain', '/trace', '/failed', '/verify', '/audit',
45
+ '/export', '/import', '/persona', '/skill-hints', '/diagnostics',
46
+ '/tokens', '/watch', '/yolo', '/sandbox', '/kill',
47
+ // workflow & ergonomics commands
48
+ '/theme', '/title', '/personality', '/new', '/side', '/btw', '/raw',
49
+ '/feedback', '/rollout', '/ps', '/stop', '/logout', '/apps', '/plugins',
50
+ '/experimental', '/memories', '/debug-config', '/mention', '/keymap', '/ide',
51
+ ];
52
+ export function startREPL(agent, mcpClient, config, workspace) {
53
+ console.log(chalk.bold.hex('#CC9166')('\n🧠 BRAINROUTER TERMINAL AGENT CLIENT v0.3.4'));
54
+ console.log(chalk.gray('Midnight Ledger / Obsidian Surface theme active.'));
55
+ console.log(chalk.gray(`Workspace root: ${workspace?.workspaceRoot || process.cwd()}`));
56
+ // Surface offline mode prominently — easy to miss the warning that scrolled
57
+ // by during startup, and the user needs to know memory tools won't fire.
58
+ if (!mcpClient.isConnected()) {
59
+ console.log(chalk.yellow('⚠️ OFFLINE MODE — MCP server unreachable. Local tools only; memory recall / skills disabled.'));
60
+ }
61
+ console.log(chalk.gray('Type ') + chalk.cyan('/help') + chalk.gray(' for commands, or start typing your prompt.\n'));
62
+ const rl = readline.createInterface({
63
+ input: process.stdin,
64
+ output: process.stdout,
65
+ prompt: chalk.hex('#CC9166')('brainrouter> '),
66
+ // Tab-completion: complete slash commands when the line begins with "/"
67
+ // and complete workspace file paths when the user is mid-`@mention`.
68
+ completer: (line) => {
69
+ const atMatch = line.match(/@([^\s]*)$/);
70
+ if (atMatch) {
71
+ const partial = atMatch[1];
72
+ const candidates = completeWorkspacePath(agent.workspaceRoot, partial);
73
+ return [candidates.map((c) => `@${c}`), `@${partial}`];
74
+ }
75
+ if (line.startsWith('/')) {
76
+ const hits = SLASH_COMMANDS.filter((cmd) => cmd.startsWith(line));
77
+ return [hits.length ? hits : SLASH_COMMANDS.slice(), line];
78
+ }
79
+ return [[], line];
80
+ },
81
+ });
82
+ // GitHub PR detection cache. `gh pr view` takes ~300ms and prompts often
83
+ // refresh many times per turn; cache the result for 30s. Returns either
84
+ // a string like "#42" or null when there's no PR / gh not installed.
85
+ let prCache = null;
86
+ const PR_CACHE_TTL_MS = 30_000;
87
+ const detectGitHubPR = (cwd) => {
88
+ const now = Date.now();
89
+ if (prCache && now - prCache.cachedAt < PR_CACHE_TTL_MS)
90
+ return prCache.value;
91
+ let value = null;
92
+ try {
93
+ const out = execSync('gh pr view --json number,title 2>/dev/null', {
94
+ cwd,
95
+ stdio: ['ignore', 'pipe', 'ignore'],
96
+ timeout: 1500,
97
+ }).toString().trim();
98
+ if (out) {
99
+ const parsed = JSON.parse(out);
100
+ if (typeof parsed.number === 'number')
101
+ value = `#${parsed.number}`;
102
+ }
103
+ }
104
+ catch {
105
+ // gh missing, not a PR branch, or not a github repo — fine.
106
+ }
107
+ prCache = { value, cachedAt: now };
108
+ return value;
109
+ };
110
+ // Reflect the current access mode and any configured statusline segments
111
+ // in the prompt. Configurable via /statusline; default just shows the mode.
112
+ const renderStatusline = () => {
113
+ const prefs = readPreferences(agent.workspaceRoot);
114
+ const segments = prefs.statusline.split(',').map((s) => s.trim()).filter(Boolean);
115
+ const out = [];
116
+ for (const seg of segments) {
117
+ if (seg === 'mode')
118
+ out.push(agent.getAccessMode());
119
+ else if (seg === 'model')
120
+ out.push(agent.getModel());
121
+ else if (seg === 'tokens') {
122
+ const u = agent.lastTurnUsage;
123
+ if (u.calls > 0)
124
+ out.push(`${u.promptTokens}↑${u.completionTokens}↓`);
125
+ }
126
+ else if (seg === 'session') {
127
+ const k = agent.sessionKey;
128
+ out.push(k.length > 22 ? `${k.slice(0, 22)}…` : k);
129
+ }
130
+ else if (seg === 'branch' || seg === 'dirty') {
131
+ try {
132
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: agent.workspaceRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
133
+ const dirty = execSync('git status --porcelain', { cwd: agent.workspaceRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== '';
134
+ if (seg === 'branch')
135
+ out.push(branch);
136
+ else if (seg === 'dirty' && dirty)
137
+ out.push('*');
138
+ }
139
+ catch { /* not a git repo */ }
140
+ }
141
+ else if (seg === 'pr') {
142
+ // Detect open GitHub PR for the current branch — 30s cache so the
143
+ // prompt refresh is cheap (re-shells out only on a stale window).
144
+ const pr = detectGitHubPR(agent.workspaceRoot);
145
+ if (pr)
146
+ out.push(pr);
147
+ }
148
+ }
149
+ return out.filter(Boolean).join(' · ');
150
+ };
151
+ const refreshPromptForMode = () => {
152
+ const mode = agent.getAccessMode();
153
+ const accent = mode === 'shell' ? chalk.red : mode === 'write' ? chalk.hex('#CC9166') : chalk.green;
154
+ const line = renderStatusline();
155
+ rl.setPrompt(accent(`brainrouter[${line}]> `));
156
+ // The terminal title shares the same trigger conditions as the prompt:
157
+ // any time the prompt redraws (mode change, post-turn, post-spawn), the
158
+ // awaiting-input count or active model may have shifted. Cheap call.
159
+ refreshTerminalTitle();
160
+ };
161
+ // When a /goal is active, after each turn we schedule the NEXT turn
162
+ // automatically. The flag tracks whether such a continuation is pending so
163
+ // user input (next line typed) cancels it before it fires. Declared early
164
+ // so refreshTerminalTitle() (below) can read it for the awaiting-count.
165
+ let pendingContinuation = false;
166
+ // Returns the number of child agents currently in `pending` or `running`
167
+ // status — used by the tab title to surface "needs attention" counts.
168
+ const getRunningChildCount = () => {
169
+ try {
170
+ const sessions = listSessions(agent.workspaceRoot);
171
+ return sessions.filter((s) => s.status === 'pending' || s.status === 'running').length;
172
+ }
173
+ catch {
174
+ return 0;
175
+ }
176
+ };
177
+ // Dynamic terminal tab title. Refreshed at startup AND whenever the agent
178
+ // count / awaiting state changes (after each turn, post-spawn). Prefixes
179
+ // a "(N) " count when there's work requiring attention — pending
180
+ // continuation OR a running child — so background tabs surface attention
181
+ // without focus.
182
+ const refreshTerminalTitle = () => {
183
+ try {
184
+ const prefs = readPreferences(agent.workspaceRoot);
185
+ const cfg = prefs.terminalTitle ?? 'model,session';
186
+ if (cfg.toLowerCase() === 'off')
187
+ return;
188
+ const segs = cfg.split(',').map((s) => s.trim()).filter(Boolean);
189
+ const parts = [];
190
+ for (const seg of segs) {
191
+ if (seg === 'model')
192
+ parts.push(agent.getModel());
193
+ else if (seg === 'session')
194
+ parts.push(agent.sessionKey.slice(0, 24));
195
+ else if (seg === 'mode')
196
+ parts.push(agent.getAccessMode());
197
+ else if (seg === 'branch') {
198
+ try {
199
+ parts.push(execSync('git rev-parse --abbrev-ref HEAD', {
200
+ cwd: agent.workspaceRoot,
201
+ stdio: ['ignore', 'pipe', 'ignore'],
202
+ }).toString().trim());
203
+ }
204
+ catch { /* not a git repo */ }
205
+ }
206
+ }
207
+ if (parts.length === 0)
208
+ return;
209
+ // Awaiting-input prefix: pendingContinuation OR any running children.
210
+ const awaitingCount = (pendingContinuation ? 1 : 0) + getRunningChildCount();
211
+ const prefix = awaitingCount > 0 ? `(${awaitingCount}) ` : '';
212
+ process.stdout.write(`\x1b]0;${prefix}brainrouter · ${parts.join(' · ')}\x07`);
213
+ }
214
+ catch { /* terminal doesn't support OSC titles */ }
215
+ };
216
+ refreshPromptForMode();
217
+ refreshTerminalTitle();
218
+ // Vim mode: readline supports editorMode 'vi' via setRawMode + tty.
219
+ // We honor the persisted preference at startup so users don't have to
220
+ // re-toggle it each session.
221
+ const initialPrefs = readPreferences(agent.workspaceRoot);
222
+ if (initialPrefs.editorMode === 'vi') {
223
+ process.stdout.write(chalk.gray('Vim mode enabled (composer uses vi keybindings). Toggle with /vim.\n'));
224
+ // Node's readline doesn't natively expose vi mode; we approximate by
225
+ // emitting a hint and trusting the user's terminal/inputrc. A future
226
+ // pass can swap in a custom keypress handler for full Vim semantics.
227
+ }
228
+ // Shift+Tab cycles the access mode.
229
+ // Order: read → write → shell → read …
230
+ if (process.stdin.isTTY) {
231
+ try {
232
+ process.stdin.setRawMode?.(false);
233
+ }
234
+ catch { /* noop */ }
235
+ }
236
+ process.stdin.on('keypress', (_str, key) => {
237
+ if (key && key.name === 'tab' && key.shift) {
238
+ const cycle = ['read', 'write', 'shell'];
239
+ const current = agent.getAccessMode();
240
+ const next = cycle[(cycle.indexOf(current) + 1) % cycle.length];
241
+ agent.setAccessMode(next);
242
+ refreshPromptForMode();
243
+ process.stdout.write(`\n${chalk.gray(`Access mode → ${next}`)}\n`);
244
+ rl.prompt();
245
+ }
246
+ });
247
+ // Publish the rl interface to cliPrompt.ts so out-of-scope helpers
248
+ // (askYesNo, safePrintAbovePrompt) can talk to the same stdin/stdout
249
+ // pair the REPL owns. Cleared on close.
250
+ setActiveReadline(rl);
251
+ rl.on('close', () => { setActiveReadline(undefined); });
252
+ rl.prompt();
253
+ let isProcessing = false;
254
+ // (pendingContinuation declared earlier alongside the title refresh helpers.)
255
+ /**
256
+ * Prompt the agent receives for iterations 2..N of an active goal —
257
+ * fired automatically by the post-turn loop after each completed turn.
258
+ * Orients the model around the active objective, forces an evidence
259
+ * audit, and refuses prose-only "I will continue" answers.
260
+ *
261
+ * Distinct from `buildGoalKickoffPrompt` (in `commands/_helpers.ts`),
262
+ * which is the FIRST-turn prompt fired by `/goal <text>` and `/goal resume`.
263
+ */
264
+ const buildGoalContinuationPrompt = (goal, lastPrompt, lastAnswer) => {
265
+ const iter = goal.budget.iterationsUsed + 1;
266
+ const remaining = Math.max(0, goal.budget.maxIterations - iter);
267
+ return [
268
+ `[GOAL CONTINUATION — iteration ${iter}/${goal.budget.maxIterations}, ${remaining} remaining]`,
269
+ '',
270
+ `Your active goal is: ${goal.text}`,
271
+ '',
272
+ `Last user message: ${lastPrompt || '(none)'}`,
273
+ `Your previous response (truncated): ${lastAnswer.slice(0, 600)}${lastAnswer.length > 600 ? '…' : ''}`,
274
+ '',
275
+ '## What to do this turn',
276
+ '1. **Audit the evidence in this thread** against the goal\'s outcome. Look at files you wrote, tests you ran, tools that returned ok=true.',
277
+ '2. **Decide one of three:**',
278
+ ' - If the outcome is met with concrete evidence (file paths, test names, command outputs), **write the user-visible answer / analysis / summary as prose AND THEN call `goal_complete` with a short 1–2 sentence proof — in the SAME response.** The proof is audit metadata; the prose is what the user reads. Skipping the prose means the user sees a placeholder.',
279
+ ' - If no defensible path forward remains without user input or missing materials, **write the user-visible explanation as prose AND THEN call `goal_blocked` with a reason + needed input.**',
280
+ ' - Otherwise (mid-goal), take the **next concrete tool action** (read a file, write code, spawn a worker child, run a verifier). Do NOT respond with prose like "I will now do X" — that\'s a no-op and the CLI will stop the continuation. Anti-spin applies to mid-goal turns; the final goal-completing turn requires prose.',
281
+ '3. Use update_plan to track progress if you haven\'t already.',
282
+ '',
283
+ 'Reminder: budget is finite. Pick the highest-leverage action that moves the goal forward.',
284
+ ].join('\n');
285
+ };
286
+ /**
287
+ * Print a line of output while the readline prompt is showing without
288
+ * clobbering whatever the user is mid-typing. Used by child-agent callbacks
289
+ * that fire AFTER the parent's runTurn returned — the agent's tool events
290
+ * keep streaming for a while because children run detached, and naive
291
+ * console.log + spinner.start() would steal the input row.
292
+ */
293
+ const safePrintAbovePrompt = (msg) => {
294
+ if (!process.stdout.isTTY) {
295
+ console.log(msg);
296
+ return;
297
+ }
298
+ // \r → column 0, \x1b[2K → clear the whole line, including any prompt + typed text.
299
+ process.stdout.write('\r\x1b[2K');
300
+ console.log(msg);
301
+ // Redraw the prompt and re-render the in-progress input buffer.
302
+ try {
303
+ rl._refreshLine?.();
304
+ }
305
+ catch {
306
+ rl.prompt(true);
307
+ }
308
+ };
309
+ /** Run a turn programmatically (used by `/continue` and the line handler). */
310
+ const runAgentTurn = async (rawInput) => {
311
+ if (isProcessing) {
312
+ console.log(chalk.yellow('\nA previous turn is still running.\n'));
313
+ return;
314
+ }
315
+ isProcessing = true;
316
+ rl.pause();
317
+ const { expanded, mentions } = expandMentions(rawInput, agent.workspaceRoot);
318
+ if (mentions.length > 0) {
319
+ console.log(chalk.gray(`📎 Attached ${mentions.length} file${mentions.length === 1 ? '' : 's'}: ${mentions.map((m) => m.token).join(', ')}`));
320
+ }
321
+ const startedAt = Date.now();
322
+ const spinner = ora(chalk.gray('Agent starting...')).start();
323
+ // Once the parent's runTurn returns, child agents may still emit tool
324
+ // events asynchronously. After this flag flips, we MUST NOT touch the
325
+ // spinner (which is already .succeeded) — restarting it would steal the
326
+ // readline row and the user would feel like they can't type.
327
+ let parentDone = false;
328
+ const tickStatus = (status) => {
329
+ if (parentDone)
330
+ return;
331
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
332
+ const u = agent.lastTurnUsage;
333
+ const tokens = u.calls > 0 ? ` ${u.promptTokens.toLocaleString()}↑ ${u.completionTokens.toLocaleString()}↓` : '';
334
+ spinner.text = chalk.gray(`${status} ${elapsed}s${tokens}`);
335
+ };
336
+ try {
337
+ const answer = await agent.runTurn(expanded, {
338
+ onStatusUpdate: tickStatus,
339
+ onToolStart: (name, args) => {
340
+ // Render spawn_agent / spawn_agents specially — a one-liner
341
+ // ("Ran agent <role> — <one-line task>") so a fan-out of 5
342
+ // children produces 5 clean lines instead of 5 JSON dumps. The
343
+ // raw JSON is still in the transcript for debugging.
344
+ let line;
345
+ if (name === 'spawn_agent') {
346
+ const role = chalk.magenta(String(args?.role ?? 'agent'));
347
+ const label = args?.label ? chalk.gray(` [${args.label}]`) : '';
348
+ const task = String(args?.prompt ?? '').replace(/\s+/g, ' ').trim();
349
+ const preview = chalk.gray(task.length > 100 ? task.slice(0, 99) + '…' : task);
350
+ line = chalk.gray('🤖 Spawning agent: ') + role + label + chalk.gray(' — ') + preview;
351
+ }
352
+ else if (name === 'spawn_agents') {
353
+ const agents = Array.isArray(args?.agents) ? args.agents : [];
354
+ const summary = agents
355
+ .map((a) => chalk.magenta(String(a?.role ?? 'agent')))
356
+ .join(chalk.gray(', '));
357
+ line = chalk.gray(`🤖 Spawning ${agents.length} agent${agents.length === 1 ? '' : 's'} in parallel: `) + summary;
358
+ }
359
+ else {
360
+ line = chalk.gray('🛞 Calling tool: ') + chalk.cyan(name) + chalk.gray(`(${JSON.stringify(args).slice(0, 240)})`);
361
+ }
362
+ if (parentDone) {
363
+ safePrintAbovePrompt(line);
364
+ return;
365
+ }
366
+ spinner.stop();
367
+ console.log(line);
368
+ },
369
+ onToolEnd: (name, result) => {
370
+ const line = result.success
371
+ ? chalk.green('✓ Tool ') + chalk.cyan(name) + chalk.green(' completed: ') + chalk.gray(result.summary)
372
+ : chalk.red('❌ Tool ') + chalk.cyan(name) + chalk.red(' failed: ') + chalk.yellow(result.summary);
373
+ // Inspection-tool preview: indented under the summary so the user
374
+ // sees the actual result (directory listing, grep matches, glob
375
+ // paths) even when the LLM later replies with only a stub like
376
+ // "I have listed the directory." Capped to a handful of lines in
377
+ // getToolPreview itself.
378
+ const previewBlock = result.preview
379
+ ? '\n' + result.preview.split('\n').map((l) => chalk.gray(' ' + l)).join('\n')
380
+ : '';
381
+ const composed = line + previewBlock;
382
+ if (parentDone) {
383
+ safePrintAbovePrompt(composed);
384
+ return;
385
+ }
386
+ console.log(composed);
387
+ tickStatus('Thinking');
388
+ spinner.start();
389
+ },
390
+ onPlanUpdate: (items, explanation) => {
391
+ if (parentDone) {
392
+ safePrintAbovePrompt(chalk.gray(`📋 Plan updated (${items.length} item${items.length === 1 ? '' : 's'})`));
393
+ return;
394
+ }
395
+ spinner.stop();
396
+ console.log(chalk.gray('📋 Plan updated:'));
397
+ if (explanation)
398
+ console.log(chalk.gray(` ${explanation}`));
399
+ for (const item of items) {
400
+ const mark = item.status === 'completed' ? chalk.green('✓')
401
+ : item.status === 'in_progress' ? chalk.yellow('⏳')
402
+ : chalk.gray('☐');
403
+ const text = item.status === 'completed' ? chalk.gray(item.step) : item.step;
404
+ console.log(` ${mark} ${text}`);
405
+ }
406
+ tickStatus('Thinking');
407
+ spinner.start();
408
+ },
409
+ onChildComplete: (event) => {
410
+ const head = event.status === 'completed'
411
+ ? chalk.green(`🏁 Agent ${event.childId} (${event.role}) completed`)
412
+ : chalk.red(`💥 Agent ${event.childId} (${event.role}) failed`);
413
+ const tail = event.status === 'completed' && event.preview
414
+ ? chalk.gray(` — ${event.preview}`)
415
+ : event.error ? chalk.yellow(` — ${event.error}`) : '';
416
+ const line = head + tail;
417
+ if (parentDone) {
418
+ safePrintAbovePrompt(line);
419
+ return;
420
+ }
421
+ spinner.stop();
422
+ console.log(line);
423
+ tickStatus('Thinking');
424
+ spinner.start();
425
+ },
426
+ onMemoryEvent: (event) => {
427
+ let line;
428
+ if (event.kind === 'briefing') {
429
+ const src = event.sources.length > 0 ? event.sources.join(', ') : '(none)';
430
+ line = chalk.gray(`🧠 Briefing: ${event.recordCount} record${event.recordCount === 1 ? '' : 's'} from ${src}`);
431
+ }
432
+ else if (event.kind === 'capture') {
433
+ // Truthful capture line: show sensory rows actually written, and
434
+ // — critically — flag when extraction silently failed. The old
435
+ // line said "Captured turn → memory" even when 0 cognitive
436
+ // records came out the other end, which made the user think
437
+ // their conversation was searchable when nothing of the sort
438
+ // was happening.
439
+ const sensory = event.sensoryRecorded ?? event.messageCount;
440
+ const extracted = event.extractedCount;
441
+ const triggered = event.extractionTriggered;
442
+ const sk = event.sessionKey.slice(0, 12);
443
+ if (event.extractionWarning) {
444
+ line = chalk.yellow(`💾 Captured ${sensory} sensory msg(s) in ${sk}… — ⚠️ ${event.extractionWarning}`);
445
+ }
446
+ else if (triggered && typeof extracted === 'number') {
447
+ if (extracted > 0) {
448
+ line = chalk.gray(`💾 Captured ${sensory} msg(s) → ${extracted} cognitive record(s) extracted (${sk}…)`);
449
+ }
450
+ else {
451
+ // LLM ran successfully but found nothing notable to promote
452
+ // (greeting, trivial exchange, all-duplicates). NOT an error.
453
+ line = chalk.gray(`💾 Captured ${sensory} msg(s) → no new memories worth promoting (${sk}…)`);
454
+ }
455
+ }
456
+ else if (triggered === false) {
457
+ // Sensory landed; extractor below the every-N-turn threshold.
458
+ line = chalk.gray(`💾 Captured ${sensory} msg(s) → sensory buffer (${sk}…)`);
459
+ }
460
+ else {
461
+ line = chalk.gray(`💾 Captured ${sensory} msg(s) → memory (${sk}…)`);
462
+ }
463
+ }
464
+ else if (event.kind === 'citation' && event.recordIds.length > 0) {
465
+ line = chalk.gray(`📌 Reinforced ${event.recordIds.length} record${event.recordIds.length === 1 ? '' : 's'}: ${event.recordIds.slice(0, 3).join(', ')}${event.recordIds.length > 3 ? '…' : ''}`);
466
+ }
467
+ else if (event.kind === 'contradiction') {
468
+ line = chalk.yellow(`⚠️ Memory contradiction: ${event.warning.slice(0, 140)}`);
469
+ }
470
+ if (!line)
471
+ return;
472
+ if (parentDone) {
473
+ safePrintAbovePrompt(line);
474
+ return;
475
+ }
476
+ spinner.stop();
477
+ console.log(line);
478
+ tickStatus('Thinking');
479
+ spinner.start();
480
+ },
481
+ });
482
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
483
+ const u = agent.lastTurnUsage;
484
+ const tokenSummary = u.calls > 0
485
+ ? chalk.gray(` · ${u.promptTokens.toLocaleString()} in / ${u.completionTokens.toLocaleString()} out across ${u.calls} call${u.calls === 1 ? '' : 's'}`)
486
+ : '';
487
+ parentDone = true;
488
+ spinner.succeed(chalk.green(`Done!${chalk.gray(` ${elapsed}s`)}${tokenSummary}`));
489
+ const prefsForRender = readPreferences(agent.workspaceRoot);
490
+ const rendered = prefsForRender.rawScrollback ? answer : marked.parse(answer);
491
+ console.log('\n' + rendered + '\n');
492
+ const warning = agent.takeContradictionWarning();
493
+ if (warning) {
494
+ console.log(chalk.yellow(`⚠️ Memory: ${warning}`));
495
+ console.log(chalk.gray(` Use /memory or /briefing to investigate, /forget <id> to archive obsolete records.\n`));
496
+ }
497
+ }
498
+ catch (err) {
499
+ parentDone = true;
500
+ spinner.fail(chalk.red('Execution failed'));
501
+ console.error(chalk.red(`\nError: ${err.message}\n`));
502
+ }
503
+ finally {
504
+ isProcessing = false;
505
+ // Clear any active skill latched by /skill / /feature-dev / /spec /
506
+ // /review / /implement-plan so subsequent plain prompts don't keep
507
+ // spiking the same skill. The skill memetic potential still decays
508
+ // server-side on its own half-life; this just stops attribution.
509
+ agent.activeSkill = undefined;
510
+ // Auto-continuation logic. Rules:
511
+ // - the goal must be active (not paused / complete / blocked / usage_limited)
512
+ // - the turn made at least one tool call (prose-only turns are anti-spin)
513
+ // - we still have iteration AND token budget left
514
+ // - the agent didn't call goal_complete / goal_blocked this turn
515
+ //
516
+ // BEFORE checking, accumulate this turn's tokens into the goal's
517
+ // running tally. If that tips us over a token cap, transition to
518
+ // `usage_limited` instead of continuing — same effect as exhausting
519
+ // the iteration cap, but distinguishable in status.
520
+ let goalAfter = readGoal(agent.workspaceRoot, agent.sessionKey);
521
+ if (goalAfter && goalAfter.budget.maxTokens) {
522
+ const delta = (agent.lastTurnUsage?.promptTokens ?? 0) + (agent.lastTurnUsage?.completionTokens ?? 0);
523
+ if (delta > 0) {
524
+ const updated = addGoalTokens(agent.workspaceRoot, agent.sessionKey, delta);
525
+ if (updated)
526
+ goalAfter = updated;
527
+ }
528
+ if (goalAfter &&
529
+ goalAfter.status === 'active' &&
530
+ typeof goalAfter.budget.maxTokens === 'number' &&
531
+ (goalAfter.budget.tokensUsed ?? 0) >= goalAfter.budget.maxTokens) {
532
+ const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, `Token budget reached: ${(goalAfter.budget.tokensUsed ?? 0).toLocaleString()} of ${goalAfter.budget.maxTokens.toLocaleString()} used.`);
533
+ if (limited)
534
+ goalAfter = limited;
535
+ }
536
+ }
537
+ const shouldContinue = !!goalAfter &&
538
+ goalAfter.status === 'active' &&
539
+ goalHasBudgetLeft(goalAfter) &&
540
+ agent.lastTurnToolCalls > 0 &&
541
+ agent.lastGoalTransition === undefined;
542
+ if (goalAfter && goalAfter.status === 'complete') {
543
+ console.log(chalk.green(`\n🎯 Goal achieved — ${goalAfter.blockedReason ?? 'evidence on record.'}\n`));
544
+ }
545
+ else if (goalAfter && goalAfter.status === 'blocked') {
546
+ console.log(chalk.yellow(`\n🚧 Goal blocked: ${goalAfter.blockedReason ?? '(no reason)'}\n`));
547
+ console.log(chalk.gray(` Resolve the blocker, then /goal resume to continue.\n`));
548
+ }
549
+ else if (goalAfter && goalAfter.status === 'usage_limited') {
550
+ console.log(chalk.yellow(`\n⏸ Goal hit usage limit: ${goalAfter.blockedReason ?? 'budget exhausted'}.`));
551
+ console.log(chalk.gray(` Raise the cap with /goal budget <n> or /goal tokens <n>, then /goal resume.\n`));
552
+ }
553
+ else if (goalAfter && goalAfter.status === 'active' && !goalHasBudgetLeft(goalAfter)) {
554
+ // Iteration cap reached — transition to usage_limited so the user
555
+ // gets a consistent resumable state regardless of which cap tripped.
556
+ const reason = `Iteration budget exhausted (${goalAfter.budget.iterationsUsed}/${goalAfter.budget.maxIterations}).`;
557
+ const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, reason);
558
+ console.log(chalk.yellow(`\n⏸ ${reason} Extend with /goal budget <n> and /goal resume, mark /goal complete, or /goal clear.\n`));
559
+ if (limited)
560
+ goalAfter = limited;
561
+ }
562
+ else if (goalAfter && goalAfter.status === 'active' && agent.lastTurnToolCalls === 0) {
563
+ console.log(chalk.gray(`(goal continuation suppressed: last turn made no tool calls — anti-spin)\n`));
564
+ }
565
+ rl.resume();
566
+ refreshPromptForMode(); // pick up token-meter / branch updates
567
+ rl.prompt();
568
+ if (shouldContinue && goalAfter) {
569
+ pendingContinuation = true;
570
+ const next = goalAfter.budget.iterationsUsed + 1;
571
+ // Pre-tick steering: if the NEXT turn would be the final one inside
572
+ // the budget, inject a wrap-up directive so the model lands soft
573
+ // instead of being cut off mid-thought.
574
+ //
575
+ // CRITICAL: also drop any stale steering when the next turn is NOT
576
+ // final. Without this, a previously-injected "wrap up gracefully"
577
+ // message would persist after the user extended the budget via
578
+ // /goal budget or /goal tokens, telling the model "this is your
579
+ // last turn" for every subsequent turn. The removal is idempotent
580
+ // — if no steering was set, this is a no-op.
581
+ const finalBudgetTurn = goalIsOnFinalBudgetTurn(goalAfter);
582
+ if (finalBudgetTurn) {
583
+ agent.replaceTaggedSystemMessage('goal-budget-steering', buildBudgetSteeringMessage(goalAfter));
584
+ console.log(chalk.gray(`(final budget turn — wrap-up steering injected)`));
585
+ }
586
+ else {
587
+ agent.removeTaggedSystemMessage('goal-budget-steering');
588
+ }
589
+ console.log(chalk.gray(`(goal continuation queued — iteration ${next}/${goalAfter.budget.maxIterations}; type anything to cancel)`));
590
+ const followUp = buildGoalContinuationPrompt(goalAfter, agent.lastUserPrompt, agent.lastAnswer);
591
+ setImmediate(() => {
592
+ if (!pendingContinuation || isProcessing)
593
+ return; // user cancelled or busy
594
+ pendingContinuation = false;
595
+ tickGoalIteration(agent.workspaceRoot, agent.sessionKey);
596
+ void runAgentTurn(followUp);
597
+ });
598
+ }
599
+ }
600
+ };
601
+ rl.on('line', async (line) => {
602
+ // User typed: any pending goal continuation is cancelled.
603
+ if (pendingContinuation) {
604
+ pendingContinuation = false;
605
+ console.log(chalk.gray('(goal continuation cancelled by user input)'));
606
+ }
607
+ const input = line.trim();
608
+ if (!input) {
609
+ rl.prompt();
610
+ return;
611
+ }
612
+ if (input.startsWith('/')) {
613
+ // Split on any whitespace, not a literal space. Without this, a slash
614
+ // command followed by a tab (autocomplete completion that wasn't
615
+ // consumed) or a trailing newline ends up as command="/help\t" which
616
+ // would fall through to "Unknown slash command".
617
+ const parts = input.trim().split(/\s+/);
618
+ const command = parts[0].toLowerCase();
619
+ const args = parts.slice(1);
620
+ // Wrap the slash-command dispatcher so a thrown error or rejected
621
+ // promise can never leave the REPL without a prompt. Without this, a
622
+ // bug inside any /command (file write, MCP call, etc.) bricks the
623
+ // session because the user never sees the prompt come back.
624
+ try {
625
+ await handleSlashCommand(command, args, agent, mcpClient, config, rl, {
626
+ refreshPromptForMode,
627
+ isProcessing: () => isProcessing,
628
+ runAgentTurn: (prompt) => { void runAgentTurn(prompt); },
629
+ runAgentTurnAsync: (prompt) => runAgentTurn(prompt),
630
+ });
631
+ }
632
+ catch (err) {
633
+ console.error(chalk.red(`\nSlash command "${command}" failed: ${err?.message ?? err}\n`));
634
+ }
635
+ finally {
636
+ // The /continue and /side/btw cases own their own prompt cycle via
637
+ // runAgentTurn — only re-prompt if no turn is in flight.
638
+ if (!isProcessing)
639
+ rl.prompt();
640
+ }
641
+ return;
642
+ }
643
+ if (isProcessing) {
644
+ console.log(chalk.yellow('\nA previous turn is still running. Wait for the prompt before sending another message.\n'));
645
+ rl.prompt();
646
+ return;
647
+ }
648
+ await runAgentTurn(input);
649
+ });
650
+ rl.on('SIGINT', async () => {
651
+ console.log(chalk.yellow('\nExiting session...'));
652
+ rl.close();
653
+ });
654
+ rl.on('close', async () => {
655
+ await mcpClient.close();
656
+ console.log(chalk.bold.hex('#CC9166')('Goodbye!\n'));
657
+ process.exit(0);
658
+ });
659
+ }
660
+ const HELP_CATEGORIES = [
661
+ {
662
+ key: 'session',
663
+ title: 'Session & State',
664
+ entries: [
665
+ { cmd: '/status', desc: 'Connection status, LLM config, DB stats' },
666
+ { cmd: '/workspace', desc: 'Active workspace and session identity' },
667
+ { cmd: '/doctor', desc: 'Config, connection, memory extraction health' },
668
+ { cmd: '/config', desc: 'View active configuration profile' },
669
+ { cmd: '/clear', desc: 'Clear chat history for the active session' },
670
+ { cmd: '/compact', desc: 'LLM-driven compaction of the active session' },
671
+ { cmd: '/new [label]', desc: 'Start a new chat with a fresh session key' },
672
+ { cmd: '/fork [label]', desc: 'Fork this chat into a new session, keep prior context' },
673
+ { cmd: '/rename <label>', desc: 'Rename the current session' },
674
+ { cmd: '/resume <id>', desc: 'Resume a previous session by sessionKey' },
675
+ { cmd: '/sessions', desc: 'List persisted sessions for this workspace' },
676
+ { cmd: '/side <q> /btw <q>', desc: 'Ephemeral side conversation in a forked session' },
677
+ { cmd: '/init', desc: 'Create AGENT.md in the workspace' },
678
+ { cmd: '/exit /quit', desc: 'Close MCP connection and exit' },
679
+ ],
680
+ },
681
+ {
682
+ key: 'memory',
683
+ title: 'Memory & Recall',
684
+ entries: [
685
+ { cmd: '/memory <query>', desc: 'Search long-term memory (memory_search)' },
686
+ { cmd: '/recall <query>', desc: 'Explicit cognitive recall (no LLM turn)' },
687
+ { cmd: '/briefing', desc: 'Show what was recalled before the most recent turn' },
688
+ { cmd: '/scenes', desc: 'List active focus scenes' },
689
+ { cmd: '/working', desc: 'Show the working-memory canvas' },
690
+ { cmd: '/working reset confirm', desc: 'Clear the canvas' },
691
+ { cmd: '/forget <recordId>', desc: 'Archive a memory record by ID' },
692
+ { cmd: '/memories', desc: 'Manage memory pipeline + consolidate to filesystem' },
693
+ { cmd: '/handover', desc: 'Generate continuation note for next session' },
694
+ { cmd: '/explain <query>', desc: 'Why recall returned what it did' },
695
+ { cmd: '/failed [area]', desc: 'Past failed attempts for a problem area' },
696
+ { cmd: '/verify <id> [status]', desc: 'Re-verify a memory record' },
697
+ { cmd: '/audit', desc: 'Recent memory audit log' },
698
+ { cmd: '/export [path]', desc: 'Dump memory + evidence + ops to JSON' },
699
+ { cmd: '/import <path>', desc: 'Import a BrainRouter memory envelope' },
700
+ { cmd: '/persona <name>', desc: 'Fetch a persona definition' },
701
+ { cmd: '/skill-hints <skill> <hints>', desc: 'Register extraction hints' },
702
+ { cmd: '/diagnostics', desc: 'Scrubbed runtime + DB stats bundle' },
703
+ ],
704
+ },
705
+ {
706
+ key: 'workflow',
707
+ title: 'Workflows & Skills',
708
+ entries: [
709
+ { cmd: '/spec <title>', desc: 'Produce spec.md (spec-driven-skill)' },
710
+ { cmd: '/feature-dev <feat>', desc: 'Multi-agent feature dev with spec + tasks' },
711
+ { cmd: '/review [scope]', desc: 'Multi-agent code review → review.md' },
712
+ { cmd: '/implement-plan', desc: 'Execute next plan item; append walkthrough' },
713
+ { cmd: '/approve [slug]', desc: 'Approve workflow + kick off implementation' },
714
+ { cmd: '/workflows', desc: 'List durable workflow folders' },
715
+ { cmd: '/skill <name> [input]', desc: 'Run any catalogued skill' },
716
+ { cmd: '/skills', desc: 'List installed BrainRouter skills' },
717
+ { cmd: '/plan', desc: 'Show the durable CLI task plan' },
718
+ { cmd: '/tools', desc: 'List local + MCP tools available to the agent' },
719
+ { cmd: '/goal [text|clear|complete|pause|resume|budget <n>]', desc: 'Sticky goal' },
720
+ { cmd: '/continue', desc: 'Resume after a loop-limit abort' },
721
+ { cmd: '/loop <interval> <prompt> /loop stop', desc: 'Repeat a prompt on cadence' },
722
+ { cmd: '/commit', desc: 'Generate message, stage, and git commit' },
723
+ { cmd: '/diff', desc: 'Show git changes (stream-paginated)' },
724
+ ],
725
+ },
726
+ {
727
+ key: 'orchestration',
728
+ title: 'Multi-Agent Orchestration',
729
+ entries: [
730
+ { cmd: '/roles', desc: 'List available agent roles' },
731
+ { cmd: '/agents [--json]', desc: 'List child agent sessions' },
732
+ { cmd: '/agent <id> [--full]', desc: 'Detail + recent transcript of a child' },
733
+ { cmd: '/spawn <role> <prompt>', desc: 'Spawn a child agent' },
734
+ { cmd: '/wait <id> [ms]', desc: 'Wait for a child to finish' },
735
+ { cmd: '/kill <agent-id>', desc: 'Stop a running child' },
736
+ { cmd: '/auto-review [on|off]', desc: 'Auto-run reviewer after every worker' },
737
+ { cmd: '/ps', desc: 'List background tasks (loop + running children)' },
738
+ { cmd: '/stop', desc: 'Stop the running loop, mark stale children' },
739
+ ],
740
+ },
741
+ {
742
+ key: 'guard',
743
+ title: 'Guardrails & Permissions',
744
+ entries: [
745
+ { cmd: '/permissions [read|write|shell]', desc: 'View or set agent access mode' },
746
+ { cmd: '/yolo [on|off]', desc: 'Auto-approve run_command' },
747
+ { cmd: '/sandbox [status|add-read|add-write|remove|clear]', desc: 'Sandbox grants' },
748
+ { cmd: '/hooks [list|add|remove|enable|disable]', desc: 'Lifecycle shell hooks' },
749
+ { cmd: '/hookify [list|create|enable|disable|remove]', desc: 'Markdown rule guards' },
750
+ { cmd: '/logout', desc: 'Clear API keys from the active profile' },
751
+ ],
752
+ },
753
+ {
754
+ key: 'obs',
755
+ title: 'Observability',
756
+ entries: [
757
+ { cmd: '/tokens', desc: 'Session token usage + memory-savings estimate' },
758
+ { cmd: '/watch', desc: 'Tail trace log (BRAINROUTER_TRACE_LOG required)' },
759
+ { cmd: '/trace save <desc> /trace search <q>', desc: 'Debug-trace store' },
760
+ { cmd: '/transcript [main|sessionKey]', desc: 'Recent persisted transcript' },
761
+ { cmd: '/rollout', desc: 'Print the transcript file path' },
762
+ { cmd: '/debug-config', desc: 'Show config layers, env, preferences' },
763
+ ],
764
+ },
765
+ {
766
+ key: 'ui',
767
+ title: 'UI & Ergonomics',
768
+ entries: [
769
+ { cmd: '/theme [auto|light|dark|mono]', desc: 'Markdown output theme' },
770
+ { cmd: '/title <segments>', desc: 'Terminal title (model,session,branch,mode)' },
771
+ { cmd: '/statusline <segments>', desc: 'Prompt (mode,branch,dirty,model,tokens,session,pr)' },
772
+ { cmd: '/personality <style>', desc: 'concise | standard | detailed | pair-programmer' },
773
+ { cmd: '/raw [on|off]', desc: 'Toggle raw scrollback' },
774
+ { cmd: '/vim', desc: 'Toggle vi-mode for the composer' },
775
+ { cmd: '/keymap [json]', desc: 'Show built-in bindings and set overrides' },
776
+ { cmd: '/copy', desc: 'Copy last assistant response to clipboard' },
777
+ { cmd: '/mention [partial]', desc: 'Suggest files for @ mentions' },
778
+ { cmd: '/model <name>', desc: 'Switch the LLM model in-session' },
779
+ { cmd: '/mcp', desc: 'Show the active MCP server and tool namespaces' },
780
+ { cmd: '/ide', desc: 'Show detected IDE host' },
781
+ { cmd: '/apps /plugins', desc: 'List workspace skills and plugin folders' },
782
+ { cmd: '/feedback [message]', desc: 'Append feedback entry' },
783
+ { cmd: '/experimental [on|off]', desc: 'Toggle experimental features' },
784
+ ],
785
+ },
786
+ ];
787
+ export function renderHelp(category) {
788
+ // Match by key OR by leading char of title (allowing /help m → memory).
789
+ const wantedCategory = category
790
+ ? HELP_CATEGORIES.find((c) => c.key === category || c.title.toLowerCase().startsWith(category))
791
+ : undefined;
792
+ // Special case: show a single category if the user asked for one explicitly.
793
+ if (category && wantedCategory) {
794
+ printHelpCategory(wantedCategory);
795
+ console.log(chalk.gray('\nTry /help to see all categories. Tab autocompletes commands; @ mentions files.\n'));
796
+ return;
797
+ }
798
+ if (category && !wantedCategory) {
799
+ console.log(chalk.red(`\nUnknown help category "${category}". Available:`));
800
+ for (const c of HELP_CATEGORIES) {
801
+ console.log(` ${chalk.cyan('/help ' + c.key)} ${chalk.gray(c.title)}`);
802
+ }
803
+ console.log();
804
+ return;
805
+ }
806
+ // No category → decide between full dump and index based on terminal height.
807
+ const totalLines = HELP_CATEGORIES.reduce((n, c) => n + c.entries.length + 2, 0);
808
+ const rows = process.stdout.rows ?? 9999;
809
+ if (rows >= totalLines + 6) {
810
+ // Tall enough — show everything.
811
+ for (const c of HELP_CATEGORIES)
812
+ printHelpCategory(c);
813
+ console.log(chalk.gray('\nTips: @ mentions files · Tab autocompletes · Shift+Tab cycles access mode (read → write → shell).\n'));
814
+ return;
815
+ }
816
+ // Small terminal — show index + per-category command count.
817
+ console.log(chalk.bold('\nAvailable command categories:'));
818
+ for (const c of HELP_CATEGORIES) {
819
+ console.log(` ${chalk.cyan('/help ' + c.key.padEnd(14))} ${chalk.gray(`${c.title} (${c.entries.length} commands)`)}`);
820
+ }
821
+ console.log(chalk.gray('\nYour terminal is short — run /help <category> to drill in. Resize and re-run /help to see all at once.\n'));
822
+ }
823
+ function printHelpCategory(c) {
824
+ console.log(chalk.bold(`\n${c.title}:`));
825
+ // Find max command-column width for alignment.
826
+ const colWidth = Math.min(40, c.entries.reduce((w, e) => Math.max(w, e.cmd.length), 0));
827
+ for (const e of c.entries) {
828
+ console.log(` ${chalk.cyan(e.cmd.padEnd(colWidth))} ${chalk.gray(e.desc)}`);
829
+ }
830
+ }
831
+ async function handleSlashCommand(command, args, agent, mcpClient, config, rl, ctx) {
832
+ // Category dispatch — each extracted module returns true iff it matched
833
+ // the command. New categories should be added here as they're extracted
834
+ // from the giant switch below. Long-term goal: shrink the switch to
835
+ // nothing so this dispatch is the only entrypoint.
836
+ const cmdCtx = { command, args, agent, mcpClient, config, rl, repl: ctx };
837
+ if (await tryHandleMemoryCommand(cmdCtx))
838
+ return;
839
+ if (await tryHandleUiCommand(cmdCtx))
840
+ return;
841
+ if (await tryHandleWorkflowCommand(cmdCtx))
842
+ return;
843
+ if (await tryHandleObsCommand(cmdCtx))
844
+ return;
845
+ if (await tryHandleOrchestrationCommand(cmdCtx))
846
+ return;
847
+ if (await tryHandleSessionCommand(cmdCtx))
848
+ return;
849
+ if (await tryHandleGuardCommand(cmdCtx))
850
+ return;
851
+ // All commands extracted to category files above. Anything that reaches
852
+ // here didn't match any handler.
853
+ console.log(chalk.red(`\nUnknown slash command: ${command}. Type /help for assistance.\n`));
854
+ }
855
+ // runOrchestrationPrompt was the second-class turn pipeline used by /spawn,
856
+ // /wait, /kill, /commit, /approve, /spec, /feature-dev, /review,
857
+ // /implement-plan, /skill. It lacked goal continuation, the isProcessing
858
+ // lock, /raw honoring, contradiction surfacing, and token summary — so any
859
+ // command that took the second-class path felt visibly weaker than a plain
860
+ // prompt. Removed in favor of routing every command through the closure's
861
+ // runAgentTurn (which has all of the above).
862
+ /**
863
+ * Tab-completion source for `@path/to/file` mentions. Given a partial workspace
864
+ * path, return the matching files and directories one level deep. Stays inside
865
+ * the workspace and ignores noise dirs to keep the completion list useful.
866
+ */
867
+ export function completeWorkspacePath(workspaceRoot, partial) {
868
+ const ignore = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'coverage', '.brainrouter']);
869
+ // Split partial into "dir/" + "prefix" so we only enumerate one directory at a time.
870
+ const lastSlash = partial.lastIndexOf('/');
871
+ const subdir = lastSlash >= 0 ? partial.slice(0, lastSlash + 1) : '';
872
+ const prefix = lastSlash >= 0 ? partial.slice(lastSlash + 1) : partial;
873
+ let absDir;
874
+ try {
875
+ absDir = path.resolve(workspaceRoot, subdir || '.');
876
+ }
877
+ catch {
878
+ return [];
879
+ }
880
+ // Don't escape the workspace.
881
+ if (path.relative(workspaceRoot, absDir).startsWith('..'))
882
+ return [];
883
+ let entries;
884
+ try {
885
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
886
+ }
887
+ catch {
888
+ return [];
889
+ }
890
+ return entries
891
+ .filter((e) => !ignore.has(e.name) && e.name.startsWith(prefix))
892
+ .map((e) => `${subdir}${e.name}${e.isDirectory() ? '/' : ''}`)
893
+ .sort();
894
+ }