@kinqs/brainrouter-cli 0.3.6 → 0.3.8

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 (129) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/changelog/0.2.0.md +15 -0
  8. package/changelog/0.3.0.md +20 -0
  9. package/changelog/0.3.1.md +22 -0
  10. package/changelog/0.3.2.md +15 -0
  11. package/changelog/0.3.3.md +19 -0
  12. package/changelog/0.3.4.md +20 -0
  13. package/changelog/0.3.5.md +9 -0
  14. package/changelog/0.3.6.md +9 -0
  15. package/changelog/0.3.7.md +20 -0
  16. package/changelog/0.3.8.md +30 -0
  17. package/changelog/README.md +41 -0
  18. package/dist/agent/agent.d.ts +34 -1
  19. package/dist/agent/agent.js +372 -79
  20. package/dist/agent/toolCallRecovery.d.ts +57 -0
  21. package/dist/agent/toolCallRecovery.js +130 -0
  22. package/dist/agent/toolSafety.d.ts +17 -0
  23. package/dist/agent/toolSafety.js +102 -0
  24. package/dist/cli/banner.d.ts +20 -0
  25. package/dist/cli/banner.js +47 -14
  26. package/dist/cli/cliPrompt.d.ts +40 -3
  27. package/dist/cli/cliPrompt.js +117 -25
  28. package/dist/cli/commands/_context.d.ts +3 -1
  29. package/dist/cli/commands/_helpers.d.ts +1 -1
  30. package/dist/cli/commands/config.d.ts +46 -0
  31. package/dist/cli/commands/config.js +1042 -0
  32. package/dist/cli/commands/init.d.ts +20 -0
  33. package/dist/cli/commands/init.js +64 -0
  34. package/dist/cli/commands/login.d.ts +13 -0
  35. package/dist/cli/commands/login.js +179 -0
  36. package/dist/cli/commands/mcp.d.ts +13 -11
  37. package/dist/cli/commands/mcp.js +261 -74
  38. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  39. package/dist/cli/commands/mcpInstall.js +87 -0
  40. package/dist/cli/commands/orchestration.js +51 -0
  41. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  42. package/dist/cli/commands/releaseNotes.js +109 -0
  43. package/dist/cli/commands/schedule.d.ts +18 -0
  44. package/dist/cli/commands/schedule.js +189 -0
  45. package/dist/cli/commands/ui.js +119 -60
  46. package/dist/cli/commands/workflow.d.ts +2 -0
  47. package/dist/cli/commands/workflow.js +54 -8
  48. package/dist/cli/ink/ChatApp.d.ts +206 -0
  49. package/dist/cli/ink/ChatApp.js +493 -0
  50. package/dist/cli/ink/Frame.d.ts +26 -0
  51. package/dist/cli/ink/Frame.js +5 -0
  52. package/dist/cli/ink/Picker.d.ts +71 -0
  53. package/dist/cli/ink/Picker.js +168 -0
  54. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  55. package/dist/cli/ink/SlashPalette.js +136 -0
  56. package/dist/cli/ink/TextField.d.ts +34 -0
  57. package/dist/cli/ink/TextField.js +47 -0
  58. package/dist/cli/ink/WizardApp.d.ts +7 -0
  59. package/dist/cli/ink/WizardApp.js +422 -0
  60. package/dist/cli/ink/ambientChat.d.ts +34 -0
  61. package/dist/cli/ink/ambientChat.js +7 -0
  62. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  63. package/dist/cli/ink/consoleCapture.js +33 -0
  64. package/dist/cli/ink/markdownRender.d.ts +41 -0
  65. package/dist/cli/ink/markdownRender.js +278 -0
  66. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  67. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  68. package/dist/cli/ink/runChat.d.ts +34 -0
  69. package/dist/cli/ink/runChat.js +682 -0
  70. package/dist/cli/ink/runPicker.d.ts +31 -0
  71. package/dist/cli/ink/runPicker.js +139 -0
  72. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  73. package/dist/cli/ink/runSlashPalette.js +33 -0
  74. package/dist/cli/ink/runWizard.d.ts +22 -0
  75. package/dist/cli/ink/runWizard.js +133 -0
  76. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  77. package/dist/cli/ink/stdinHandoff.js +78 -0
  78. package/dist/cli/ink/toolFormat.d.ts +75 -0
  79. package/dist/cli/ink/toolFormat.js +206 -0
  80. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  81. package/dist/cli/ink/useTerminalSize.js +26 -0
  82. package/dist/cli/repl.d.ts +25 -3
  83. package/dist/cli/repl.js +52 -714
  84. package/dist/cli/slashSuggest.d.ts +32 -0
  85. package/dist/cli/slashSuggest.js +146 -0
  86. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  87. package/dist/cli/wizard/modelsApi.js +166 -0
  88. package/dist/cli/wizard/picker.d.ts +202 -0
  89. package/dist/cli/wizard/picker.js +547 -0
  90. package/dist/cli/wizard/providers.d.ts +86 -0
  91. package/dist/cli/wizard/providers.js +190 -0
  92. package/dist/cli/wizard/runner.d.ts +13 -0
  93. package/dist/cli/wizard/runner.js +488 -0
  94. package/dist/cli/wizard/types.d.ts +122 -0
  95. package/dist/cli/wizard/types.js +109 -0
  96. package/dist/config/config.d.ts +13 -1
  97. package/dist/config/config.js +45 -3
  98. package/dist/index.js +157 -206
  99. package/dist/memory/briefing.d.ts +1 -1
  100. package/dist/memory/briefing.js +4 -4
  101. package/dist/memory/consolidation.d.ts +1 -1
  102. package/dist/orchestration/agentRegistry.d.ts +36 -0
  103. package/dist/orchestration/agentRegistry.js +64 -0
  104. package/dist/orchestration/orchestrator.d.ts +7 -0
  105. package/dist/orchestration/orchestrator.js +2 -0
  106. package/dist/orchestration/tools.d.ts +105 -3
  107. package/dist/orchestration/tools.js +167 -8
  108. package/dist/prompt/skillCatalog.d.ts +11 -0
  109. package/dist/prompt/skillCatalog.js +134 -0
  110. package/dist/prompt/skillRunner.d.ts +2 -2
  111. package/dist/prompt/skillRunner.js +2 -31
  112. package/dist/prompt/systemPrompt.js +7 -2
  113. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  114. package/dist/runtime/anthropicAdapter.js +293 -0
  115. package/dist/runtime/cronParser.d.ts +23 -0
  116. package/dist/runtime/cronParser.js +122 -0
  117. package/dist/runtime/mcpClient.js +14 -11
  118. package/dist/runtime/mcpPool.d.ts +170 -0
  119. package/dist/runtime/mcpPool.js +442 -0
  120. package/dist/runtime/mcpUtils.d.ts +17 -1
  121. package/dist/runtime/mcpUtils.js +23 -0
  122. package/dist/runtime/scheduleTicker.d.ts +33 -0
  123. package/dist/runtime/scheduleTicker.js +99 -0
  124. package/dist/runtime/vendorSnippets.d.ts +45 -0
  125. package/dist/runtime/vendorSnippets.js +153 -0
  126. package/dist/state/scheduleStore.d.ts +37 -0
  127. package/dist/state/scheduleStore.js +64 -0
  128. package/package.json +14 -5
  129. 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,24 +12,27 @@ 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
