@kinqs/brainrouter-cli 0.3.6 → 0.3.7
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/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/dist/agent/agent.d.ts +12 -1
- package/dist/agent/agent.js +134 -18
- package/dist/cli/banner.d.ts +20 -0
- package/dist/cli/banner.js +47 -14
- package/dist/cli/cliPrompt.d.ts +40 -3
- package/dist/cli/cliPrompt.js +52 -25
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +13 -11
- package/dist/cli/commands/mcp.js +239 -74
- package/dist/cli/commands/orchestration.js +18 -0
- package/dist/cli/commands/ui.js +117 -58
- package/dist/cli/commands/workflow.d.ts +2 -0
- package/dist/cli/commands/workflow.js +54 -8
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +65 -0
- package/dist/cli/ink/Picker.js +133 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +571 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +73 -0
- package/dist/cli/ink/toolFormat.js +180 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +43 -712
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +12 -0
- package/dist/config/config.js +45 -3
- package/dist/index.js +148 -206
- package/dist/memory/briefing.d.ts +1 -1
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +10 -1
- package/dist/orchestration/tools.js +48 -4
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.js +5 -1
- package/dist/runtime/mcpClient.js +14 -11
- package/dist/runtime/mcpPool.d.ts +162 -0
- package/dist/runtime/mcpPool.js +423 -0
- package/dist/runtime/mcpUtils.d.ts +3 -1
- package/package.json +8 -2
- package/.env.example +0 -116
package/dist/cli/repl.js
CHANGED
|
@@ -1,21 +1,6 @@
|
|
|
1
|
-
import readline from 'node:readline';
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import chalk from 'chalk';
|
|
5
|
-
import { spinner } from './spinner.js';
|
|
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, buildGoalContinuationPrompt, formatBudget, goalHasBudgetLeft, 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 { 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';
|
|
19
4
|
// Category dispatch — extracted slash-command handlers. Each module exports
|
|
20
5
|
// a tryHandleX(ctx) that returns true iff it matched the command. Walked
|
|
21
6
|
// in order; first match wins, no match falls through to the legacy switch.
|
|
@@ -27,22 +12,23 @@ import { tryHandleOrchestrationCommand } from './commands/orchestration.js';
|
|
|
27
12
|
import { tryHandleSessionCommand } from './commands/session.js';
|
|
28
13
|
import { tryHandleGuardCommand } from './commands/guard.js';
|
|
29
14
|
import { tryHandleMcpCommand } from './commands/mcp.js';
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
showSectionPrefix: false,
|
|
34
|
-
}));
|
|
15
|
+
import { tryHandleInitCommand } from './commands/init.js';
|
|
16
|
+
import { tryHandleConfigCommand } from './commands/config.js';
|
|
17
|
+
import { tryHandleLoginCommand } from './commands/login.js';
|
|
35
18
|
/**
|
|
36
19
|
* All slash commands the REPL recognizes. Used for tab autocomplete and for
|
|
37
20
|
* the readline completer. Keep alphabetically grouped roughly by surface area.
|
|
21
|
+
*
|
|
22
|
+
* The Ink chat REPL (cli/ink/runChat.tsx) consumes this same list for its
|
|
23
|
+
* inline slash palette so both surfaces stay in lockstep as new commands land.
|
|
38
24
|
*/
|
|
39
|
-
const SLASH_COMMANDS = [
|
|
25
|
+
export const SLASH_COMMANDS = [
|
|
40
26
|
'/help', '/status', '/workspace', '/where', '/tools', '/skills', '/plan', '/transcript',
|
|
41
27
|
'/doctor', '/config', '/diff', '/commit', '/clear', '/compact', '/exit', '/quit',
|
|
42
28
|
'/roles', '/agents', '/agent', '/spawn', '/wait',
|
|
43
29
|
'/spec', '/feature-dev', '/grill-me', '/review', '/implement-plan', '/skill', '/workflow', '/workflows', '/approve',
|
|
44
30
|
'/memory', '/recall', '/briefing', '/scenes', '/working', '/forget',
|
|
45
|
-
'/init', '/sessions', '/resume', '/model', '/mcp',
|
|
31
|
+
'/init', '/login', '/sessions', '/resume', '/model', '/mcp',
|
|
46
32
|
'/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop',
|
|
47
33
|
'/continue', '/auto-review', '/vim', '/statusline', '/quiet',
|
|
48
34
|
'/handover', '/explain', '/trace', '/failed', '/verify', '/audit',
|
|
@@ -53,686 +39,6 @@ const SLASH_COMMANDS = [
|
|
|
53
39
|
'/feedback', '/rollout', '/ps', '/stop', '/logout', '/apps', '/plugins',
|
|
54
40
|
'/experimental', '/memories', '/debug-config', '/mention', '/keymap', '/ide',
|
|
55
41
|
];
|
|
56
|
-
export function startREPL(agent, mcpClient, config, workspace) {
|
|
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.
|
|
64
|
-
if (!mcpClient.isConnected()) {
|
|
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.'));
|
|
68
|
-
}
|
|
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'));
|
|
72
|
-
const rl = readline.createInterface({
|
|
73
|
-
input: process.stdin,
|
|
74
|
-
output: process.stdout,
|
|
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> '),
|
|
87
|
-
// Tab-completion: complete slash commands when the line begins with "/"
|
|
88
|
-
// and complete workspace file paths when the user is mid-`@mention`.
|
|
89
|
-
completer: (line) => {
|
|
90
|
-
const atMatch = line.match(/@([^\s]*)$/);
|
|
91
|
-
if (atMatch) {
|
|
92
|
-
const partial = atMatch[1];
|
|
93
|
-
const candidates = completeWorkspacePath(agent.workspaceRoot, partial);
|
|
94
|
-
return [candidates.map((c) => `@${c}`), `@${partial}`];
|
|
95
|
-
}
|
|
96
|
-
if (line.startsWith('/')) {
|
|
97
|
-
const hits = SLASH_COMMANDS.filter((cmd) => cmd.startsWith(line));
|
|
98
|
-
return [hits.length ? hits : SLASH_COMMANDS.slice(), line];
|
|
99
|
-
}
|
|
100
|
-
return [[], line];
|
|
101
|
-
},
|
|
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
|
-
}
|
|
117
|
-
// GitHub PR detection cache. `gh pr view` takes ~300ms and prompts often
|
|
118
|
-
// refresh many times per turn; cache the result for 30s. Returns either
|
|
119
|
-
// a string like "#42" or null when there's no PR / gh not installed.
|
|
120
|
-
let prCache = null;
|
|
121
|
-
const PR_CACHE_TTL_MS = 30_000;
|
|
122
|
-
const detectGitHubPR = (cwd) => {
|
|
123
|
-
const now = Date.now();
|
|
124
|
-
if (prCache && now - prCache.cachedAt < PR_CACHE_TTL_MS)
|
|
125
|
-
return prCache.value;
|
|
126
|
-
let value = null;
|
|
127
|
-
try {
|
|
128
|
-
const out = execSync('gh pr view --json number,title 2>/dev/null', {
|
|
129
|
-
cwd,
|
|
130
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
131
|
-
timeout: 1500,
|
|
132
|
-
}).toString().trim();
|
|
133
|
-
if (out) {
|
|
134
|
-
const parsed = JSON.parse(out);
|
|
135
|
-
if (typeof parsed.number === 'number')
|
|
136
|
-
value = `#${parsed.number}`;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
// gh missing, not a PR branch, or not a github repo — fine.
|
|
141
|
-
}
|
|
142
|
-
prCache = { value, cachedAt: now };
|
|
143
|
-
return value;
|
|
144
|
-
};
|
|
145
|
-
// Reflect the current access mode and any configured statusline segments
|
|
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.
|
|
149
|
-
const renderStatusline = () => {
|
|
150
|
-
const prefs = readPreferences(agent.workspaceRoot);
|
|
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(' · ');
|
|
161
|
-
};
|
|
162
|
-
const refreshPromptForMode = () => {
|
|
163
|
-
const mode = agent.getAccessMode();
|
|
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;
|
|
172
|
-
const line = renderStatusline();
|
|
173
|
-
rl.setPrompt(accent(`brainrouter[${line}]> `));
|
|
174
|
-
// The terminal title shares the same trigger conditions as the prompt:
|
|
175
|
-
// any time the prompt redraws (mode change, post-turn, post-spawn), the
|
|
176
|
-
// awaiting-input count or active model may have shifted. Cheap call.
|
|
177
|
-
refreshTerminalTitle();
|
|
178
|
-
};
|
|
179
|
-
// When a /goal is active, after each turn we schedule the NEXT turn
|
|
180
|
-
// automatically. The flag tracks whether such a continuation is pending so
|
|
181
|
-
// user input (next line typed) cancels it before it fires. Declared early
|
|
182
|
-
// so refreshTerminalTitle() (below) can read it for the awaiting-count.
|
|
183
|
-
let pendingContinuation = false;
|
|
184
|
-
// Returns the number of child agents currently in `pending` or `running`
|
|
185
|
-
// status — used by the tab title to surface "needs attention" counts.
|
|
186
|
-
const getRunningChildCount = () => {
|
|
187
|
-
try {
|
|
188
|
-
const sessions = listSessions(agent.workspaceRoot);
|
|
189
|
-
return sessions.filter((s) => s.status === 'pending' || s.status === 'running').length;
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
return 0;
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
// Dynamic terminal tab title. Refreshed at startup AND whenever the agent
|
|
196
|
-
// count / awaiting state changes (after each turn, post-spawn). Prefixes
|
|
197
|
-
// a "(N) " count when there's work requiring attention — pending
|
|
198
|
-
// continuation OR a running child — so background tabs surface attention
|
|
199
|
-
// without focus.
|
|
200
|
-
const refreshTerminalTitle = () => {
|
|
201
|
-
try {
|
|
202
|
-
const prefs = readPreferences(agent.workspaceRoot);
|
|
203
|
-
const cfg = prefs.terminalTitle ?? 'model,session';
|
|
204
|
-
if (cfg.toLowerCase() === 'off')
|
|
205
|
-
return;
|
|
206
|
-
const segs = cfg.split(',').map((s) => s.trim()).filter(Boolean);
|
|
207
|
-
const parts = [];
|
|
208
|
-
for (const seg of segs) {
|
|
209
|
-
if (seg === 'model')
|
|
210
|
-
parts.push(agent.getModel());
|
|
211
|
-
else if (seg === 'session')
|
|
212
|
-
parts.push(agent.sessionKey.slice(0, 24));
|
|
213
|
-
else if (seg === 'mode')
|
|
214
|
-
parts.push(agent.getAccessMode());
|
|
215
|
-
else if (seg === 'branch') {
|
|
216
|
-
try {
|
|
217
|
-
parts.push(execSync('git rev-parse --abbrev-ref HEAD', {
|
|
218
|
-
cwd: agent.workspaceRoot,
|
|
219
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
220
|
-
}).toString().trim());
|
|
221
|
-
}
|
|
222
|
-
catch { /* not a git repo */ }
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (parts.length === 0)
|
|
226
|
-
return;
|
|
227
|
-
// Awaiting-input prefix: pendingContinuation OR any running children.
|
|
228
|
-
const awaitingCount = (pendingContinuation ? 1 : 0) + getRunningChildCount();
|
|
229
|
-
const prefix = awaitingCount > 0 ? `(${awaitingCount}) ` : '';
|
|
230
|
-
process.stdout.write(`\x1b]0;${prefix}brainrouter · ${parts.join(' · ')}\x07`);
|
|
231
|
-
}
|
|
232
|
-
catch { /* terminal doesn't support OSC titles */ }
|
|
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
|
-
};
|
|
248
|
-
refreshPromptForMode();
|
|
249
|
-
refreshTerminalTitle();
|
|
250
|
-
// Vim mode: readline supports editorMode 'vi' via setRawMode + tty.
|
|
251
|
-
// We honor the persisted preference at startup so users don't have to
|
|
252
|
-
// re-toggle it each session.
|
|
253
|
-
const initialPrefs = readPreferences(agent.workspaceRoot);
|
|
254
|
-
if (initialPrefs.editorMode === 'vi') {
|
|
255
|
-
process.stdout.write(chalk.gray('Vim mode enabled (composer uses vi keybindings). Toggle with /vim.\n'));
|
|
256
|
-
// Node's readline doesn't natively expose vi mode; we approximate by
|
|
257
|
-
// emitting a hint and trusting the user's terminal/inputrc. A future
|
|
258
|
-
// pass can swap in a custom keypress handler for full Vim semantics.
|
|
259
|
-
}
|
|
260
|
-
// Shift+Tab cycles the access mode.
|
|
261
|
-
// Order: read → write → shell → read …
|
|
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.
|
|
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;
|
|
275
|
-
if (key && key.name === 'tab' && key.shift) {
|
|
276
|
-
const cycle = ['read', 'write', 'shell'];
|
|
277
|
-
const current = agent.getAccessMode();
|
|
278
|
-
const next = cycle[(cycle.indexOf(current) + 1) % cycle.length];
|
|
279
|
-
agent.setAccessMode(next);
|
|
280
|
-
refreshPromptForMode();
|
|
281
|
-
process.stdout.write(`\n${chalk.gray(`Access mode → ${next}`)}\n`);
|
|
282
|
-
rl.prompt();
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
// Publish the rl interface to cliPrompt.ts so out-of-scope helpers
|
|
286
|
-
// (askYesNo, safePrintAbovePrompt) can talk to the same stdin/stdout
|
|
287
|
-
// pair the REPL owns. Cleared on close.
|
|
288
|
-
setActiveReadline(rl);
|
|
289
|
-
rl.on('close', () => { setActiveReadline(undefined); });
|
|
290
|
-
rl.prompt();
|
|
291
|
-
let isProcessing = false;
|
|
292
|
-
// (pendingContinuation declared earlier alongside the title refresh helpers.)
|
|
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
|
-
}
|
|
324
|
-
};
|
|
325
|
-
/**
|
|
326
|
-
* Print a line of output while the readline prompt is showing without
|
|
327
|
-
* clobbering whatever the user is mid-typing. Used by child-agent callbacks
|
|
328
|
-
* that fire AFTER the parent's runTurn returned — the agent's tool events
|
|
329
|
-
* keep streaming for a while because children run detached, and naive
|
|
330
|
-
* console.log + turnSpinner.start() would steal the input row.
|
|
331
|
-
*/
|
|
332
|
-
const safePrintAbovePrompt = (msg) => {
|
|
333
|
-
if (!process.stdout.isTTY) {
|
|
334
|
-
console.log(msg);
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
// \r → column 0, \x1b[2K → clear the whole line, including any prompt + typed text.
|
|
338
|
-
process.stdout.write('\r\x1b[2K');
|
|
339
|
-
console.log(msg);
|
|
340
|
-
// Redraw the prompt and re-render the in-progress input buffer.
|
|
341
|
-
try {
|
|
342
|
-
rl._refreshLine?.();
|
|
343
|
-
}
|
|
344
|
-
catch {
|
|
345
|
-
rl.prompt(true);
|
|
346
|
-
}
|
|
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();
|
|
351
|
-
/** Run a turn programmatically (used by `/continue` and the line handler). */
|
|
352
|
-
const runAgentTurn = async (rawInput) => {
|
|
353
|
-
if (isProcessing) {
|
|
354
|
-
console.log(chalk.yellow('\nA previous turn is still running.\n'));
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
isProcessing = true;
|
|
358
|
-
rl.pause();
|
|
359
|
-
const { expanded, mentions } = expandMentions(rawInput, agent.workspaceRoot);
|
|
360
|
-
if (mentions.length > 0 && !isQuiet()) {
|
|
361
|
-
console.log(chalk.gray(`📎 Attached ${mentions.length} file${mentions.length === 1 ? '' : 's'}: ${mentions.map((m) => m.token).join(', ')}`));
|
|
362
|
-
}
|
|
363
|
-
const startedAt = Date.now();
|
|
364
|
-
const turnSpinner = spinner(chalk.gray('Agent starting...')).start();
|
|
365
|
-
// Once the parent's runTurn returns, child agents may still emit tool
|
|
366
|
-
// events asynchronously. After this flag flips, we MUST NOT touch the
|
|
367
|
-
// spinner (which is already .succeeded) — restarting it would steal the
|
|
368
|
-
// readline row and the user would feel like they can't type.
|
|
369
|
-
let parentDone = false;
|
|
370
|
-
const tickStatus = (status) => {
|
|
371
|
-
if (parentDone)
|
|
372
|
-
return;
|
|
373
|
-
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
374
|
-
const u = agent.lastTurnUsage;
|
|
375
|
-
const tokens = u.calls > 0 ? ` ${u.promptTokens.toLocaleString()}↑ ${u.completionTokens.toLocaleString()}↓` : '';
|
|
376
|
-
turnSpinner.text = chalk.gray(`${status} ${elapsed}s${tokens}`);
|
|
377
|
-
};
|
|
378
|
-
try {
|
|
379
|
-
const answer = await agent.runTurn(expanded, {
|
|
380
|
-
onStatusUpdate: tickStatus,
|
|
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;
|
|
387
|
-
// Render spawn_agent / spawn_agents specially — a one-liner
|
|
388
|
-
// ("Ran agent <role> — <one-line task>") so a fan-out of 5
|
|
389
|
-
// children produces 5 clean lines instead of 5 JSON dumps. The
|
|
390
|
-
// raw JSON is still in the transcript for debugging.
|
|
391
|
-
let line;
|
|
392
|
-
if (name === 'spawn_agent') {
|
|
393
|
-
const role = chalk.magenta(String(args?.role ?? 'agent'));
|
|
394
|
-
const label = args?.label ? chalk.gray(` [${args.label}]`) : '';
|
|
395
|
-
const task = String(args?.prompt ?? '').replace(/\s+/g, ' ').trim();
|
|
396
|
-
const preview = chalk.gray(task.length > 100 ? task.slice(0, 99) + '…' : task);
|
|
397
|
-
line = chalk.gray('🤖 Spawning agent: ') + role + label + chalk.gray(' — ') + preview;
|
|
398
|
-
}
|
|
399
|
-
else if (name === 'spawn_agents') {
|
|
400
|
-
const agents = Array.isArray(args?.agents) ? args.agents : [];
|
|
401
|
-
const summary = agents
|
|
402
|
-
.map((a) => chalk.magenta(String(a?.role ?? 'agent')))
|
|
403
|
-
.join(chalk.gray(', '));
|
|
404
|
-
line = chalk.gray(`🤖 Spawning ${agents.length} agent${agents.length === 1 ? '' : 's'} in parallel: `) + summary;
|
|
405
|
-
}
|
|
406
|
-
else {
|
|
407
|
-
line = chalk.gray('🛞 Calling tool: ') + chalk.cyan(name) + chalk.gray(`(${JSON.stringify(args).slice(0, 240)})`);
|
|
408
|
-
}
|
|
409
|
-
if (parentDone) {
|
|
410
|
-
safePrintAbovePrompt(line);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
turnSpinner.stop();
|
|
414
|
-
console.log(line);
|
|
415
|
-
},
|
|
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
|
-
}
|
|
424
|
-
const line = result.success
|
|
425
|
-
? chalk.green('✓ Tool ') + chalk.cyan(name) + chalk.green(' completed: ') + chalk.gray(result.summary)
|
|
426
|
-
: chalk.red('❌ Tool ') + chalk.cyan(name) + chalk.red(' failed: ') + chalk.yellow(result.summary);
|
|
427
|
-
// Inspection-tool preview: indented under the summary so the user
|
|
428
|
-
// sees the actual result (directory listing, grep matches, glob
|
|
429
|
-
// paths) even when the LLM later replies with only a stub like
|
|
430
|
-
// "I have listed the directory." Capped to a handful of lines in
|
|
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()
|
|
435
|
-
? '\n' + result.preview.split('\n').map((l) => chalk.gray(' ' + l)).join('\n')
|
|
436
|
-
: '';
|
|
437
|
-
const composed = line + previewBlock;
|
|
438
|
-
if (parentDone) {
|
|
439
|
-
safePrintAbovePrompt(composed);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
console.log(composed);
|
|
443
|
-
tickStatus('Thinking');
|
|
444
|
-
turnSpinner.start();
|
|
445
|
-
},
|
|
446
|
-
onPlanUpdate: (items, explanation) => {
|
|
447
|
-
if (parentDone) {
|
|
448
|
-
safePrintAbovePrompt(chalk.gray(`📋 Plan updated (${items.length} item${items.length === 1 ? '' : 's'})`));
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
turnSpinner.stop();
|
|
452
|
-
console.log(chalk.gray('📋 Plan updated:'));
|
|
453
|
-
if (explanation)
|
|
454
|
-
console.log(chalk.gray(` ${explanation}`));
|
|
455
|
-
for (const item of items) {
|
|
456
|
-
const mark = item.status === 'completed' ? chalk.green('✓')
|
|
457
|
-
: item.status === 'in_progress' ? chalk.yellow('⏳')
|
|
458
|
-
: chalk.gray('☐');
|
|
459
|
-
const text = item.status === 'completed' ? chalk.gray(item.step) : item.step;
|
|
460
|
-
console.log(` ${mark} ${text}`);
|
|
461
|
-
}
|
|
462
|
-
tickStatus('Thinking');
|
|
463
|
-
turnSpinner.start();
|
|
464
|
-
},
|
|
465
|
-
onChildComplete: (event) => {
|
|
466
|
-
const head = event.status === 'completed'
|
|
467
|
-
? chalk.green(`🏁 Agent ${event.childId} (${event.role}) completed`)
|
|
468
|
-
: chalk.red(`💥 Agent ${event.childId} (${event.role}) failed`);
|
|
469
|
-
const tail = event.status === 'completed' && event.preview
|
|
470
|
-
? chalk.gray(` — ${event.preview}`)
|
|
471
|
-
: event.error ? chalk.yellow(` — ${event.error}`) : '';
|
|
472
|
-
const line = head + tail;
|
|
473
|
-
if (parentDone) {
|
|
474
|
-
safePrintAbovePrompt(line);
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
turnSpinner.stop();
|
|
478
|
-
console.log(line);
|
|
479
|
-
tickStatus('Thinking');
|
|
480
|
-
turnSpinner.start();
|
|
481
|
-
},
|
|
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;
|
|
487
|
-
let line;
|
|
488
|
-
if (event.kind === 'briefing') {
|
|
489
|
-
const src = event.sources.length > 0 ? event.sources.join(', ') : '(none)';
|
|
490
|
-
line = chalk.gray(`🧠 Briefing: ${event.recordCount} record${event.recordCount === 1 ? '' : 's'} from ${src}`);
|
|
491
|
-
}
|
|
492
|
-
else if (event.kind === 'capture') {
|
|
493
|
-
// Truthful capture line: show sensory rows actually written, and
|
|
494
|
-
// — critically — flag when extraction silently failed. The old
|
|
495
|
-
// line said "Captured turn → memory" even when 0 cognitive
|
|
496
|
-
// records came out the other end, which made the user think
|
|
497
|
-
// their conversation was searchable when nothing of the sort
|
|
498
|
-
// was happening.
|
|
499
|
-
const sensory = event.sensoryRecorded ?? event.messageCount;
|
|
500
|
-
const extracted = event.extractedCount;
|
|
501
|
-
const triggered = event.extractionTriggered;
|
|
502
|
-
const sk = event.sessionKey.slice(0, 12);
|
|
503
|
-
if (event.extractionWarning) {
|
|
504
|
-
line = chalk.yellow(`💾 Captured ${sensory} sensory msg(s) in ${sk}… — ⚠️ ${event.extractionWarning}`);
|
|
505
|
-
}
|
|
506
|
-
else if (triggered && typeof extracted === 'number') {
|
|
507
|
-
if (extracted > 0) {
|
|
508
|
-
line = chalk.gray(`💾 Captured ${sensory} msg(s) → ${extracted} cognitive record(s) extracted (${sk}…)`);
|
|
509
|
-
}
|
|
510
|
-
else {
|
|
511
|
-
// LLM ran successfully but found nothing notable to promote
|
|
512
|
-
// (greeting, trivial exchange, all-duplicates). NOT an error.
|
|
513
|
-
line = chalk.gray(`💾 Captured ${sensory} msg(s) → no new memories worth promoting (${sk}…)`);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
else if (triggered === false) {
|
|
517
|
-
// Sensory landed; extractor below the every-N-turn threshold.
|
|
518
|
-
line = chalk.gray(`💾 Captured ${sensory} msg(s) → sensory buffer (${sk}…)`);
|
|
519
|
-
}
|
|
520
|
-
else {
|
|
521
|
-
line = chalk.gray(`💾 Captured ${sensory} msg(s) → memory (${sk}…)`);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
else if (event.kind === 'citation' && event.recordIds.length > 0) {
|
|
525
|
-
line = chalk.gray(`📌 Reinforced ${event.recordIds.length} record${event.recordIds.length === 1 ? '' : 's'}: ${event.recordIds.slice(0, 3).join(', ')}${event.recordIds.length > 3 ? '…' : ''}`);
|
|
526
|
-
}
|
|
527
|
-
else if (event.kind === 'contradiction') {
|
|
528
|
-
line = chalk.yellow(`⚠️ Memory contradiction: ${event.warning.slice(0, 140)}`);
|
|
529
|
-
}
|
|
530
|
-
if (!line)
|
|
531
|
-
return;
|
|
532
|
-
if (parentDone) {
|
|
533
|
-
safePrintAbovePrompt(line);
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
turnSpinner.stop();
|
|
537
|
-
console.log(line);
|
|
538
|
-
tickStatus('Thinking');
|
|
539
|
-
turnSpinner.start();
|
|
540
|
-
},
|
|
541
|
-
});
|
|
542
|
-
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
543
|
-
const u = agent.lastTurnUsage;
|
|
544
|
-
const tokenSummary = u.calls > 0
|
|
545
|
-
? chalk.gray(` · ${u.promptTokens.toLocaleString()} in / ${u.completionTokens.toLocaleString()} out across ${u.calls} call${u.calls === 1 ? '' : 's'}`)
|
|
546
|
-
: '';
|
|
547
|
-
parentDone = true;
|
|
548
|
-
turnSpinner.succeed(chalk.green(`Done!${chalk.gray(` ${elapsed}s`)}${tokenSummary}`));
|
|
549
|
-
const prefsForRender = readPreferences(agent.workspaceRoot);
|
|
550
|
-
const rendered = prefsForRender.rawScrollback ? answer : marked.parse(answer);
|
|
551
|
-
console.log('\n' + rendered + '\n');
|
|
552
|
-
const warning = agent.takeContradictionWarning();
|
|
553
|
-
if (warning) {
|
|
554
|
-
console.log(chalk.yellow(`⚠️ Memory: ${warning}`));
|
|
555
|
-
console.log(chalk.gray(` Use /memory or /briefing to investigate, /forget <id> to archive obsolete records.\n`));
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
catch (err) {
|
|
559
|
-
parentDone = true;
|
|
560
|
-
turnSpinner.fail(chalk.red('Execution failed'));
|
|
561
|
-
console.error(chalk.red(`\nError: ${err.message}\n`));
|
|
562
|
-
}
|
|
563
|
-
finally {
|
|
564
|
-
isProcessing = false;
|
|
565
|
-
// Clear any active skill latched by /skill / /feature-dev / /spec /
|
|
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.
|
|
573
|
-
agent.activeSkill = undefined;
|
|
574
|
-
agent.refreshSystemPrompt();
|
|
575
|
-
// Auto-continuation logic. Rules:
|
|
576
|
-
// - the goal must be active (not paused / complete / blocked / usage_limited)
|
|
577
|
-
// - the turn made at least one tool call (prose-only turns are anti-spin)
|
|
578
|
-
// - we still have iteration AND token budget left
|
|
579
|
-
// - the agent didn't call goal_complete / goal_blocked this turn
|
|
580
|
-
//
|
|
581
|
-
// BEFORE checking, accumulate this turn's tokens into the goal's
|
|
582
|
-
// running tally. If that tips us over a token cap, transition to
|
|
583
|
-
// `usage_limited` instead of continuing — same effect as exhausting
|
|
584
|
-
// the iteration cap, but distinguishable in status.
|
|
585
|
-
let goalAfter = readGoal(agent.workspaceRoot, agent.sessionKey);
|
|
586
|
-
if (goalAfter && goalAfter.budget.maxTokens) {
|
|
587
|
-
const delta = (agent.lastTurnUsage?.promptTokens ?? 0) + (agent.lastTurnUsage?.completionTokens ?? 0);
|
|
588
|
-
if (delta > 0) {
|
|
589
|
-
const updated = addGoalTokens(agent.workspaceRoot, agent.sessionKey, delta);
|
|
590
|
-
if (updated)
|
|
591
|
-
goalAfter = updated;
|
|
592
|
-
}
|
|
593
|
-
if (goalAfter &&
|
|
594
|
-
goalAfter.status === 'active' &&
|
|
595
|
-
typeof goalAfter.budget.maxTokens === 'number' &&
|
|
596
|
-
(goalAfter.budget.tokensUsed ?? 0) >= goalAfter.budget.maxTokens) {
|
|
597
|
-
const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, `Token budget reached: ${(goalAfter.budget.tokensUsed ?? 0).toLocaleString()} of ${goalAfter.budget.maxTokens.toLocaleString()} used.`);
|
|
598
|
-
if (limited)
|
|
599
|
-
goalAfter = limited;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
const shouldContinue = !!goalAfter &&
|
|
603
|
-
goalAfter.status === 'active' &&
|
|
604
|
-
goalHasBudgetLeft(goalAfter) &&
|
|
605
|
-
agent.lastTurnToolCalls > 0 &&
|
|
606
|
-
agent.lastGoalTransition === undefined;
|
|
607
|
-
if (goalAfter && goalAfter.status === 'complete') {
|
|
608
|
-
console.log(chalk.green(`\n🎯 Goal achieved — ${goalAfter.blockedReason ?? 'evidence on record.'}\n`));
|
|
609
|
-
}
|
|
610
|
-
else if (goalAfter && goalAfter.status === 'blocked') {
|
|
611
|
-
console.log(chalk.yellow(`\n🚧 Goal blocked: ${goalAfter.blockedReason ?? '(no reason)'}\n`));
|
|
612
|
-
console.log(chalk.gray(` Resolve the blocker, then /goal resume to continue.\n`));
|
|
613
|
-
}
|
|
614
|
-
else if (goalAfter && goalAfter.status === 'usage_limited') {
|
|
615
|
-
console.log(chalk.yellow(`\n⏸ Goal hit usage limit: ${goalAfter.blockedReason ?? 'budget exhausted'}.`));
|
|
616
|
-
console.log(chalk.gray(` Raise the cap with /goal budget <n> or /goal tokens <n>, then /goal resume.\n`));
|
|
617
|
-
}
|
|
618
|
-
else if (goalAfter && goalAfter.status === 'active' && !goalHasBudgetLeft(goalAfter)) {
|
|
619
|
-
// Iteration cap reached — transition to usage_limited so the user
|
|
620
|
-
// gets a consistent resumable state regardless of which cap tripped.
|
|
621
|
-
const reason = `Iteration budget exhausted (${goalAfter.budget.iterationsUsed}/${formatBudget(goalAfter.budget.maxIterations)}).`;
|
|
622
|
-
const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, reason);
|
|
623
|
-
console.log(chalk.yellow(`\n⏸ ${reason} Extend with /goal budget <n> and /goal resume, mark /goal complete, or /goal clear.\n`));
|
|
624
|
-
if (limited)
|
|
625
|
-
goalAfter = limited;
|
|
626
|
-
}
|
|
627
|
-
else if (goalAfter && goalAfter.status === 'active' && agent.lastTurnToolCalls === 0) {
|
|
628
|
-
console.log(chalk.gray(`(goal continuation suppressed: last turn made no tool calls — anti-spin)\n`));
|
|
629
|
-
}
|
|
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.
|
|
639
|
-
refreshPromptForMode(); // pick up token-meter / branch updates
|
|
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();
|
|
644
|
-
if (shouldContinue && goalAfter) {
|
|
645
|
-
pendingContinuation = true;
|
|
646
|
-
const next = goalAfter.budget.iterationsUsed + 1;
|
|
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)`));
|
|
655
|
-
const followUp = buildGoalContinuationPrompt(goalAfter, agent.lastUserPrompt, agent.lastAnswer);
|
|
656
|
-
setImmediate(() => {
|
|
657
|
-
if (!pendingContinuation || isProcessing)
|
|
658
|
-
return; // user cancelled or busy
|
|
659
|
-
pendingContinuation = false;
|
|
660
|
-
tickGoalIteration(agent.workspaceRoot, agent.sessionKey);
|
|
661
|
-
void runAgentTurn(followUp);
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
};
|
|
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();
|
|
671
|
-
// User typed: any pending goal continuation is cancelled.
|
|
672
|
-
if (pendingContinuation) {
|
|
673
|
-
pendingContinuation = false;
|
|
674
|
-
console.log(chalk.gray('(goal continuation cancelled by user input)'));
|
|
675
|
-
}
|
|
676
|
-
const input = line.trim();
|
|
677
|
-
if (!input) {
|
|
678
|
-
rl.prompt();
|
|
679
|
-
return;
|
|
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
|
-
}
|
|
688
|
-
if (input.startsWith('/')) {
|
|
689
|
-
// Split on any whitespace, not a literal space. Without this, a slash
|
|
690
|
-
// command followed by a tab (autocomplete completion that wasn't
|
|
691
|
-
// consumed) or a trailing newline ends up as command="/help\t" which
|
|
692
|
-
// would fall through to "Unknown slash command".
|
|
693
|
-
const parts = input.trim().split(/\s+/);
|
|
694
|
-
const command = parts[0].toLowerCase();
|
|
695
|
-
const args = parts.slice(1);
|
|
696
|
-
// Wrap the slash-command dispatcher so a thrown error or rejected
|
|
697
|
-
// promise can never leave the REPL without a prompt. Without this, a
|
|
698
|
-
// bug inside any /command (file write, MCP call, etc.) bricks the
|
|
699
|
-
// session because the user never sees the prompt come back.
|
|
700
|
-
try {
|
|
701
|
-
await handleSlashCommand(command, args, agent, mcpClient, config, rl, {
|
|
702
|
-
refreshPromptForMode,
|
|
703
|
-
isProcessing: () => isProcessing,
|
|
704
|
-
runAgentTurn: (prompt) => { void runAgentTurn(prompt); },
|
|
705
|
-
runAgentTurnAsync: (prompt) => runAgentTurn(prompt),
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
catch (err) {
|
|
709
|
-
console.error(chalk.red(`\nSlash command "${command}" failed: ${err?.message ?? err}\n`));
|
|
710
|
-
}
|
|
711
|
-
finally {
|
|
712
|
-
// The /continue and /side/btw cases own their own prompt cycle via
|
|
713
|
-
// runAgentTurn — only re-prompt if no turn is in flight.
|
|
714
|
-
if (!isProcessing)
|
|
715
|
-
rl.prompt();
|
|
716
|
-
}
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
if (isProcessing) {
|
|
720
|
-
console.log(chalk.yellow('\nA previous turn is still running. Wait for the prompt before sending another message.\n'));
|
|
721
|
-
rl.prompt();
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
await runAgentTurn(input);
|
|
725
|
-
});
|
|
726
|
-
rl.on('SIGINT', async () => {
|
|
727
|
-
console.log(chalk.yellow('\nExiting session...'));
|
|
728
|
-
rl.close();
|
|
729
|
-
});
|
|
730
|
-
rl.on('close', async () => {
|
|
731
|
-
await mcpClient.close();
|
|
732
|
-
console.log(chalk.bold.hex('#CC9166')('Goodbye!\n'));
|
|
733
|
-
process.exit(0);
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
42
|
const HELP_CATEGORIES = [
|
|
737
43
|
{
|
|
738
44
|
key: 'session',
|
|
@@ -742,7 +48,8 @@ const HELP_CATEGORIES = [
|
|
|
742
48
|
{ cmd: '/workspace', desc: 'Active workspace and session identity' },
|
|
743
49
|
{ cmd: '/where', desc: 'Single-screen view of workspace, workflow, goal, plan, recall, children' },
|
|
744
50
|
{ cmd: '/doctor', desc: 'Config, connection, memory extraction health' },
|
|
745
|
-
{ cmd: '/config', desc: '
|
|
51
|
+
{ cmd: '/config [key] [value]', desc: 'Settings panel; `/config theme dark` to set; `/config raw` for JSON dump' },
|
|
52
|
+
{ cmd: '/login', desc: 'In-REPL MCP profile editor (transport → fields → probe → save)' },
|
|
746
53
|
{ cmd: '/clear', desc: 'Clear chat history for the active session' },
|
|
747
54
|
{ cmd: '/compact', desc: 'LLM-driven compaction of the active session' },
|
|
748
55
|
{ cmd: '/new [label]', desc: 'Start a new chat with a fresh session key' },
|
|
@@ -751,7 +58,7 @@ const HELP_CATEGORIES = [
|
|
|
751
58
|
{ cmd: '/resume <id>', desc: 'Resume a previous session by sessionKey' },
|
|
752
59
|
{ cmd: '/sessions', desc: 'List persisted sessions for this workspace' },
|
|
753
60
|
{ cmd: '/side <q> /btw <q>', desc: 'Ephemeral side conversation in a forked session' },
|
|
754
|
-
{ cmd: '/init', desc: '
|
|
61
|
+
{ cmd: '/init', desc: 'Re-run the onboarding wizard (Theme → Provider → API key → Model → MCP → AGENT.md)' },
|
|
755
62
|
{ cmd: '/exit /quit', desc: 'Close MCP connection and exit' },
|
|
756
63
|
],
|
|
757
64
|
},
|
|
@@ -905,6 +212,28 @@ export function renderHelp(category) {
|
|
|
905
212
|
}
|
|
906
213
|
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'));
|
|
907
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Look up a one-line description for a slash command by walking the
|
|
217
|
+
* help registry. Used to populate the slash-suggest popup. Falls back
|
|
218
|
+
* to a generic placeholder if the command isn't documented (those
|
|
219
|
+
* commands still work — they just won't get a custom description
|
|
220
|
+
* inside the popup until someone adds them to `HELP_CATEGORIES`).
|
|
221
|
+
*
|
|
222
|
+
* Description text in `HELP_CATEGORIES` sometimes carries a parenthesised
|
|
223
|
+
* argument hint (e.g. "/config [key] [value]"); we strip everything
|
|
224
|
+
* after the first space when matching by cmd token so e.g. `/config`
|
|
225
|
+
* matches `cmd: "/config [key] [value]"`.
|
|
226
|
+
*/
|
|
227
|
+
export function lookupSlashDescription(cmd) {
|
|
228
|
+
for (const cat of HELP_CATEGORIES) {
|
|
229
|
+
for (const entry of cat.entries) {
|
|
230
|
+
const token = entry.cmd.split(/\s+/)[0];
|
|
231
|
+
if (token === cmd)
|
|
232
|
+
return entry.desc;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return '(no description)';
|
|
236
|
+
}
|
|
908
237
|
function printHelpCategory(c) {
|
|
909
238
|
console.log(chalk.bold(`\n${c.title}:`));
|
|
910
239
|
// Find max command-column width for alignment.
|
|
@@ -913,12 +242,21 @@ function printHelpCategory(c) {
|
|
|
913
242
|
console.log(` ${chalk.cyan(e.cmd.padEnd(colWidth))} ${chalk.gray(e.desc)}`);
|
|
914
243
|
}
|
|
915
244
|
}
|
|
916
|
-
async function handleSlashCommand(command, args, agent, mcpClient, config, rl, ctx) {
|
|
245
|
+
export async function handleSlashCommand(command, args, agent, mcpClient, config, rl, ctx) {
|
|
917
246
|
// Category dispatch — each extracted module returns true iff it matched
|
|
918
247
|
// the command. New categories should be added here as they're extracted
|
|
919
248
|
// from the giant switch below. Long-term goal: shrink the switch to
|
|
920
249
|
// nothing so this dispatch is the only entrypoint.
|
|
921
250
|
const cmdCtx = { command, args, agent, mcpClient, config, rl, repl: ctx };
|
|
251
|
+
// 0.3.7 wizard / config / login dispatchers run first so they shadow
|
|
252
|
+
// the legacy /init + /config handlers in ui.ts (which still ship
|
|
253
|
+
// their old behaviour as fallbacks but are now superseded).
|
|
254
|
+
if (await tryHandleInitCommand(cmdCtx))
|
|
255
|
+
return;
|
|
256
|
+
if (await tryHandleConfigCommand(cmdCtx))
|
|
257
|
+
return;
|
|
258
|
+
if (await tryHandleLoginCommand(cmdCtx))
|
|
259
|
+
return;
|
|
922
260
|
if (await tryHandleMemoryCommand(cmdCtx))
|
|
923
261
|
return;
|
|
924
262
|
if (await tryHandleUiCommand(cmdCtx))
|
|
@@ -939,13 +277,6 @@ async function handleSlashCommand(command, args, agent, mcpClient, config, rl, c
|
|
|
939
277
|
// here didn't match any handler.
|
|
940
278
|
console.log(chalk.red(`\nUnknown slash command: ${command}. Type /help for assistance.\n`));
|
|
941
279
|
}
|
|
942
|
-
// runOrchestrationPrompt was the second-class turn pipeline used by /spawn,
|
|
943
|
-
// /wait, /kill, /commit, /approve, /spec, /feature-dev, /review,
|
|
944
|
-
// /implement-plan, /skill. It lacked goal continuation, the isProcessing
|
|
945
|
-
// lock, /raw honoring, contradiction surfacing, and token summary — so any
|
|
946
|
-
// command that took the second-class path felt visibly weaker than a plain
|
|
947
|
-
// prompt. Removed in favor of routing every command through the closure's
|
|
948
|
-
// runAgentTurn (which has all of the above).
|
|
949
280
|
/**
|
|
950
281
|
* Tab-completion source for `@path/to/file` mentions. Given a partial workspace
|
|
951
282
|
* path, return the matching files and directories one level deep. Stays inside
|