@kinqs/brainrouter-cli 0.3.5 → 0.3.6

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 (49) hide show
  1. package/.env.example +55 -48
  2. package/bin/cli.cjs +71 -0
  3. package/dist/agent/agent.d.ts +212 -2
  4. package/dist/agent/agent.js +428 -38
  5. package/dist/cli/banner.d.ts +60 -0
  6. package/dist/cli/banner.js +199 -0
  7. package/dist/cli/cliPrompt.d.ts +69 -0
  8. package/dist/cli/cliPrompt.js +287 -0
  9. package/dist/cli/commands/_helpers.js +6 -6
  10. package/dist/cli/commands/guard.js +75 -10
  11. package/dist/cli/commands/mcp.d.ts +17 -0
  12. package/dist/cli/commands/mcp.js +121 -0
  13. package/dist/cli/commands/memory.js +2 -2
  14. package/dist/cli/commands/obs.js +22 -22
  15. package/dist/cli/commands/session.js +13 -5
  16. package/dist/cli/commands/ui.js +97 -45
  17. package/dist/cli/commands/workflow.d.ts +18 -0
  18. package/dist/cli/commands/workflow.js +314 -43
  19. package/dist/cli/repl.js +219 -132
  20. package/dist/cli/spinner.d.ts +34 -0
  21. package/dist/cli/spinner.js +36 -0
  22. package/dist/cli/statusline.d.ts +67 -0
  23. package/dist/cli/statusline.js +204 -0
  24. package/dist/cli/theme.d.ts +79 -0
  25. package/dist/cli/theme.js +106 -0
  26. package/dist/cli/whereView.d.ts +81 -0
  27. package/dist/cli/whereView.js +245 -0
  28. package/dist/config/config.d.ts +40 -0
  29. package/dist/config/config.js +45 -73
  30. package/dist/index.js +80 -13
  31. package/dist/memory/briefing.d.ts +10 -0
  32. package/dist/memory/briefing.js +69 -1
  33. package/dist/prompt/breadthHint.d.ts +5 -0
  34. package/dist/prompt/breadthHint.js +44 -0
  35. package/dist/prompt/systemPrompt.d.ts +34 -0
  36. package/dist/prompt/systemPrompt.js +124 -108
  37. package/dist/runtime/dangerousCommand.d.ts +53 -0
  38. package/dist/runtime/dangerousCommand.js +105 -0
  39. package/dist/runtime/mcpClient.d.ts +38 -1
  40. package/dist/runtime/mcpClient.js +90 -2
  41. package/dist/state/goalStore.d.ts +98 -17
  42. package/dist/state/goalStore.js +132 -42
  43. package/dist/state/preferencesStore.d.ts +67 -3
  44. package/dist/state/preferencesStore.js +84 -1
  45. package/dist/state/workflowArtifacts.d.ts +63 -2
  46. package/dist/state/workflowArtifacts.js +120 -8
  47. package/dist/tests/_helpers.d.ts +31 -0
  48. package/dist/tests/_helpers.js +91 -0
  49. package/package.json +5 -4
package/dist/cli/repl.js CHANGED
@@ -2,17 +2,20 @@ import readline from 'node:readline';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import chalk from 'chalk';
5
- import ora from 'ora';
5
+ import { spinner } from './spinner.js';
6
6
  import { exec } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { marked } from 'marked';
9
9
  import { markedTerminal } from 'marked-terminal';
10
10
  import { expandMentions } from '../memory/mentions.js';
11
- import { addGoalTokens, buildBudgetSteeringMessage, goalHasBudgetLeft, goalIsOnFinalBudgetTurn, readGoal, tickGoalIteration, usageLimitGoal } from '../state/goalStore.js';
11
+ import { addGoalTokens, buildGoalContinuationPrompt, formatBudget, goalHasBudgetLeft, readGoal, tickGoalIteration, usageLimitGoal } from '../state/goalStore.js';
12
12
  import { readPreferences } from '../state/preferencesStore.js';
13
13
  import { execSync } from 'node:child_process';
14
14
  import { listSessions } from '../orchestration/orchestrator.js';
15
- import { setActiveReadline } from './cliPrompt.js';
15
+ import { isPickerActive, setActiveReadline } from './cliPrompt.js';
16
+ import { resolveTheme } from './theme.js';
17
+ import { buildBannerInputs, renderBanner } from './banner.js';
18
+ import { isKnownSegment, renderSegments } from './statusline.js';
16
19
  // Category dispatch — extracted slash-command handlers. Each module exports
17
20
  // a tryHandleX(ctx) that returns true iff it matched the command. Walked
18
21
  // in order; first match wins, no match falls through to the legacy switch.