- const execPromise = promisify(exec);
31
- // Setup marked terminal rendering
32
- marked.use(markedTerminal({
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';
18
+ import { tryHandleScheduleCommand } from './commands/schedule.js';
19
+ import { tryHandleReleaseNotesCommand } from './commands/releaseNotes.js';
35
20
  /**
36
21
  * All slash commands the REPL recognizes. Used for tab autocomplete and for
37
22
  * the readline completer. Keep alphabetically grouped roughly by surface area.
23
+ *
24
+ * The Ink chat REPL (cli/ink/runChat.tsx) consumes this same list for its
25
+ * inline slash palette so both surfaces stay in lockstep as new commands land.
38
26
  */
39
- const SLASH_COMMANDS = [
27
+ export const SLASH_COMMANDS = [
40
28
  '/help', '/status', '/workspace', '/where', '/tools', '/skills', '/plan', '/transcript',
41
29
  '/doctor', '/config', '/diff', '/commit', '/clear', '/compact', '/exit', '/quit',
42
30
  '/roles', '/agents', '/agent', '/spawn', '/wait',
43
31
  '/spec', '/feature-dev', '/grill-me', '/review', '/implement-plan', '/skill', '/workflow', '/workflows', '/approve',
44
32
  '/memory', '/recall', '/briefing', '/scenes', '/working', '/forget',
45
- '/init', '/sessions', '/resume', '/model', '/mcp',
46
- '/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop',
47
- '/continue', '/auto-review', '/vim', '/statusline', '/quiet',
33
+ '/init', '/login', '/sessions', '/resume', '/model', '/mcp',
34
+ '/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop', '/schedule',
35
+ '/continue', '/auto-review', '/vim', '/statusline', '/quiet', '/release-notes',
48
36
  '/handover', '/explain', '/trace', '/failed', '/verify', '/audit',
49
37
  '/export', '/import', '/persona', '/skill-hints', '/diagnostics',
50
38
  '/tokens', '/watch', '/yolo', '/mode', '/review-policy', '/sandbox', '/kill',
@@ -53,686 +41,6 @@ const SLASH_COMMANDS = [
53
41
  '/feedback', '/rollout', '/ps', '/stop', '/logout', '/apps', '/plugins',
54
42
  '/experimental', '/memories', '/debug-config', '/mention', '/keymap', '/ide',
55
43
  ];
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
44
  const HELP_CATEGORIES = [
737
45
  {
738
46
  key: 'session',
@@ -742,7 +50,8 @@ const HELP_CATEGORIES = [
742
50
  { cmd: '/workspace', desc: 'Active workspace and session identity' },
743
51
  { cmd: '/where', desc: 'Single-screen view of workspace, workflow, goal, plan, recall, children' },
744
52
  { cmd: '/doctor', desc: 'Config, connection, memory extraction health' },
745
- { cmd: '/config', desc: 'View active configuration profile' },
53
+ { cmd: '/config [key] [value]', desc: 'Settings panel; `/config theme dark` to set; `/config raw` for JSON dump' },
54
+ { cmd: '/login', desc: 'In-REPL MCP profile editor (transport → fields → probe → save)' },
746
55
  { cmd: '/clear', desc: 'Clear chat history for the active session' },
747
56
  { cmd: '/compact', desc: 'LLM-driven compaction of the active session' },
748
57
  { cmd: '/new [label]', desc: 'Start a new chat with a fresh session key' },
@@ -751,7 +60,7 @@ const HELP_CATEGORIES = [
751
60
  { cmd: '/resume <id>', desc: 'Resume a previous session by sessionKey' },
752
61
  { cmd: '/sessions', desc: 'List persisted sessions for this workspace' },
753
62
  { cmd: '/side <q> /btw <q>', desc: 'Ephemeral side conversation in a forked session' },
754
- { cmd: '/init', desc: 'Create AGENT.md in the workspace' },
63
+ { cmd: '/init', desc: 'Re-run the onboarding wizard (Theme → Provider → API key → Model → MCP → AGENT.md)' },
755
64
  { cmd: '/exit /quit', desc: 'Close MCP connection and exit' },
756
65
  ],
757
66
  },
@@ -866,6 +175,7 @@ const HELP_CATEGORIES = [
866
175
  { cmd: '/apps /plugins', desc: 'List workspace skills and plugin folders' },
867
176
  { cmd: '/feedback [message]', desc: 'Append feedback entry' },
868
177
  { cmd: '/experimental [on|off]', desc: 'Toggle experimental features' },
178
+ { cmd: '/release-notes [version|list]', desc: 'Show changelog for current (or specified) CLI version' },
869
179
  ],
870
180
  },
871
181
  ];
@@ -905,6 +215,28 @@ export function renderHelp(category) {
905
215
  }
906
216
  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
217
  }
218
+ /**
219
+ * Look up a one-line description for a slash command by walking the
220
+ * help registry. Used to populate the slash-suggest popup. Falls back
221
+ * to a generic placeholder if the command isn't documented (those
222
+ * commands still work — they just won't get a custom description
223
+ * inside the popup until someone adds them to `HELP_CATEGORIES`).
224
+ *
225
+ * Description text in `HELP_CATEGORIES` sometimes carries a parenthesised
226
+ * argument hint (e.g. "/config [key] [value]"); we strip everything
227
+ * after the first space when matching by cmd token so e.g. `/config`
228
+ * matches `cmd: "/config [key] [value]"`.
229
+ */
230
+ export function lookupSlashDescription(cmd) {
231
+ for (const cat of HELP_CATEGORIES) {
232
+ for (const entry of cat.entries) {
233
+ const token = entry.cmd.split(/\s+/)[0];
234
+ if (token === cmd)
235
+ return entry.desc;
236
+ }
237
+ }
238
+ return '(no description)';
239
+ }
908
240
  function printHelpCategory(c) {
909
241
  console.log(chalk.bold(`\n${c.title}:`));
910
242
  // Find max command-column width for alignment.
@@ -913,18 +245,31 @@ function printHelpCategory(c) {
913
245
  console.log(` ${chalk.cyan(e.cmd.padEnd(colWidth))} ${chalk.gray(e.desc)}`);
914
246
  }
915
247
  }
916
- async function handleSlashCommand(command, args, agent, mcpClient, config, rl, ctx) {
248
+ export async function handleSlashCommand(command, args, agent, mcpClient, config, rl, ctx) {
917
249
  // Category dispatch — each extracted module returns true iff it matched
918
250
  // the command. New categories should be added here as they're extracted
919
251
  // from the giant switch below. Long-term goal: shrink the switch to
920
252
  // nothing so this dispatch is the only entrypoint.
921
253
  const cmdCtx = { command, args, agent, mcpClient, config, rl, repl: ctx };
254
+ // 0.3.7 wizard / config / login dispatchers run first so they shadow
255
+ // the legacy /init + /config handlers in ui.ts (which still ship
256
+ // their old behaviour as fallbacks but are now superseded).
257
+ if (await tryHandleInitCommand(cmdCtx))
258
+ return;
259
+ if (await tryHandleConfigCommand(cmdCtx))
260
+ return;
261
+ if (await tryHandleLoginCommand(cmdCtx))
262
+ return;
922
263
  if (await tryHandleMemoryCommand(cmdCtx))
923
264
  return;
924
265
  if (await tryHandleUiCommand(cmdCtx))
925
266
  return;
926
267
  if (await tryHandleWorkflowCommand(cmdCtx))
927
268
  return;
269
+ if (await tryHandleScheduleCommand(cmdCtx))
270
+ return;
271
+ if (await tryHandleReleaseNotesCommand(cmdCtx))
272
+ return;
928
273
  if (await tryHandleObsCommand(cmdCtx))
929
274
  return;
930
275
  if (await tryHandleOrchestrationCommand(cmdCtx))
@@ -939,13 +284,6 @@ async function handleSlashCommand(command, args, agent, mcpClient, config, rl, c
939
284
  // here didn't match any handler.
940
285
  console.log(chalk.red(`\nUnknown slash command: ${command}. Type /help for assistance.\n`));
941
286
  }
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
287
  /**
950
288
  * Tab-completion source for `@path/to/file` mentions. Given a partial workspace
951
289
  * path, return the matching files and directories one level deep. Stays inside