@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.
- package/.env.example +55 -48
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +212 -2
- package/dist/agent/agent.js +428 -38
- package/dist/cli/banner.d.ts +60 -0
- package/dist/cli/banner.js +199 -0
- package/dist/cli/cliPrompt.d.ts +69 -0
- package/dist/cli/cliPrompt.js +287 -0
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/mcp.d.ts +17 -0
- package/dist/cli/commands/mcp.js +121 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +97 -45
- package/dist/cli/commands/workflow.d.ts +18 -0
- package/dist/cli/commands/workflow.js +314 -43
- package/dist/cli/repl.js +219 -132
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -0
- package/dist/config/config.d.ts +40 -0
- package/dist/config/config.js +45 -73
- package/dist/index.js +80 -13
- package/dist/memory/briefing.d.ts +10 -0
- package/dist/memory/briefing.js +69 -1
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +124 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +90 -2
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- 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
|
|
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,
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
console.log(
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
'
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
+
turnSpinner.stop();
|
|
422
478
|
console.log(line);
|
|
423
479
|
tickStatus('Thinking');
|
|
424
|
-
|
|
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
|
-
|
|
536
|
+
turnSpinner.stop();
|
|
477
537
|
console.log(line);
|
|
478
538
|
tickStatus('Thinking');
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
507
|
-
// spiking the same skill. The skill memetic potential
|
|
508
|
-
// server-side on its own half-life; this just stops
|
|
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-
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
|
|
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: '/
|
|
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: '
|
|
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`));
|