@kinqs/brainrouter-cli 0.3.5 → 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.
Files changed (125) 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/bin/cli.cjs +71 -0
  8. package/dist/agent/agent.d.ts +224 -3
  9. package/dist/agent/agent.js +561 -55
  10. package/dist/cli/banner.d.ts +80 -0
  11. package/dist/cli/banner.js +232 -0
  12. package/dist/cli/cliPrompt.d.ts +106 -0
  13. package/dist/cli/cliPrompt.js +314 -0
  14. package/dist/cli/commands/_context.d.ts +3 -1
  15. package/dist/cli/commands/_helpers.d.ts +1 -1
  16. package/dist/cli/commands/_helpers.js +6 -6
  17. package/dist/cli/commands/config.d.ts +46 -0
  18. package/dist/cli/commands/config.js +1042 -0
  19. package/dist/cli/commands/guard.js +75 -10
  20. package/dist/cli/commands/init.d.ts +20 -0
  21. package/dist/cli/commands/init.js +64 -0
  22. package/dist/cli/commands/login.d.ts +13 -0
  23. package/dist/cli/commands/login.js +179 -0
  24. package/dist/cli/commands/mcp.d.ts +19 -0
  25. package/dist/cli/commands/mcp.js +286 -0
  26. package/dist/cli/commands/memory.js +2 -2
  27. package/dist/cli/commands/obs.js +22 -22
  28. package/dist/cli/commands/orchestration.js +18 -0
  29. package/dist/cli/commands/session.js +13 -5
  30. package/dist/cli/commands/ui.js +202 -91
  31. package/dist/cli/commands/workflow.d.ts +20 -0
  32. package/dist/cli/commands/workflow.js +368 -51
  33. package/dist/cli/ink/ChatApp.d.ts +206 -0
  34. package/dist/cli/ink/ChatApp.js +493 -0
  35. package/dist/cli/ink/Frame.d.ts +26 -0
  36. package/dist/cli/ink/Frame.js +5 -0
  37. package/dist/cli/ink/Picker.d.ts +65 -0
  38. package/dist/cli/ink/Picker.js +133 -0
  39. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  40. package/dist/cli/ink/SlashPalette.js +136 -0
  41. package/dist/cli/ink/TextField.d.ts +34 -0
  42. package/dist/cli/ink/TextField.js +47 -0
  43. package/dist/cli/ink/WizardApp.d.ts +7 -0
  44. package/dist/cli/ink/WizardApp.js +422 -0
  45. package/dist/cli/ink/ambientChat.d.ts +34 -0
  46. package/dist/cli/ink/ambientChat.js +7 -0
  47. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  48. package/dist/cli/ink/consoleCapture.js +33 -0
  49. package/dist/cli/ink/markdownRender.d.ts +41 -0
  50. package/dist/cli/ink/markdownRender.js +278 -0
  51. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  52. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  53. package/dist/cli/ink/runChat.d.ts +34 -0
  54. package/dist/cli/ink/runChat.js +571 -0
  55. package/dist/cli/ink/runPicker.d.ts +31 -0
  56. package/dist/cli/ink/runPicker.js +139 -0
  57. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  58. package/dist/cli/ink/runSlashPalette.js +33 -0
  59. package/dist/cli/ink/runWizard.d.ts +22 -0
  60. package/dist/cli/ink/runWizard.js +133 -0
  61. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  62. package/dist/cli/ink/stdinHandoff.js +78 -0
  63. package/dist/cli/ink/toolFormat.d.ts +73 -0
  64. package/dist/cli/ink/toolFormat.js +180 -0
  65. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  66. package/dist/cli/ink/useTerminalSize.js +26 -0
  67. package/dist/cli/repl.d.ts +25 -3
  68. package/dist/cli/repl.js +64 -646
  69. package/dist/cli/slashSuggest.d.ts +32 -0
  70. package/dist/cli/slashSuggest.js +146 -0
  71. package/dist/cli/spinner.d.ts +34 -0
  72. package/dist/cli/spinner.js +36 -0
  73. package/dist/cli/statusline.d.ts +67 -0
  74. package/dist/cli/statusline.js +204 -0
  75. package/dist/cli/theme.d.ts +79 -0
  76. package/dist/cli/theme.js +106 -0
  77. package/dist/cli/whereView.d.ts +81 -0
  78. package/dist/cli/whereView.js +245 -0
  79. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  80. package/dist/cli/wizard/modelsApi.js +166 -0
  81. package/dist/cli/wizard/picker.d.ts +202 -0
  82. package/dist/cli/wizard/picker.js +547 -0
  83. package/dist/cli/wizard/providers.d.ts +86 -0
  84. package/dist/cli/wizard/providers.js +190 -0
  85. package/dist/cli/wizard/runner.d.ts +13 -0
  86. package/dist/cli/wizard/runner.js +488 -0
  87. package/dist/cli/wizard/types.d.ts +122 -0
  88. package/dist/cli/wizard/types.js +109 -0
  89. package/dist/config/config.d.ts +52 -0
  90. package/dist/config/config.js +89 -75
  91. package/dist/index.js +215 -206
  92. package/dist/memory/briefing.d.ts +11 -1
  93. package/dist/memory/briefing.js +69 -1
  94. package/dist/memory/consolidation.d.ts +1 -1
  95. package/dist/orchestration/agentRegistry.d.ts +36 -0
  96. package/dist/orchestration/agentRegistry.js +64 -0
  97. package/dist/orchestration/orchestrator.d.ts +7 -0
  98. package/dist/orchestration/orchestrator.js +2 -0
  99. package/dist/orchestration/tools.d.ts +10 -1
  100. package/dist/orchestration/tools.js +48 -4
  101. package/dist/prompt/breadthHint.d.ts +5 -0
  102. package/dist/prompt/breadthHint.js +44 -0
  103. package/dist/prompt/skillCatalog.d.ts +11 -0
  104. package/dist/prompt/skillCatalog.js +134 -0
  105. package/dist/prompt/skillRunner.d.ts +2 -2
  106. package/dist/prompt/skillRunner.js +2 -31
  107. package/dist/prompt/systemPrompt.d.ts +34 -0
  108. package/dist/prompt/systemPrompt.js +128 -108
  109. package/dist/runtime/dangerousCommand.d.ts +53 -0
  110. package/dist/runtime/dangerousCommand.js +105 -0
  111. package/dist/runtime/mcpClient.d.ts +38 -1
  112. package/dist/runtime/mcpClient.js +104 -13
  113. package/dist/runtime/mcpPool.d.ts +162 -0
  114. package/dist/runtime/mcpPool.js +423 -0
  115. package/dist/runtime/mcpUtils.d.ts +3 -1
  116. package/dist/state/goalStore.d.ts +98 -17
  117. package/dist/state/goalStore.js +132 -42
  118. package/dist/state/preferencesStore.d.ts +67 -3
  119. package/dist/state/preferencesStore.js +84 -1
  120. package/dist/state/workflowArtifacts.d.ts +63 -2
  121. package/dist/state/workflowArtifacts.js +120 -8
  122. package/dist/tests/_helpers.d.ts +31 -0
  123. package/dist/tests/_helpers.js +91 -0
  124. package/package.json +12 -5
  125. package/.env.example +0 -109