@@ -23,6 +26,7 @@ import { tryHandleObsCommand } from './commands/obs.js';
23
26
  import { tryHandleOrchestrationCommand } from './commands/orchestration.js';
24
27
  import { tryHandleSessionCommand } from './commands/session.js';
25
28
  import { tryHandleGuardCommand } from './commands/guard.js';
29
+ import { tryHandleMcpCommand } from './commands/mcp.js';
26
30
  const execPromise = promisify(exec);
27
31
  // Setup marked terminal rendering
28
32
  marked.use(markedTerminal({
@@ -33,36 +37,53 @@ marked.use(markedTerminal({
33
37
  * the readline completer. Keep alphabetically grouped roughly by surface area.
34
38
  */
35
39
  const SLASH_COMMANDS = [
36
- '/help', '/status', '/workspace', '/tools', '/skills', '/plan', '/transcript',
40
+ '/help', '/status', '/workspace', '/where', '/tools', '/skills', '/plan', '/transcript',
37
41
  '/doctor', '/config', '/diff', '/commit', '/clear', '/compact', '/exit', '/quit',
38
42
  '/roles', '/agents', '/agent', '/spawn', '/wait',
39
- '/spec', '/feature-dev', '/review', '/implement-plan', '/skill', '/workflows', '/approve',
43
+ '/spec', '/feature-dev', '/grill-me', '/review', '/implement-plan', '/skill', '/workflow', '/workflows', '/approve',
40
44
  '/memory', '/recall', '/briefing', '/scenes', '/working', '/forget',
41
45
  '/init', '/sessions', '/resume', '/model', '/mcp',
42
46
  '/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop',
43
- '/continue', '/auto-review', '/vim', '/statusline',
47
+ '/continue', '/auto-review', '/vim', '/statusline', '/quiet',
44
48
  '/handover', '/explain', '/trace', '/failed', '/verify', '/audit',
45
49
  '/export', '/import', '/persona', '/skill-hints', '/diagnostics',
46
- '/tokens', '/watch', '/yolo', '/sandbox', '/kill',
50
+ '/tokens', '/watch', '/yolo', '/mode', '/review-policy', '/sandbox', '/kill',
47
51
  // workflow & ergonomics commands
48
- '/theme', '/title', '/personality', '/new', '/side', '/btw', '/raw',
52
+ '/theme', '/title', '/personality', '/effort', '/new', '/side', '/btw', '/raw',
49
53
  '/feedback', '/rollout', '/ps', '/stop', '/logout', '/apps', '/plugins',
50
54
  '/experimental', '/memories', '/debug-config', '/mention', '/keymap', '/ide',
51
55
  ];
52
56
  export function startREPL(agent, mcpClient, config, workspace) {
53
- console.log(chalk.bold.hex('#CC9166')('\n🧠 BRAINROUTER TERMINAL AGENT CLIENT v0.3.5'));
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.
57
+ const theme = resolveTheme(agent.workspaceRoot);
58
+ const banner = renderBanner(buildBannerInputs(config, agent, mcpClient), theme);
59
+ console.log('\n' + banner);
60
+ // Offline-mode advisory stays as a separate line below the box so the
61
+ // colored warning isn't easy to miss when scanning past banner chrome.
62
+ // Carries the remediation hint that used to live as a duplicate pre-banner
63
+ // warning in the chat command's catch block.
58
64
  if (!mcpClient.isConnected()) {
59
- console.log(chalk.yellow('⚠️ OFFLINE MODE — MCP server unreachable. Local tools only; memory recall / skills disabled.'));
65
+ console.log(theme.warning(' ⚠️ OFFLINE MODE — MCP server unreachable. Memory recall, skills, and capture are disabled.'));
66
+ console.log(theme.muted(' Local tools (file edits, shell, web fetch, spawn_agent) still work.'));
67
+ console.log(theme.muted(' Start the MCP server and restart the CLI to restore full functionality.'));
60
68
  }
61
- console.log(chalk.gray('Type ') + chalk.cyan('/help') + chalk.gray(' for commands, or start typing your prompt.\n'));
69
+ console.log(theme.muted(' Type ') + theme.info('/help') +
70
+ theme.muted(' for commands · ') + theme.info('/where') +
71
+ theme.muted(' for current state · just start typing your prompt.\n'));
62
72
  const rl = readline.createInterface({
63
73
  input: process.stdin,
64
74
  output: process.stdout,
65
- prompt: chalk.hex('#CC9166')('brainrouter> '),
75
+ // Explicit `terminal: true` instead of relying on the auto-detect from
76
+ // `input.isTTY`. The auto-detect returns `undefined` in some shells /
77
+ // terminal multiplexers (tmux on certain platforms, VS Code's integrated
78
+ // terminal with specific settings, ssh -t pipelines), and a falsy value
79
+ // means readline falls back to a non-TTY interface — no keypress events,
80
+ // no raw mode, Backspace echoes as `^?` instead of erasing.
81
+ terminal: true,
82
+ // Initial prompt uses the resolved theme's primary accent so light/mono
83
+ // users get a readable prompt even on the first draw. refreshPromptForMode
84
+ // re-renders immediately after wiring up the access-mode accent, so this
85
+ // initial value mostly governs the millisecond before that runs.
86
+ prompt: theme.primary('brainrouter> '),
66
87
  // Tab-completion: complete slash commands when the line begins with "/"
67
88
  // and complete workspace file paths when the user is mid-`@mention`.
68
89
  completer: (line) => {
@@ -79,6 +100,20 @@ export function startREPL(agent, mcpClient, config, workspace) {
79
100
  return [[], line];
80
101
  },
81
102
  });
103
+ // Belt-and-suspenders: force-engage raw-mode keypress handling on stdin.
104
+ // readline.createInterface does this internally for a TTY input, but its
105
+ // auto-init is unreliable across the terminal zoo (tmux, screen, VS Code
106
+ // integrated terminal, certain SSH setups) — when it doesn't engage, the
107
+ // symptom is Backspace echoing `^?` and arrow keys echoing `^[[A` instead
108
+ // of doing what they're supposed to do. Calling these here is a no-op when
109
+ // readline already did them, and a fix in the cases where it didn't.
110
+ if (process.stdin.isTTY) {
111
+ try {
112
+ readline.emitKeypressEvents(process.stdin);
113
+ process.stdin.setRawMode?.(true);
114
+ }
115
+ catch { /* not a real TTY after all */ }
116
+ }
82
117
  // GitHub PR detection cache. `gh pr view` takes ~300ms and prompts often
83
118
  // refresh many times per turn; cache the result for 30s. Returns either
84
119
  // a string like "#42" or null when there's no PR / gh not installed.
@@ -109,48 +144,31 @@ export function startREPL(agent, mcpClient, config, workspace) {
109
144
  };
110
145
  // Reflect the current access mode and any configured statusline segments
111
146
  // in the prompt. Configurable via /statusline; default just shows the mode.
147
+ // Segment expansion lives in ./statusline.ts so the segment vocabulary is
148
+ // one source of truth for both /statusline and the prompt renderer.
112
149
  const renderStatusline = () => {
113
150
  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(' · ');
151
+ const requested = prefs.statusline.split(',').map((s) => s.trim()).filter(Boolean);
152
+ const segments = requested.filter(isKnownSegment);
153
+ return renderSegments(segments, {
154
+ workspaceRoot: agent.workspaceRoot,
155
+ sessionKey: agent.sessionKey,
156
+ accessMode: agent.getAccessMode(),
157
+ model: agent.getModel(),
158
+ lastTurnUsage: agent.lastTurnUsage,
159
+ prDetector: () => detectGitHubPR(agent.workspaceRoot),
160
+ }).join(' · ');
150
161
  };
151
162
  const refreshPromptForMode = () => {
152
163
  const mode = agent.getAccessMode();
153
- const accent = mode === 'shell' ? chalk.red : mode === 'write' ? chalk.hex('#CC9166') : chalk.green;
164
+ // Mode-to-token mapping reads as semantic intent rather than raw color:
165
+ // read → success (least dangerous; matches the ✓ established for "ok")
166
+ // write → primary (brand accent; the default writable mode)
167
+ // shell → danger (escalated capability; same color as failed tools)
168
+ // Theme tokens mean BRAINROUTER_THEME=light|mono actually affects the
169
+ // prompt — the surface the user stares at most. Previously hard-coded
170
+ // chalk.hex('#CC9166')/red/green ignored the user's theme entirely.
171
+ const accent = mode === 'shell' ? theme.danger : mode === 'write' ? theme.primary : theme.success;
154
172
  const line = renderStatusline();
155
173
  rl.setPrompt(accent(`brainrouter[${line}]> `));
156
174
  // The terminal title shares the same trigger conditions as the prompt:
@@ -213,6 +231,20 @@ export function startREPL(agent, mcpClient, config, workspace) {
213
231
  }
214
232
  catch { /* terminal doesn't support OSC titles */ }
215
233
  };
234
+ // Quiet mode: hides recall tables, briefing dumps, tool-completion previews.
235
+ // Env var (`BRAINROUTER_QUIET`, set by --quiet flag) wins for the session;
236
+ // /quiet writes through to preferences AND mirrors into the env so toggling
237
+ // back-and-forth keeps a single source of truth at read time.
238
+ const isQuiet = () => {
239
+ if (process.env.BRAINROUTER_QUIET === '1')
240
+ return true;
241
+ try {
242
+ return readPreferences(agent.workspaceRoot).quiet === true;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ };
216
248
  refreshPromptForMode();
217
249
  refreshTerminalTitle();
218
250
  // Vim mode: readline supports editorMode 'vi' via setRawMode + tty.
@@ -227,13 +259,19 @@ export function startREPL(agent, mcpClient, config, workspace) {
227
259
  }
228
260
  // Shift+Tab cycles the access mode.
229
261
  // Order: read → write → shell → read …
230
- if (process.stdin.isTTY) {
231
- try {
232
- process.stdin.setRawMode?.(false);
233
- }
234
- catch { /* noop */ }
235
- }
262
+ // NOTE: a previous version called `setRawMode(false)` here, claiming it
263
+ // was needed for keypress events. The opposite is true — readline enables
264
+ // raw mode automatically for a TTY input, and disabling it breaks BOTH
265
+ // (a) keypress event delivery for shift+tab (which depend on raw bytes)
266
+ // and (b) Backspace handling at the prompt (readline expects to receive
267
+ // the raw 0x7F itself; in cooked mode the terminal's line discipline
268
+ // owns it and readline's internal buffer drifts out of sync). Leave the
269
+ // default in place.
236
270
  process.stdin.on('keypress', (_str, key) => {
271
+ // The ask_user_choice picker owns stdin while it's on screen; yield to
272
+ // it or shift+tab would cycle the access mode mid-picker.
273
+ if (isPickerActive())
274
+ return;
237
275
  if (key && key.name === 'tab' && key.shift) {
238
276
  const cycle = ['read', 'write', 'shell'];
239
277
  const current = agent.getAccessMode();
@@ -252,43 +290,44 @@ export function startREPL(agent, mcpClient, config, workspace) {
252
290
  rl.prompt();
253
291
  let isProcessing = false;
254
292
  // (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');
293
+ // Idle help hint: one-time per session. 30s after the prompt appears (and
294
+ // while neither a turn nor a pending continuation is running), print a
295
+ // single discoverability nudge so first-time users find /help and /where.
296
+ // Dismissed permanently as soon as it fires or the user starts typing.
297
+ // safePrintAbovePrompt is defined further down the actual call only
298
+ // happens via setTimeout (30s after declaration), so by firing time
299
+ // safePrintAbovePrompt has been bound, no TDZ in practice.
300
+ let idleHintFired = false;
301
+ let idleHintTimer;
302
+ const clearIdleHint = () => {
303
+ if (idleHintTimer) {
304
+ clearTimeout(idleHintTimer);
305
+ idleHintTimer = undefined;
306
+ }
307
+ };
308
+ const armIdleHint = () => {
309
+ if (idleHintFired || !process.stdout.isTTY)
310
+ return;
311
+ clearIdleHint();
312
+ idleHintTimer = setTimeout(() => {
313
+ if (idleHintFired || isProcessing || pendingContinuation)
314
+ return;
315
+ idleHintFired = true;
316
+ safePrintAbovePrompt(chalk.gray(' Tip: press ') + chalk.cyan('?') + chalk.gray(' or ') +
317
+ chalk.cyan('/help') + chalk.gray(' for commands, ') + chalk.cyan('/where') +
318
+ chalk.gray(' for current state.'));
319
+ }, 30_000);
320
+ if (typeof idleHintTimer.unref === 'function') {
321
+ // unref so a fully-idle CLI can still exit cleanly on Ctrl-C / SIGTERM.
322
+ idleHintTimer.unref();
323
+ }
285
324
  };
286
325
  /**
287
326
  * Print a line of output while the readline prompt is showing without
288
327
  * clobbering whatever the user is mid-typing. Used by child-agent callbacks
289
328
  * that fire AFTER the parent's runTurn returned — the agent's tool events
290
329
  * keep streaming for a while because children run detached, and naive
291
- * console.log + spinner.start() would steal the input row.
330
+ * console.log + turnSpinner.start() would steal the input row.
292
331
  */
293
332
  const safePrintAbovePrompt = (msg) => {
294
333
  if (!process.stdout.isTTY) {
@@ -306,6 +345,9 @@ export function startREPL(agent, mcpClient, config, workspace) {
306
345
  rl.prompt(true);
307
346
  }
308
347
  };
348
+ // Now that safePrintAbovePrompt is bound, arm the idle hint for the first
349
+ // time. Subsequent arming happens after each prompt redraw in runAgentTurn.
350
+ armIdleHint();
309
351
  /** Run a turn programmatically (used by `/continue` and the line handler). */
310
352
  const runAgentTurn = async (rawInput) => {
311
353
  if (isProcessing) {
@@ -315,11 +357,11 @@ export function startREPL(agent, mcpClient, config, workspace) {
315
357
  isProcessing = true;
316
358
  rl.pause();
317
359
  const { expanded, mentions } = expandMentions(rawInput, agent.workspaceRoot);
318
- if (mentions.length > 0) {
360
+ if (mentions.length > 0 && !isQuiet()) {
319
361
  console.log(chalk.gray(`📎 Attached ${mentions.length} file${mentions.length === 1 ? '' : 's'}: ${mentions.map((m) => m.token).join(', ')}`));
320
362
  }
321
363
  const startedAt = Date.now();
322
- const spinner = ora(chalk.gray('Agent starting...')).start();
364
+ const turnSpinner = spinner(chalk.gray('Agent starting...')).start();
323
365
  // Once the parent's runTurn returns, child agents may still emit tool
324
366
  // events asynchronously. After this flag flips, we MUST NOT touch the
325
367
  // spinner (which is already .succeeded) — restarting it would steal the
@@ -331,12 +373,17 @@ export function startREPL(agent, mcpClient, config, workspace) {
331
373
  const elapsed = Math.floor((Date.now() - startedAt) / 1000);
332
374
  const u = agent.lastTurnUsage;
333
375
  const tokens = u.calls > 0 ? ` ${u.promptTokens.toLocaleString()}↑ ${u.completionTokens.toLocaleString()}↓` : '';
334
- spinner.text = chalk.gray(`${status} ${elapsed}s${tokens}`);
376
+ turnSpinner.text = chalk.gray(`${status} ${elapsed}s${tokens}`);
335
377
  };
336
378
  try {
337
379
  const answer = await agent.runTurn(expanded, {
338
380
  onStatusUpdate: tickStatus,
339
381
  onToolStart: (name, args) => {
382
+ // Quiet mode: skip tool-start chrome entirely. The spinner already
383
+ // reflects "something is happening" and the final prose tells the
384
+ // story. Errors still surface via onToolEnd's failure branch.
385
+ if (isQuiet())
386
+ return;
340
387
  // Render spawn_agent / spawn_agents specially — a one-liner
341
388
  // ("Ran agent <role> — <one-line task>") so a fan-out of 5
342
389
  // children produces 5 clean lines instead of 5 JSON dumps. The
@@ -363,10 +410,17 @@ export function startREPL(agent, mcpClient, config, workspace) {
363
410
  safePrintAbovePrompt(line);
364
411
  return;
365
412
  }
366
- spinner.stop();
413
+ turnSpinner.stop();
367
414
  console.log(line);
368
415
  },
369
416
  onToolEnd: (name, result) => {
417
+ // Quiet mode: only print on failure. Successes are invisible — the
418
+ // prose answer or downstream tool calls speak for themselves.
419
+ if (isQuiet() && result.success) {
420
+ tickStatus('Thinking');
421
+ turnSpinner.start();
422
+ return;
423
+ }
370
424
  const line = result.success
371
425
  ? chalk.green('✓ Tool ') + chalk.cyan(name) + chalk.green(' completed: ') + chalk.gray(result.summary)
372
426
  : chalk.red('❌ Tool ') + chalk.cyan(name) + chalk.red(' failed: ') + chalk.yellow(result.summary);
@@ -374,8 +428,10 @@ export function startREPL(agent, mcpClient, config, workspace) {
374
428
  // sees the actual result (directory listing, grep matches, glob
375
429
  // paths) even when the LLM later replies with only a stub like
376
430
  // "I have listed the directory." Capped to a handful of lines in
377
- // getToolPreview itself.
378
- const previewBlock = result.preview
431
+ // getToolPreview itself. Quiet mode drops the preview even on
432
+ // failure the summary tells the user what broke; the preview is
433
+ // for diagnosing why and isn't worth the screen real-estate.
434
+ const previewBlock = result.preview && !isQuiet()
379
435
  ? '\n' + result.preview.split('\n').map((l) => chalk.gray(' ' + l)).join('\n')
380
436
  : '';
381
437
  const composed = line + previewBlock;
@@ -385,14 +441,14 @@ export function startREPL(agent, mcpClient, config, workspace) {
385
441
  }
386
442
  console.log(composed);
387
443
  tickStatus('Thinking');
388
- spinner.start();
444
+ turnSpinner.start();
389
445
  },
390
446
  onPlanUpdate: (items, explanation) => {
391
447
  if (parentDone) {
392
448
  safePrintAbovePrompt(chalk.gray(`📋 Plan updated (${items.length} item${items.length === 1 ? '' : 's'})`));
393
449
  return;
394
450
  }
395
- spinner.stop();
451
+ turnSpinner.stop();
396
452
  console.log(chalk.gray('📋 Plan updated:'));
397
453
  if (explanation)
398
454
  console.log(chalk.gray(` ${explanation}`));
@@ -404,7 +460,7 @@ export function startREPL(agent, mcpClient, config, workspace) {
404
460
  console.log(` ${mark} ${text}`);
405
461
  }
406
462
  tickStatus('Thinking');
407
- spinner.start();
463
+ turnSpinner.start();
408
464
  },
409
465
  onChildComplete: (event) => {
410
466
  const head = event.status === 'completed'
@@ -418,12 +474,16 @@ export function startREPL(agent, mcpClient, config, workspace) {
418
474
  safePrintAbovePrompt(line);
419
475
  return;
420
476
  }
421
- spinner.stop();
477
+ turnSpinner.stop();
422
478
  console.log(line);
423
479
  tickStatus('Thinking');
424
- spinner.start();
480
+ turnSpinner.start();
425
481
  },
426
482
  onMemoryEvent: (event) => {
483
+ // Quiet mode: silence briefing/capture/citation chatter. Keep
484
+ // contradictions audible — those are warnings the user should see.
485
+ if (isQuiet() && event.kind !== 'contradiction')
486
+ return;
427
487
  let line;
428
488
  if (event.kind === 'briefing') {
429
489
  const src = event.sources.length > 0 ? event.sources.join(', ') : '(none)';
@@ -473,10 +533,10 @@ export function startREPL(agent, mcpClient, config, workspace) {
473
533
  safePrintAbovePrompt(line);
474
534
  return;
475
535
  }
476
- spinner.stop();
536
+ turnSpinner.stop();
477
537
  console.log(line);
478
538
  tickStatus('Thinking');
479
- spinner.start();
539
+ turnSpinner.start();
480
540
  },
481
541
  });
482
542
  const elapsed = Math.floor((Date.now() - startedAt) / 1000);
@@ -485,7 +545,7 @@ export function startREPL(agent, mcpClient, config, workspace) {
485
545
  ? chalk.gray(` · ${u.promptTokens.toLocaleString()} in / ${u.completionTokens.toLocaleString()} out across ${u.calls} call${u.calls === 1 ? '' : 's'}`)
486
546
  : '';
487
547
  parentDone = true;
488
- spinner.succeed(chalk.green(`Done!${chalk.gray(` ${elapsed}s`)}${tokenSummary}`));
548
+ turnSpinner.succeed(chalk.green(`Done!${chalk.gray(` ${elapsed}s`)}${tokenSummary}`));
489
549
  const prefsForRender = readPreferences(agent.workspaceRoot);
490
550
  const rendered = prefsForRender.rawScrollback ? answer : marked.parse(answer);
491
551
  console.log('\n' + rendered + '\n');
@@ -497,16 +557,21 @@ export function startREPL(agent, mcpClient, config, workspace) {
497
557
  }
498
558
  catch (err) {
499
559
  parentDone = true;
500
- spinner.fail(chalk.red('Execution failed'));
560
+ turnSpinner.fail(chalk.red('Execution failed'));
501
561
  console.error(chalk.red(`\nError: ${err.message}\n`));
502
562
  }
503
563
  finally {
504
564
  isProcessing = false;
505
565
  // 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.
566
+ // /review / /implement-plan / /grill-me so subsequent plain prompts
567
+ // don't keep spiking the same skill. The skill memetic potential
568
+ // still decays server-side on its own half-life; this just stops
569
+ // attribution. Also refresh the system prompt so skill-conditional
570
+ // overlays (e.g. grill-me's CLARIFY block) disappear from
571
+ // chatHistory[0] before the user's next prompt — otherwise the
572
+ // model would see "do not make file edits" carrying over.
509
573
  agent.activeSkill = undefined;
574
+ agent.refreshSystemPrompt();
510
575
  // Auto-continuation logic. Rules:
511
576
  // - the goal must be active (not paused / complete / blocked / usage_limited)
512
577
  // - the turn made at least one tool call (prose-only turns are anti-spin)
@@ -553,7 +618,7 @@ export function startREPL(agent, mcpClient, config, workspace) {
553
618
  else if (goalAfter && goalAfter.status === 'active' && !goalHasBudgetLeft(goalAfter)) {
554
619
  // Iteration cap reached — transition to usage_limited so the user
555
620
  // gets a consistent resumable state regardless of which cap tripped.
556
- const reason = `Iteration budget exhausted (${goalAfter.budget.iterationsUsed}/${goalAfter.budget.maxIterations}).`;
621
+ const reason = `Iteration budget exhausted (${goalAfter.budget.iterationsUsed}/${formatBudget(goalAfter.budget.maxIterations)}).`;
557
622
  const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, reason);
558
623
  console.log(chalk.yellow(`\n⏸ ${reason} Extend with /goal budget <n> and /goal resume, mark /goal complete, or /goal clear.\n`));
559
624
  if (limited)
@@ -563,30 +628,30 @@ export function startREPL(agent, mcpClient, config, workspace) {
563
628
  console.log(chalk.gray(`(goal continuation suppressed: last turn made no tool calls — anti-spin)\n`));
564
629
  }
565
630
  rl.resume();
631
+ // NOTE: do NOT call setRawMode(true) here. readline's rl.resume()
632
+ // already calls input._setRawMode(true) internally for terminal
633
+ // interfaces. A redundant external setRawMode(true) DOES re-engage
634
+ // raw mode (it's idempotent for the mode itself) BUT it also resets
635
+ // `this.readableFlowing = null` on the stream as a side effect
636
+ // (lib/tty.js). After that, the stream is in "auto" flowing state
637
+ // and keystrokes don't reach readline — user sees a live prompt
638
+ // they can't type into. Trust the internal call.
566
639
  refreshPromptForMode(); // pick up token-meter / branch updates
567
640
  rl.prompt();
641
+ // Re-arm the idle hint after each completed turn — a user who walks
642
+ // away after a turn ends still gets one nudge if they hadn't seen it.
643
+ armIdleHint();
568
644
  if (shouldContinue && goalAfter) {
569
645
  pendingContinuation = true;
570
646
  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)`));
647
+ // Pre-9d this branch installed a separate `goal-budget-steering`
648
+ // tagged system message when the next turn was the final one
649
+ // inside the budget. 9d folded the wrap-up directive into the
650
+ // goal-anchor itself — `formatGoalBlock` now auto-detects the
651
+ // final-budget-turn state and prepends the directive so the
652
+ // tagged-message bookkeeping disappears entirely. The anchor is
653
+ // re-rendered at the top of every runTurn (`agent.ts:677-686`).
654
+ console.log(chalk.gray(`(goal continuation queued iteration ${next}/${formatBudget(goalAfter.budget.maxIterations)}; type anything to cancel)`));
590
655
  const followUp = buildGoalContinuationPrompt(goalAfter, agent.lastUserPrompt, agent.lastAnswer);
591
656
  setImmediate(() => {
592
657
  if (!pendingContinuation || isProcessing)
@@ -599,6 +664,10 @@ export function startREPL(agent, mcpClient, config, workspace) {
599
664
  }
600
665
  };
601
666
  rl.on('line', async (line) => {
667
+ // User typed anything → drop the pending idle-hint timer regardless of
668
+ // whether the input itself is meaningful. Empty enter still counts as
669
+ // engagement; we don't want to nag a user who's clearly at the keyboard.
670
+ clearIdleHint();
602
671
  // User typed: any pending goal continuation is cancelled.
603
672
  if (pendingContinuation) {
604
673
  pendingContinuation = false;
@@ -609,6 +678,13 @@ export function startREPL(agent, mcpClient, config, workspace) {
609
678
  rl.prompt();
610
679
  return;
611
680
  }
681
+ // Treat a bare "?" as /help — the idle-hint tip advertises it, so make
682
+ // it actually work. Anything beyond "?" (a real prompt) falls through.
683
+ if (input === '?') {
684
+ renderHelp();
685
+ rl.prompt();
686
+ return;
687
+ }
612
688
  if (input.startsWith('/')) {
613
689
  // Split on any whitespace, not a literal space. Without this, a slash
614
690
  // command followed by a tab (autocomplete completion that wasn't
@@ -664,6 +740,7 @@ const HELP_CATEGORIES = [
664
740
  entries: [
665
741
  { cmd: '/status', desc: 'Connection status, LLM config, DB stats' },
666
742
  { cmd: '/workspace', desc: 'Active workspace and session identity' },
743
+ { cmd: '/where', desc: 'Single-screen view of workspace, workflow, goal, plan, recall, children' },
667
744
  { cmd: '/doctor', desc: 'Config, connection, memory extraction health' },
668
745
  { cmd: '/config', desc: 'View active configuration profile' },
669
746
  { cmd: '/clear', desc: 'Clear chat history for the active session' },
@@ -708,13 +785,17 @@ const HELP_CATEGORIES = [
708
785
  entries: [
709
786
  { cmd: '/spec <title>', desc: 'Produce spec.md (spec-driven-skill)' },
710
787
  { cmd: '/feature-dev <feat>', desc: 'Multi-agent feature dev with spec + tasks' },
788
+ { cmd: '/grill-me [--force] <task>', desc: 'Clarify 2–5 questions before implementing (CLARIFY mode)' },
711
789
  { cmd: '/review [scope]', desc: 'Multi-agent code review → review.md' },
712
790
  { cmd: '/implement-plan', desc: 'Execute next plan item; append walkthrough' },
713
791
  { cmd: '/approve [slug]', desc: 'Approve workflow + kick off implementation' },
714
792
  { cmd: '/workflows', desc: 'List durable workflow folders' },
793
+ { cmd: '/workflow switch <slug>', desc: 'Refocus on an existing workflow (migrates any session goal into the target)' },
794
+ { cmd: '/workflow pause', desc: 'Pause the current workflow\'s goal' },
795
+ { cmd: '/workflow resume <slug>', desc: 'Switch to <slug> AND resume its goal in one shot' },
715
796
  { cmd: '/skill <name> [input]', desc: 'Run any catalogued skill' },
716
797
  { cmd: '/skills', desc: 'List installed BrainRouter skills' },
717
- { cmd: '/plan', desc: 'Show the durable CLI task plan' },
798
+ { cmd: '/plan /plan clear', desc: 'Show the durable CLI task plan; clear it (drops stale items)' },
718
799
  { cmd: '/tools', desc: 'List local + MCP tools available to the agent' },
719
800
  { cmd: '/goal [text|clear|complete|pause|resume|budget <n>]', desc: 'Sticky goal' },
720
801
  { cmd: '/continue', desc: 'Resume after a loop-limit abort' },
@@ -743,7 +824,9 @@ const HELP_CATEGORIES = [
743
824
  title: 'Guardrails & Permissions',
744
825
  entries: [
745
826
  { cmd: '/permissions [read|write|shell]', desc: 'View or set agent access mode' },
746
- { cmd: '/yolo [on|off]', desc: 'Auto-approve run_command' },
827
+ { cmd: '/mode [planning|fast]', desc: 'Session execution stance (planning asks, fast skips per-call y/N for safe commands)' },
828
+ { cmd: '/review-policy [request|proceed]', desc: 'How the agent treats multi-file approval gates' },
829
+ { cmd: '/yolo [on|off]', desc: 'Alias for `/mode fast` + `/review-policy proceed`' },
747
830
  { cmd: '/sandbox [status|add-read|add-write|remove|clear]', desc: 'Sandbox grants' },
748
831
  { cmd: '/hooks [list|add|remove|enable|disable]', desc: 'Lifecycle shell hooks' },
749
832
  { cmd: '/hookify [list|create|enable|disable|remove]', desc: 'Markdown rule guards' },
@@ -768,15 +851,17 @@ const HELP_CATEGORIES = [
768
851
  entries: [
769
852
  { cmd: '/theme [auto|light|dark|mono]', desc: 'Markdown output theme' },
770
853
  { cmd: '/title <segments>', desc: 'Terminal title (model,session,branch,mode)' },
771
- { cmd: '/statusline <segments>', desc: 'Prompt (mode,branch,dirty,model,tokens,session,pr)' },
854
+ { cmd: '/statusline <segments>', desc: 'Prompt (mode,exec,effort,branch,dirty,model,tokens,session,pr,workflow,goal,plan)' },
772
855
  { cmd: '/personality <style>', desc: 'concise | standard | detailed | pair-programmer' },
856
+ { cmd: '/effort [low|medium|high]', desc: 'Reasoning depth: low=terse, medium=default, high=step-by-step (env: BRAINROUTER_EFFORT)' },
773
857
  { cmd: '/raw [on|off]', desc: 'Toggle raw scrollback' },
858
+ { cmd: '/quiet [on|off]', desc: 'Hide recall tables, previews, briefings (model prose only)' },
774
859
  { cmd: '/vim', desc: 'Toggle vi-mode for the composer' },
775
860
  { cmd: '/keymap [json]', desc: 'Show built-in bindings and set overrides' },
776
861
  { cmd: '/copy', desc: 'Copy last assistant response to clipboard' },
777
862
  { cmd: '/mention [partial]', desc: 'Suggest files for @ mentions' },
778
863
  { cmd: '/model <name>', desc: 'Switch the LLM model in-session' },
779
- { cmd: '/mcp', desc: 'Show the active MCP server and tool namespaces' },
864
+ { cmd: '/mcp [list|reconnect|tools]', desc: 'MCP profiles, identity tags, online/offline status, reconnect, tool namespaces' },
780
865
  { cmd: '/ide', desc: 'Show detected IDE host' },
781
866
  { cmd: '/apps /plugins', desc: 'List workspace skills and plugin folders' },
782
867
  { cmd: '/feedback [message]', desc: 'Append feedback entry' },
@@ -848,6 +933,8 @@ async function handleSlashCommand(command, args, agent, mcpClient, config, rl, c
848
933
  return;
849
934
  if (await tryHandleGuardCommand(cmdCtx))
850
935
  return;
936
+ if (await tryHandleMcpCommand(cmdCtx))
937
+ return;
851
938
  // All commands extracted to category files above. Anything that reaches
852
939
  // here didn't match any handler.
853
940
  console.log(chalk.red(`\nUnknown slash command: ${command}. Type /help for assistance.\n`));