@@ -0,0 +1,571 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { EventEmitter } from 'node:events';
3
+ import { execSync } from 'node:child_process';
4
+ import chalk from 'chalk';
5
+ import { resolveTheme } from '../theme.js';
6
+ import { buildBannerInputs, renderBanner } from '../banner.js';
7
+ import { isKnownSegment, renderSegments } from '../statusline.js';
8
+ import { readPreferences } from '../../state/preferencesStore.js';
9
+ import { listSessions } from '../../orchestration/orchestrator.js';
10
+ import { expandMentions } from '../../memory/mentions.js';
11
+ import { addGoalTokens, buildGoalContinuationPrompt, formatBudget, goalHasBudgetLeft, readGoal, tickGoalIteration, usageLimitGoal, } from '../../state/goalStore.js';
12
+ import { setActiveReadline } from '../cliPrompt.js';
13
+ import { ChatApp } from './ChatApp.js';
14
+ import { handleSlashCommand, lookupSlashDescription, SLASH_COMMANDS } from '../repl.js';
15
+ import { formatToolCall } from './toolFormat.js';
16
+ import { setAmbientChat } from './ambientChat.js';
17
+ import { captureConsoleOutput } from './consoleCapture.js';
18
+ import { renderWithResizeClear } from './renderWithResizeClear.js';
19
+ export async function runChat(opts) {
20
+ const { agent, mcpClient, config } = opts;
21
+ const theme = resolveTheme(agent.workspaceRoot);
22
+ const banner = renderBanner(buildBannerInputs(config, agent, mcpClient), theme);
23
+ const offlineWarning = mcpClient.isConnected()
24
+ ? undefined
25
+ : theme.warning(' ⚠️ OFFLINE MODE — MCP server unreachable. Memory recall, skills, and capture are disabled.')
26
+ + '\n' + theme.muted(' Local tools (file edits, shell, web fetch, spawn_agent) still work.')
27
+ + '\n' + theme.muted(' Start the MCP server and restart the CLI to restore full functionality.');
28
+ const hint = theme.muted(' Type ') + theme.info('/help')
29
+ + theme.muted(' for commands · ') + theme.info('/where')
30
+ + theme.muted(' for current state · just start typing your prompt.');
31
+ // Build the slash command catalog from the registry in repl.ts so the
32
+ // inline palette suggestions match the readline REPL's autocomplete list.
33
+ const slashCatalog = SLASH_COMMANDS.map((cmd) => ({
34
+ cmd,
35
+ description: lookupSlashDescription(cmd),
36
+ }));
37
+ // Closure-shared state — equivalent to the readline REPL's local closures
38
+ // in startREPL. Captured into `onSubmit` / shim listeners so the orchestrator
39
+ // remains a single owner of the turn lifecycle.
40
+ let isProcessing = false;
41
+ let pendingContinuation = false;
42
+ let idleHintFired = false;
43
+ let idleHintTimer;
44
+ let controller;
45
+ let exited = false;
46
+ const isQuiet = () => {
47
+ if (process.env.BRAINROUTER_QUIET === '1')
48
+ return true;
49
+ try {
50
+ return readPreferences(agent.workspaceRoot).quiet === true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ };
56
+ // Idle help hint — port of the readline REPL's 30s discoverability nudge.
57
+ // Single-fire per session; user input cancels.
58
+ const armIdleHint = () => {
59
+ if (idleHintFired || !process.stdout.isTTY)
60
+ return;
61
+ if (idleHintTimer)
62
+ clearTimeout(idleHintTimer);
63
+ idleHintTimer = setTimeout(() => {
64
+ if (idleHintFired || isProcessing || pendingContinuation || exited)
65
+ return;
66
+ idleHintFired = true;
67
+ controller?.push.notice(`Tip: press ? or /help for commands, /where for current state.`);
68
+ }, 30_000);
69
+ if (typeof idleHintTimer.unref === 'function') {
70
+ idleHintTimer.unref();
71
+ }
72
+ };
73
+ const clearIdleHint = () => {
74
+ if (idleHintTimer) {
75
+ clearTimeout(idleHintTimer);
76
+ idleHintTimer = undefined;
77
+ }
78
+ };
79
+ // Footer refresh — derives model · session · branch from current agent
80
+ // state and prefs. Re-run after each turn so the bar reflects post-turn
81
+ // model swaps, branch changes, etc.
82
+ const refreshFooter = () => {
83
+ if (!controller)
84
+ return;
85
+ const prefs = readPreferences(agent.workspaceRoot);
86
+ const requested = prefs.statusline.split(',').map((s) => s.trim()).filter(Boolean);
87
+ const segments = requested.filter(isKnownSegment).filter((segment) => segment !== 'effort');
88
+ const rendered = renderSegments(segments, {
89
+ workspaceRoot: agent.workspaceRoot,
90
+ sessionKey: agent.sessionKey,
91
+ accessMode: agent.getAccessMode(),
92
+ model: agent.getModel(),
93
+ lastTurnUsage: agent.lastTurnUsage,
94
+ prDetector: () => detectGitHubPR(agent.workspaceRoot),
95
+ });
96
+ let branch;
97
+ try {
98
+ branch = execSync('git rev-parse --abbrev-ref HEAD', {
99
+ cwd: agent.workspaceRoot,
100
+ stdio: ['ignore', 'pipe', 'ignore'],
101
+ }).toString().trim();
102
+ }
103
+ catch { /* not a git repo */ }
104
+ controller.setFooter({
105
+ model: agent.getModel(),
106
+ session: agent.sessionKey,
107
+ branch,
108
+ effort: prefs.effort,
109
+ accessMode: agent.getAccessMode(),
110
+ rightExtra: rendered.length > 0 ? rendered.join(' · ') : undefined,
111
+ });
112
+ refreshTerminalTitle();
113
+ };
114
+ const refreshTerminalTitle = () => {
115
+ try {
116
+ const prefs = readPreferences(agent.workspaceRoot);
117
+ const cfg = prefs.terminalTitle ?? 'model,session';
118
+ if (cfg.toLowerCase() === 'off')
119
+ return;
120
+ const segs = cfg.split(',').map((s) => s.trim()).filter(Boolean);
121
+ const parts = [];
122
+ for (const seg of segs) {
123
+ if (seg === 'model')
124
+ parts.push(agent.getModel());
125
+ else if (seg === 'session')
126
+ parts.push(agent.sessionKey.slice(0, 24));
127
+ else if (seg === 'mode')
128
+ parts.push(agent.getAccessMode());
129
+ else if (seg === 'branch') {
130
+ try {
131
+ parts.push(execSync('git rev-parse --abbrev-ref HEAD', {
132
+ cwd: agent.workspaceRoot,
133
+ stdio: ['ignore', 'pipe', 'ignore'],
134
+ }).toString().trim());
135
+ }
136
+ catch { /* not a git repo */ }
137
+ }
138
+ }
139
+ if (parts.length === 0)
140
+ return;
141
+ const awaitingCount = (pendingContinuation ? 1 : 0) + getRunningChildCount();
142
+ const prefix = awaitingCount > 0 ? `(${awaitingCount}) ` : '';
143
+ process.stdout.write(`\x1b]0;${prefix}brainrouter · ${parts.join(' · ')}\x07`);
144
+ }
145
+ catch { /* terminal doesn't support OSC titles */ }
146
+ };
147
+ const getRunningChildCount = () => {
148
+ try {
149
+ const sessions = listSessions(agent.workspaceRoot);
150
+ return sessions.filter((s) => s.status === 'pending' || s.status === 'running').length;
151
+ }
152
+ catch {
153
+ return 0;
154
+ }
155
+ };
156
+ // gh-PR detector cache — same 30s TTL as the readline REPL so the
157
+ // statusline doesn't pay 300ms per prompt redraw.
158
+ let prCache = null;
159
+ const PR_CACHE_TTL_MS = 30_000;
160
+ function detectGitHubPR(cwd) {
161
+ const now = Date.now();
162
+ if (prCache && now - prCache.cachedAt < PR_CACHE_TTL_MS)
163
+ return prCache.value;
164
+ let value = null;
165
+ try {
166
+ const out = execSync('gh pr view --json number,title 2>/dev/null', {
167
+ cwd,
168
+ stdio: ['ignore', 'pipe', 'ignore'],
169
+ timeout: 1500,
170
+ }).toString().trim();
171
+ if (out) {
172
+ const parsed = JSON.parse(out);
173
+ if (typeof parsed.number === 'number')
174
+ value = `#${parsed.number}`;
175
+ }
176
+ }
177
+ catch { /* gh missing or no PR */ }
178
+ prCache = { value, cachedAt: now };
179
+ return value;
180
+ }
181
+ // Shim readline.Interface — satisfies the type required by
182
+ // `handleSlashCommand` so existing slash handlers (extracted into
183
+ // cli/commands/*) work unchanged under the Ink REPL. The shim is an
184
+ // EventEmitter (because readline.Interface extends it) and stubs the
185
+ // prompt/write/pause/resume surface as no-ops. `close()` exits Ink
186
+ // gracefully — used by /quit and /exit.
187
+ //
188
+ // Limits to be aware of:
189
+ // - `question(q, cb)` is implemented but ROUTES through the
190
+ // composer-as-input pattern: it temporarily replaces the submit
191
+ // handler so the next line submission is delivered to `cb`. Used
192
+ // by askYesNo. NOT a replacement for the ask_user_choice mid-turn
193
+ // picker — that path will degrade to NoTTYError until we wire a
194
+ // dedicated Ink picker into the chat tree (follow-up).
195
+ // - `write(text)` injects into the composer (mirrors readline.write).
196
+ const shim = createReadlineShim({
197
+ closeChat: () => { exited = true; controller?.exit(); },
198
+ onWriteToComposer: (text) => controller?.setComposer(text),
199
+ waitForLine: (cb) => {
200
+ questionCallback = cb;
201
+ },
202
+ });
203
+ let questionCallback;
204
+ // Goal continuation. After each turn ends successfully, schedule the
205
+ // next continuation iff the goal is still active and made progress.
206
+ // The user's next keystroke cancels the queued continuation.
207
+ const scheduleGoalContinuation = (afterPrompt, afterAnswer) => {
208
+ let goalAfter = readGoal(agent.workspaceRoot, agent.sessionKey);
209
+ if (goalAfter && goalAfter.budget.maxTokens) {
210
+ const delta = (agent.lastTurnUsage?.promptTokens ?? 0) + (agent.lastTurnUsage?.completionTokens ?? 0);
211
+ if (delta > 0) {
212
+ const updated = addGoalTokens(agent.workspaceRoot, agent.sessionKey, delta);
213
+ if (updated)
214
+ goalAfter = updated;
215
+ }
216
+ if (goalAfter &&
217
+ goalAfter.status === 'active' &&
218
+ typeof goalAfter.budget.maxTokens === 'number' &&
219
+ (goalAfter.budget.tokensUsed ?? 0) >= goalAfter.budget.maxTokens) {
220
+ const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, `Token budget reached: ${(goalAfter.budget.tokensUsed ?? 0).toLocaleString()} of ${goalAfter.budget.maxTokens.toLocaleString()} used.`);
221
+ if (limited)
222
+ goalAfter = limited;
223
+ }
224
+ }
225
+ const shouldContinue = !!goalAfter &&
226
+ goalAfter.status === 'active' &&
227
+ goalHasBudgetLeft(goalAfter) &&
228
+ agent.lastTurnToolCalls > 0 &&
229
+ agent.lastGoalTransition === undefined;
230
+ if (goalAfter && goalAfter.status === 'complete') {
231
+ controller?.push.notice(`🎯 Goal achieved — ${goalAfter.blockedReason ?? 'evidence on record.'}`, 'info');
232
+ }
233
+ else if (goalAfter && goalAfter.status === 'blocked') {
234
+ controller?.push.notice(`🚧 Goal blocked: ${goalAfter.blockedReason ?? '(no reason)'}`, 'warn');
235
+ controller?.push.notice(`Resolve the blocker, then /goal resume to continue.`, 'info');
236
+ }
237
+ else if (goalAfter && goalAfter.status === 'usage_limited') {
238
+ controller?.push.notice(`⏸ Goal hit usage limit: ${goalAfter.blockedReason ?? 'budget exhausted'}.`, 'warn');
239
+ controller?.push.notice(`Raise the cap with /goal budget <n> or /goal tokens <n>, then /goal resume.`, 'info');
240
+ }
241
+ else if (goalAfter && goalAfter.status === 'active' && !goalHasBudgetLeft(goalAfter)) {
242
+ const reason = `Iteration budget exhausted (${goalAfter.budget.iterationsUsed}/${formatBudget(goalAfter.budget.maxIterations)}).`;
243
+ const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, reason);
244
+ controller?.push.notice(`⏸ ${reason} Extend with /goal budget <n> and /goal resume, mark /goal complete, or /goal clear.`, 'warn');
245
+ if (limited)
246
+ goalAfter = limited;
247
+ }
248
+ else if (goalAfter && goalAfter.status === 'active' && agent.lastTurnToolCalls === 0) {
249
+ controller?.push.notice(`(goal continuation suppressed: last turn made no tool calls — anti-spin)`, 'info');
250
+ }
251
+ if (shouldContinue && goalAfter) {
252
+ pendingContinuation = true;
253
+ const next = goalAfter.budget.iterationsUsed + 1;
254
+ controller?.push.notice(`(goal continuation queued — iteration ${next}/${formatBudget(goalAfter.budget.maxIterations)}; type anything to cancel)`, 'info');
255
+ const followUp = buildGoalContinuationPrompt(goalAfter, afterPrompt, afterAnswer);
256
+ setImmediate(() => {
257
+ if (!pendingContinuation || isProcessing)
258
+ return;
259
+ pendingContinuation = false;
260
+ tickGoalIteration(agent.workspaceRoot, agent.sessionKey);
261
+ void runChatTurn(followUp);
262
+ });
263
+ }
264
+ };
265
+ // Run a single agent turn through the Ink chat REPL. Mirrors
266
+ // cli/repl.ts:runAgentTurn but pushes events through the Ink
267
+ // scrollback controller instead of console.log + ora spinner.
268
+ const runChatTurn = async (rawInput) => {
269
+ if (!controller)
270
+ return;
271
+ if (isProcessing) {
272
+ controller.push.notice('A previous turn is still running.');
273
+ return;
274
+ }
275
+ isProcessing = true;
276
+ clearIdleHint();
277
+ const { expanded, mentions } = expandMentions(rawInput, agent.workspaceRoot);
278
+ if (mentions.length > 0 && !isQuiet()) {
279
+ controller.push.notice(`📎 Attached ${mentions.length} file${mentions.length === 1 ? '' : 's'}: ${mentions.map((m) => m.token).join(', ')}`);
280
+ }
281
+ const startedAt = Date.now();
282
+ controller.push.setPhase('turn-running');
283
+ controller.push.setStatus('Agent starting...');
284
+ let parentDone = false;
285
+ const tickStatus = (status) => {
286
+ if (parentDone)
287
+ return;
288
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
289
+ const u = agent.lastTurnUsage;
290
+ const tokens = u.calls > 0 ? ` ${u.promptTokens.toLocaleString()}↑ ${u.completionTokens.toLocaleString()}↓` : '';
291
+ controller.push.setStatus(`${status} ${elapsed}s${tokens}`);
292
+ };
293
+ // Per-tool start time + args — agent.runTurn fires onToolStart with
294
+ // full args but onToolEnd only sees name + result, so we stash the
295
+ // args here so the end-of-call scrollback row can render the
296
+ // formatted call (`Read(src/foo.ts)`) instead of just the bare name.
297
+ // The map key is the tool name; we treat parallel same-name calls
298
+ // as overlapping which is fine for the duration display (the older
299
+ // start time wins, slightly under-counting concurrent invocations).
300
+ const toolStartTimes = new Map();
301
+ const toolArgsSnapshot = new Map();
302
+ try {
303
+ const answer = await agent.runTurn(expanded, {
304
+ onStatusUpdate: tickStatus,
305
+ onToolStart: (name, args) => {
306
+ // Surface the in-flight tool via the spinner status line — the
307
+ // scrollback entry is pushed at onToolEnd so each tool call is
308
+ // a single block (header + result), not two rows.
309
+ toolStartTimes.set(name, Date.now());
310
+ toolArgsSnapshot.set(name, args ?? {});
311
+ if (!isQuiet()) {
312
+ controller.push.setStatus(formatToolCall(name, args));
313
+ }
314
+ },
315
+ onToolEnd: (name, result) => {
316
+ // Quiet mode hides successes (the prose response covers them).
317
+ if (isQuiet() && result.success) {
318
+ tickStatus('Thinking');
319
+ return;
320
+ }
321
+ const startedAt = toolStartTimes.get(name);
322
+ const args = toolArgsSnapshot.get(name);
323
+ toolStartTimes.delete(name);
324
+ toolArgsSnapshot.delete(name);
325
+ const durationMs = startedAt ? Date.now() - startedAt : undefined;
326
+ const header = formatToolCall(name, args);
327
+ controller.push.tool(header, result.success, {
328
+ preview: !isQuiet() ? result.preview : undefined,
329
+ durationMs,
330
+ });
331
+ tickStatus('Thinking');
332
+ },
333
+ onPlanUpdate: (items, explanation) => {
334
+ // Explanation rides on the plan entry itself (renders as a dim-italic
335
+ // line above the checklist) rather than as a separate memory event,
336
+ // so the explanation visually anchors to the plan it describes.
337
+ controller.push.plan(items, explanation);
338
+ tickStatus('Thinking');
339
+ },
340
+ onChildComplete: (event) => {
341
+ const ok = event.status === 'completed';
342
+ const head = ok
343
+ ? `🏁 Agent ${event.childId} (${event.role}) completed`
344
+ : `💥 Agent ${event.childId} (${event.role}) failed`;
345
+ const tail = ok && event.preview
346
+ ? ` — ${event.preview}`
347
+ : event.error ? ` — ${event.error}` : '';
348
+ controller.push.notice(head + tail, ok ? 'info' : 'error');
349
+ tickStatus('Thinking');
350
+ },
351
+ onMemoryEvent: (event) => {
352
+ if (isQuiet() && event.kind !== 'contradiction')
353
+ return;
354
+ let line;
355
+ let level = 'info';
356
+ if (event.kind === 'briefing') {
357
+ const src = event.sources.length > 0 ? event.sources.join(', ') : '(none)';
358
+ line = `🧠 Briefing: ${event.recordCount} record${event.recordCount === 1 ? '' : 's'} from ${src}`;
359
+ }
360
+ else if (event.kind === 'capture') {
361
+ const sensory = event.sensoryRecorded ?? event.messageCount;
362
+ const extracted = event.extractedCount;
363
+ const triggered = event.extractionTriggered;
364
+ const sk = event.sessionKey.slice(0, 12);
365
+ if (event.extractionWarning) {
366
+ line = `💾 Captured ${sensory} sensory msg(s) in ${sk}… — ⚠️ ${event.extractionWarning}`;
367
+ level = 'warn';
368
+ }
369
+ else if (triggered && typeof extracted === 'number') {
370
+ line = extracted > 0
371
+ ? `💾 Captured ${sensory} msg(s) → ${extracted} cognitive record(s) extracted (${sk}…)`
372
+ : `💾 Captured ${sensory} msg(s) → no new memories worth promoting (${sk}…)`;
373
+ }
374
+ else if (triggered === false) {
375
+ line = `💾 Captured ${sensory} msg(s) → sensory buffer (${sk}…)`;
376
+ }
377
+ else {
378
+ line = `💾 Captured ${sensory} msg(s) → memory (${sk}…)`;
379
+ }
380
+ }
381
+ else if (event.kind === 'citation' && event.recordIds.length > 0) {
382
+ line = `📌 Reinforced ${event.recordIds.length} record${event.recordIds.length === 1 ? '' : 's'}: ${event.recordIds.slice(0, 3).join(', ')}${event.recordIds.length > 3 ? '…' : ''}`;
383
+ }
384
+ else if (event.kind === 'contradiction') {
385
+ line = `⚠️ Memory contradiction: ${event.warning.slice(0, 140)}`;
386
+ level = 'warn';
387
+ }
388
+ if (line)
389
+ controller.push.memory(level, line);
390
+ tickStatus('Thinking');
391
+ },
392
+ });
393
+ parentDone = true;
394
+ const elapsed = Date.now() - startedAt;
395
+ const u = agent.lastTurnUsage;
396
+ // Pass the raw answer to ChatApp; ChatApp's ScrollbackRow renders
397
+ // it through marked-terminal unless `raw: true` is set. Honors the
398
+ // user's rawScrollback preference exactly like the readline path.
399
+ const prefsForRender = readPreferences(agent.workspaceRoot);
400
+ controller.push.assistant(answer, {
401
+ raw: prefsForRender.rawScrollback === true,
402
+ durationMs: elapsed,
403
+ tokensIn: u.promptTokens,
404
+ tokensOut: u.completionTokens,
405
+ calls: u.calls,
406
+ });
407
+ const warning = agent.takeContradictionWarning();
408
+ if (warning) {
409
+ controller.push.memory('warn', `Memory: ${warning}`);
410
+ controller.push.memory('info', `Use /memory or /briefing to investigate, /forget <id> to archive obsolete records.`);
411
+ }
412
+ // Goal continuation lives at the bottom of the success path so a
413
+ // failed turn doesn't trigger it (we don't want auto-retry loops).
414
+ scheduleGoalContinuation(rawInput, answer);
415
+ }
416
+ catch (err) {
417
+ parentDone = true;
418
+ controller.push.notice(`✗ Execution failed: ${err?.message ?? err}`, 'error');
419
+ }
420
+ finally {
421
+ isProcessing = false;
422
+ controller.push.setPhase('idle');
423
+ controller.push.setStatus('');
424
+ agent.activeSkill = undefined;
425
+ agent.refreshSystemPrompt();
426
+ refreshFooter();
427
+ armIdleHint();
428
+ }
429
+ };
430
+ // Mount Ink. We DON'T set `patchConsole: false` — Ink's default
431
+ // (patchConsole enabled) is exactly what we want: legacy slash
432
+ // commands that still write via chalk + console.log have their
433
+ // output promoted ABOVE Ink's redraw region instead of clobbering it.
434
+ return new Promise((resolve) => {
435
+ const { instance, cleanupResizeClear } = renderWithResizeClear(_jsx(ChatApp, { initialBanner: '\n' + banner, initialOfflineWarning: offlineWarning, initialHint: hint, slashCommands: slashCatalog, promptLabel: `brainrouter[${agent.getAccessMode()}]`, initialAccessMode: agent.getAccessMode(), initialFooter: {
436
+ model: agent.getModel(),
437
+ session: agent.sessionKey,
438
+ effort: readPreferences(agent.workspaceRoot).effort,
439
+ }, onReady: (ctrl) => {
440
+ controller = ctrl;
441
+ // Publish the shim so cliPrompt's askYesNo can find an "active
442
+ // readline" while the Ink REPL owns stdin. Without this, every
443
+ // mid-turn yes/no prompt returns its default silently.
444
+ setActiveReadline(shim);
445
+ // Publish the controller so runPicker / runTextField route their
446
+ // UI through the chat's overlay slot instead of mounting a
447
+ // second Ink instance (which would race for stdin + terminal
448
+ // state). See ambientChat.ts for the rationale.
449
+ setAmbientChat({
450
+ showOverlay: ctrl.showOverlay,
451
+ clearOverlay: ctrl.clearOverlay,
452
+ });
453
+ refreshFooter();
454
+ armIdleHint();
455
+ }, onAccessModeCycle: () => {
456
+ const cycle = ['read', 'write', 'shell'];
457
+ const current = agent.getAccessMode();
458
+ const next = cycle[(cycle.indexOf(current) + 1) % cycle.length];
459
+ agent.setAccessMode(next);
460
+ refreshFooter();
461
+ return next;
462
+ }, onSubmit: async (text, push) => {
463
+ // Any in-flight goal continuation is cancelled by user input,
464
+ // regardless of whether the input is a slash or a prompt.
465
+ if (pendingContinuation) {
466
+ pendingContinuation = false;
467
+ push.notice('(goal continuation cancelled by user input)');
468
+ }
469
+ clearIdleHint();
470
+ // If a slash command's handler had called `rl.question(cb)`,
471
+ // the very next submission belongs to `cb` — not the dispatcher.
472
+ if (questionCallback) {
473
+ const cb = questionCallback;
474
+ questionCallback = undefined;
475
+ cb(text);
476
+ return;
477
+ }
478
+ // Bare `?` → help (mirrors the readline REPL — the idle hint
479
+ // advertises it, so make it actually work).
480
+ if (text === '?') {
481
+ await dispatchSlash('/help', [], shim);
482
+ return;
483
+ }
484
+ if (text.startsWith('/')) {
485
+ const parts = text.trim().split(/\s+/);
486
+ const command = parts[0].toLowerCase();
487
+ const args = parts.slice(1);
488
+ await dispatchSlash(command, args, shim);
489
+ return;
490
+ }
491
+ if (isProcessing) {
492
+ push.notice('A previous turn is still running. Wait for the prompt before sending another message.');
493
+ return;
494
+ }
495
+ await runChatTurn(text);
496
+ } }), { exitOnCtrlC: true, patchConsole: true });
497
+ instance.waitUntilExit().then(async () => {
498
+ exited = true;
499
+ setActiveReadline(undefined);
500
+ setAmbientChat(undefined);
501
+ cleanupResizeClear();
502
+ clearIdleHint();
503
+ try {
504
+ await mcpClient.close();
505
+ }
506
+ catch { /* already closed */ }
507
+ // Goodbye line is intentionally printed AFTER Ink unmounts so it
508
+ // doesn't get caught inside the redraw region.
509
+ process.stdout.write(chalk.bold.hex('#CC9166')('Goodbye!\n'));
510
+ resolve();
511
+ }).catch(async () => {
512
+ exited = true;
513
+ setActiveReadline(undefined);
514
+ setAmbientChat(undefined);
515
+ cleanupResizeClear();
516
+ clearIdleHint();
517
+ try {
518
+ await mcpClient.close();
519
+ }
520
+ catch { /* already closed */ }
521
+ resolve();
522
+ });
523
+ });
524
+ async function dispatchSlash(command, args, rl) {
525
+ if (!controller)
526
+ return;
527
+ try {
528
+ const captured = await captureConsoleOutput(() => handleSlashCommand(command, args, agent, mcpClient, config, rl, {
529
+ refreshPromptForMode: refreshFooter,
530
+ replaceBanner: (text) => controller?.replaceBanner(text),
531
+ isProcessing: () => isProcessing,
532
+ runAgentTurn: (prompt) => { void runChatTurn(prompt); },
533
+ runAgentTurnAsync: (prompt) => runChatTurn(prompt),
534
+ }));
535
+ const output = captured.output.trimEnd();
536
+ if (output) {
537
+ controller.push.raw(output);
538
+ }
539
+ }
540
+ catch (err) {
541
+ controller.push.notice(`Slash command "${command}" failed: ${err?.message ?? err}`, 'error');
542
+ }
543
+ finally {
544
+ // Pull any preferences / model / branch / effort changes the
545
+ // command made (e.g. /effort, /model, /theme, /statusline) so
546
+ // the footer reflects them immediately rather than waiting for
547
+ // the next chat turn to refresh.
548
+ refreshFooter();
549
+ }
550
+ }
551
+ }
552
+ function createReadlineShim(hooks) {
553
+ const emitter = new EventEmitter();
554
+ const shim = emitter;
555
+ shim.close = () => { hooks.closeChat(); };
556
+ shim.prompt = (_preserveCursor) => { };
557
+ shim.pause = () => shim;
558
+ shim.resume = () => shim;
559
+ shim.write = (text) => { hooks.onWriteToComposer(text); };
560
+ shim.setPrompt = (_text) => { };
561
+ // Promise-shaped `question` for askYesNo: print the prompt text via
562
+ // console.log (Ink's patchConsole bubbles it above the redraw region)
563
+ // and stash the callback for the next submission.
564
+ shim.question = (q, cb) => {
565
+ process.stdout.write(q);
566
+ hooks.waitForLine(cb);
567
+ };
568
+ shim.line = '';
569
+ shim.cursor = 0;
570
+ return shim;
571
+ }
@@ -0,0 +1,31 @@
1
+ import { type PickerProps, type PickerResult } from './Picker.js';
2
+ import { type TextFieldProps, type TextFieldResult } from './TextField.js';
3
+ /**
4
+ * One-shot Ink mount helpers. Used by `/config`, `/login`, and any
5
+ * slash command that needs a single picker / text prompt without
6
+ * managing Ink lifecycle by hand.
7
+ *
8
+ * Two paths:
9
+ *
10
+ * 1. **Overlay path** — when the Ink chat REPL is running, the
11
+ * ambient ChatController is set (see ambientChat.ts +
12
+ * runChat.tsx). We render <Picker> inside the chat's overlay
13
+ * slot, NOT as a second Ink mount — that would race the chat
14
+ * for stdin + terminal state and break the picker's interaction.
15
+ *
16
+ * 2. **Standalone path** — for the legacy readline REPL, mount a
17
+ * fresh Ink instance. Unmount via `instance.unmount()` from
18
+ * outside the React tree (no `useApp().exit()`) so the wrapper
19
+ * doesn't risk exiting the wrong Ink instance if something goes
20
+ * sideways. The Picker/TextField components are exit-agnostic;
21
+ * they call onResolve and trust the caller to handle unmount.
22
+ *
23
+ * Stdin handoff for the standalone path: snapshot + detach existing
24
+ * listeners before mount, restore them and reset stdin state after
25
+ * Ink unmounts (matches the pattern in runWizard.tsx / runSlashPalette).
26
+ */
27
+ export declare function runPicker(opts: Omit<PickerProps, 'onResolve'>): Promise<PickerResult>;
28
+ export declare function runTextField(opts: Omit<TextFieldProps, 'onResolve'>): Promise<TextFieldResult>;
29
+ /** Re-export the shared types so callers don't import from picker.tsx directly. */
30
+ export type { PickerRow, PickerResult } from './Picker.js';
31
+ export type { TextFieldResult } from './TextField.js';