@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
@@ -0,0 +1,682 @@
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 { startScheduleTicker } from '../../runtime/scheduleTicker.js';
16
+ import { formatToolCall } from './toolFormat.js';
17
+ import { setAmbientChat } from './ambientChat.js';
18
+ import { captureConsoleOutput } from './consoleCapture.js';
19
+ import { renderWithResizeClear } from './renderWithResizeClear.js';
20
+ export async function runChat(opts) {
21
+ const { agent, mcpClient, config } = opts;
22
+ const theme = resolveTheme(agent.workspaceRoot);
23
+ const banner = renderBanner(buildBannerInputs(config, agent, mcpClient), theme);
24
+ const offlineWarning = mcpClient.isConnected()
25
+ ? undefined
26
+ : theme.warning(' ⚠️ OFFLINE MODE — MCP server unreachable. Memory recall, skills, and capture are disabled.')
27
+ + '\n' + theme.muted(' Local tools (file edits, shell, web fetch, spawn_agent) still work.')
28
+ + '\n' + theme.muted(' Start the MCP server and restart the CLI to restore full functionality.');
29
+ const hint = theme.muted(' Type ') + theme.info('/help')
30
+ + theme.muted(' for commands · ') + theme.info('/where')
31
+ + theme.muted(' for current state · just start typing your prompt.');
32
+ // Build the slash command catalog from the registry in repl.ts so the
33
+ // inline palette suggestions match the readline REPL's autocomplete list.
34
+ const slashCatalog = SLASH_COMMANDS.map((cmd) => ({
35
+ cmd,
36
+ description: lookupSlashDescription(cmd),
37
+ }));
38
+ // Closure-shared state — equivalent to the readline REPL's local closures
39
+ // in startREPL. Captured into `onSubmit` / shim listeners so the orchestrator
40
+ // remains a single owner of the turn lifecycle.
41
+ let isProcessing = false;
42
+ let pendingContinuation = false;
43
+ let idleHintFired = false;
44
+ let idleHintTimer;
45
+ let controller;
46
+ let exited = false;
47
+ const isQuiet = () => {
48
+ if (process.env.BRAINROUTER_QUIET === '1')
49
+ return true;
50
+ try {
51
+ return readPreferences(agent.workspaceRoot).quiet === true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ };
57
+ // Idle help hint — port of the readline REPL's 30s discoverability nudge.
58
+ // Single-fire per session; user input cancels.
59
+ const armIdleHint = () => {
60
+ if (idleHintFired || !process.stdout.isTTY)
61
+ return;
62
+ if (idleHintTimer)
63
+ clearTimeout(idleHintTimer);
64
+ idleHintTimer = setTimeout(() => {
65
+ if (idleHintFired || isProcessing || pendingContinuation || exited)
66
+ return;
67
+ idleHintFired = true;
68
+ controller?.push.notice(`Tip: press ? or /help for commands, /where for current state.`);
69
+ }, 30_000);
70
+ if (typeof idleHintTimer.unref === 'function') {
71
+ idleHintTimer.unref();
72
+ }
73
+ };
74
+ const clearIdleHint = () => {
75
+ if (idleHintTimer) {
76
+ clearTimeout(idleHintTimer);
77
+ idleHintTimer = undefined;
78
+ }
79
+ };
80
+ // Footer refresh — derives model · session · branch from current agent
81
+ // state and prefs. Re-run after each turn so the bar reflects post-turn
82
+ // model swaps, branch changes, etc.
83
+ const refreshFooter = () => {
84
+ if (!controller)
85
+ return;
86
+ const prefs = readPreferences(agent.workspaceRoot);
87
+ const requested = prefs.statusline.split(',').map((s) => s.trim()).filter(Boolean);
88
+ const segments = requested.filter(isKnownSegment).filter((segment) => segment !== 'effort');
89
+ const rendered = renderSegments(segments, {
90
+ workspaceRoot: agent.workspaceRoot,
91
+ sessionKey: agent.sessionKey,
92
+ accessMode: agent.getAccessMode(),
93
+ model: agent.getModel(),
94
+ lastTurnUsage: agent.lastTurnUsage,
95
+ prDetector: () => detectGitHubPR(agent.workspaceRoot),
96
+ });
97
+ let branch;
98
+ try {
99
+ branch = execSync('git rev-parse --abbrev-ref HEAD', {
100
+ cwd: agent.workspaceRoot,
101
+ stdio: ['ignore', 'pipe', 'ignore'],
102
+ }).toString().trim();
103
+ }
104
+ catch { /* not a git repo */ }
105
+ controller.setFooter({
106
+ model: agent.getModel(),
107
+ session: agent.sessionKey,
108
+ branch,
109
+ effort: prefs.effort,
110
+ accessMode: agent.getAccessMode(),
111
+ rightExtra: rendered.length > 0 ? rendered.join(' · ') : undefined,
112
+ });
113
+ refreshTerminalTitle();
114
+ };
115
+ const refreshTerminalTitle = () => {
116
+ try {
117
+ const prefs = readPreferences(agent.workspaceRoot);
118
+ const cfg = prefs.terminalTitle ?? 'model,session';
119
+ if (cfg.toLowerCase() === 'off')
120
+ return;
121
+ const segs = cfg.split(',').map((s) => s.trim()).filter(Boolean);
122
+ const parts = [];
123
+ for (const seg of segs) {
124
+ if (seg === 'model')
125
+ parts.push(agent.getModel());
126
+ else if (seg === 'session')
127
+ parts.push(agent.sessionKey.slice(0, 24));
128
+ else if (seg === 'mode')
129
+ parts.push(agent.getAccessMode());
130
+ else if (seg === 'branch') {
131
+ try {
132
+ parts.push(execSync('git rev-parse --abbrev-ref HEAD', {
133
+ cwd: agent.workspaceRoot,
134
+ stdio: ['ignore', 'pipe', 'ignore'],
135
+ }).toString().trim());
136
+ }
137
+ catch { /* not a git repo */ }
138
+ }
139
+ }
140
+ if (parts.length === 0)
141
+ return;
142
+ const awaitingCount = (pendingContinuation ? 1 : 0) + getRunningChildCount();
143
+ const prefix = awaitingCount > 0 ? `(${awaitingCount}) ` : '';
144
+ process.stdout.write(`\x1b]0;${prefix}brainrouter · ${parts.join(' · ')}\x07`);
145
+ }
146
+ catch { /* terminal doesn't support OSC titles */ }
147
+ };
148
+ const getRunningChildCount = () => {
149
+ try {
150
+ const sessions = listSessions(agent.workspaceRoot);
151
+ return sessions.filter((s) => s.status === 'pending' || s.status === 'running').length;
152
+ }
153
+ catch {
154
+ return 0;
155
+ }
156
+ };
157
+ // gh-PR detector cache — same 30s TTL as the readline REPL so the
158
+ // statusline doesn't pay 300ms per prompt redraw.
159
+ let prCache = null;
160
+ const PR_CACHE_TTL_MS = 30_000;
161
+ function detectGitHubPR(cwd) {
162
+ const now = Date.now();
163
+ if (prCache && now - prCache.cachedAt < PR_CACHE_TTL_MS)
164
+ return prCache.value;
165
+ let value = null;
166
+ try {
167
+ const out = execSync('gh pr view --json number,title 2>/dev/null', {
168
+ cwd,
169
+ stdio: ['ignore', 'pipe', 'ignore'],
170
+ timeout: 1500,
171
+ }).toString().trim();
172
+ if (out) {
173
+ const parsed = JSON.parse(out);
174
+ if (typeof parsed.number === 'number')
175
+ value = `#${parsed.number}`;
176
+ }
177
+ }
178
+ catch { /* gh missing or no PR */ }
179
+ prCache = { value, cachedAt: now };
180
+ return value;
181
+ }
182
+ // Shim readline.Interface — satisfies the type required by
183
+ // `handleSlashCommand` so existing slash handlers (extracted into
184
+ // cli/commands/*) work unchanged under the Ink REPL. The shim is an
185
+ // EventEmitter (because readline.Interface extends it) and stubs the
186
+ // prompt/write/pause/resume surface as no-ops. `close()` exits Ink
187
+ // gracefully — used by /quit and /exit.
188
+ //
189
+ // Limits to be aware of:
190
+ // - `question(q, cb)` is implemented but ROUTES through the
191
+ // composer-as-input pattern: it temporarily replaces the submit
192
+ // handler so the next line submission is delivered to `cb`. Used
193
+ // by askYesNo. NOT a replacement for the ask_user_choice mid-turn
194
+ // picker — that path will degrade to NoTTYError until we wire a
195
+ // dedicated Ink picker into the chat tree (follow-up).
196
+ // - `write(text)` injects into the composer (mirrors readline.write).
197
+ const shim = createReadlineShim({
198
+ closeChat: () => { exited = true; controller?.exit(); },
199
+ onWriteToComposer: (text) => controller?.setComposer(text),
200
+ waitForLine: (cb) => {
201
+ questionCallback = cb;
202
+ },
203
+ });
204
+ let questionCallback;
205
+ // Goal continuation. After each turn ends successfully, schedule the
206
+ // next continuation iff the goal is still active and made progress.
207
+ // The user's next keystroke cancels the queued continuation.
208
+ const scheduleGoalContinuation = (afterPrompt, afterAnswer) => {
209
+ let goalAfter = readGoal(agent.workspaceRoot, agent.sessionKey);
210
+ if (goalAfter && goalAfter.budget.maxTokens) {
211
+ const delta = (agent.lastTurnUsage?.promptTokens ?? 0) + (agent.lastTurnUsage?.completionTokens ?? 0);
212
+ if (delta > 0) {
213
+ const updated = addGoalTokens(agent.workspaceRoot, agent.sessionKey, delta);
214
+ if (updated)
215
+ goalAfter = updated;
216
+ }
217
+ if (goalAfter &&
218
+ goalAfter.status === 'active' &&
219
+ typeof goalAfter.budget.maxTokens === 'number' &&
220
+ (goalAfter.budget.tokensUsed ?? 0) >= goalAfter.budget.maxTokens) {
221
+ const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, `Token budget reached: ${(goalAfter.budget.tokensUsed ?? 0).toLocaleString()} of ${goalAfter.budget.maxTokens.toLocaleString()} used.`);
222
+ if (limited)
223
+ goalAfter = limited;
224
+ }
225
+ }
226
+ const shouldContinue = !!goalAfter &&
227
+ goalAfter.status === 'active' &&
228
+ goalHasBudgetLeft(goalAfter) &&
229
+ agent.lastTurnToolCalls > 0 &&
230
+ agent.lastGoalTransition === undefined;
231
+ if (goalAfter && goalAfter.status === 'complete') {
232
+ controller?.push.notice(`🎯 Goal achieved — ${goalAfter.blockedReason ?? 'evidence on record.'}`, 'info');
233
+ }
234
+ else if (goalAfter && goalAfter.status === 'blocked') {
235
+ controller?.push.notice(`🚧 Goal blocked: ${goalAfter.blockedReason ?? '(no reason)'}`, 'warn');
236
+ controller?.push.notice(`Resolve the blocker, then /goal resume to continue.`, 'info');
237
+ }
238
+ else if (goalAfter && goalAfter.status === 'usage_limited') {
239
+ controller?.push.notice(`⏸ Goal hit usage limit: ${goalAfter.blockedReason ?? 'budget exhausted'}.`, 'warn');
240
+ controller?.push.notice(`Raise the cap with /goal budget <n> or /goal tokens <n>, then /goal resume.`, 'info');
241
+ }
242
+ else if (goalAfter && goalAfter.status === 'active' && !goalHasBudgetLeft(goalAfter)) {
243
+ const reason = `Iteration budget exhausted (${goalAfter.budget.iterationsUsed}/${formatBudget(goalAfter.budget.maxIterations)}).`;
244
+ const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, reason);
245
+ controller?.push.notice(`⏸ ${reason} Extend with /goal budget <n> and /goal resume, mark /goal complete, or /goal clear.`, 'warn');
246
+ if (limited)
247
+ goalAfter = limited;
248
+ }
249
+ else if (goalAfter && goalAfter.status === 'active' && agent.lastTurnToolCalls === 0) {
250
+ controller?.push.notice(`(goal continuation suppressed: last turn made no tool calls — anti-spin)`, 'info');
251
+ }
252
+ if (shouldContinue && goalAfter) {
253
+ pendingContinuation = true;
254
+ const next = goalAfter.budget.iterationsUsed + 1;
255
+ controller?.push.notice(`(goal continuation queued — iteration ${next}/${formatBudget(goalAfter.budget.maxIterations)}; type anything to cancel)`, 'info');
256
+ const followUp = buildGoalContinuationPrompt(goalAfter, afterPrompt, afterAnswer);
257
+ setImmediate(() => {
258
+ if (!pendingContinuation || isProcessing)
259
+ return;
260
+ pendingContinuation = false;
261
+ tickGoalIteration(agent.workspaceRoot, agent.sessionKey);
262
+ void runChatTurn(followUp);
263
+ });
264
+ }
265
+ };
266
+ // Run a single agent turn through the Ink chat REPL. Mirrors
267
+ // cli/repl.ts:runAgentTurn but pushes events through the Ink
268
+ // scrollback controller instead of console.log + ora spinner.
269
+ const runChatTurn = async (rawInput) => {
270
+ if (!controller)
271
+ return;
272
+ if (isProcessing) {
273
+ controller.push.notice('A previous turn is still running.');
274
+ return;
275
+ }
276
+ isProcessing = true;
277
+ clearIdleHint();
278
+ const { expanded, mentions } = expandMentions(rawInput, agent.workspaceRoot);
279
+ if (mentions.length > 0 && !isQuiet()) {
280
+ controller.push.notice(`📎 Attached ${mentions.length} file${mentions.length === 1 ? '' : 's'}: ${mentions.map((m) => m.token).join(', ')}`);
281
+ }
282
+ const startedAt = Date.now();
283
+ controller.push.setPhase('turn-running');
284
+ controller.push.setStatus('Agent starting...');
285
+ let parentDone = false;
286
+ const tickStatus = (status) => {
287
+ if (parentDone)
288
+ return;
289
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
290
+ const u = agent.lastTurnUsage;
291
+ const tokens = u.calls > 0 ? ` ${u.promptTokens.toLocaleString()}↑ ${u.completionTokens.toLocaleString()}↓` : '';
292
+ // When children are alive — typically because the parent is in a
293
+ // wait_agent / wait_agents / R1 guardrail auto-drain — append a
294
+ // compact "running children" row so the parent never looks frozen.
295
+ const childrenRow = runningChildren.size > 0 ? ` · ${formatRunningChildrenRow()}` : '';
296
+ controller.push.setStatus(`${status} ${elapsed}s${tokens}${childrenRow}`);
297
+ };
298
+ // Per-tool start time + args — agent.runTurn fires onToolStart with
299
+ // full args but onToolEnd only sees name + result, so we stash the
300
+ // args here so the end-of-call scrollback row can render the
301
+ // formatted call (`Read(src/foo.ts)`) instead of just the bare name.
302
+ // The map key is the tool name; we treat parallel same-name calls
303
+ // as overlapping which is fine for the duration display (the older
304
+ // start time wins, slightly under-counting concurrent invocations).
305
+ const toolStartTimes = new Map();
306
+ const toolArgsSnapshot = new Map();
307
+ // Stash child tool args between onChildToolStart and onChildToolEnd so the
308
+ // end row can render `Read(foo.ts)` instead of just `read_file`. Keyed by
309
+ // `${childId}:${tool}` so two children running the same tool don't collide.
310
+ const childToolArgs = new Map();
311
+ // Currently-running children for the compact "running children" status row.
312
+ // Maintained from onChildToolStart / onChildComplete (the only signals the
313
+ // REPL gets about child lifecycle that don't require re-reading sessions).
314
+ const runningChildren = new Map();
315
+ const formatRunningChildrenRow = () => {
316
+ if (runningChildren.size === 0)
317
+ return '';
318
+ const parts = [];
319
+ for (const [id, info] of runningChildren) {
320
+ const idShort = id.slice(0, 8);
321
+ const tail = info.tool ? ` ${info.tool}` : '';
322
+ parts.push(`${id.startsWith('agent-') ? id.slice(0, 14) : 'agent-' + idShort} (${info.role}${tail})`);
323
+ }
324
+ return `running children: ${parts.join(', ')}`;
325
+ };
326
+ try {
327
+ const answer = await agent.runTurn(expanded, {
328
+ onStatusUpdate: tickStatus,
329
+ onToolStart: (name, args) => {
330
+ // Surface the in-flight tool via the spinner status line — the
331
+ // scrollback entry is pushed at onToolEnd so each tool call is
332
+ // a single block (header + result), not two rows.
333
+ toolStartTimes.set(name, Date.now());
334
+ toolArgsSnapshot.set(name, args ?? {});
335
+ if (!isQuiet()) {
336
+ controller.push.setStatus(formatToolCall(name, args));
337
+ }
338
+ },
339
+ onToolEnd: (name, result) => {
340
+ // Quiet mode hides successes (the prose response covers them).
341
+ if (isQuiet() && result.success) {
342
+ tickStatus('Thinking');
343
+ return;
344
+ }
345
+ const startedAt = toolStartTimes.get(name);
346
+ const args = toolArgsSnapshot.get(name);
347
+ toolStartTimes.delete(name);
348
+ toolArgsSnapshot.delete(name);
349
+ const durationMs = startedAt ? Date.now() - startedAt : undefined;
350
+ const header = formatToolCall(name, args);
351
+ controller.push.tool(header, result.success, {
352
+ preview: !isQuiet() ? result.preview : undefined,
353
+ durationMs,
354
+ });
355
+ tickStatus('Thinking');
356
+ },
357
+ onPlanUpdate: (items, explanation) => {
358
+ // Explanation rides on the plan entry itself (renders as a dim-italic
359
+ // line above the checklist) rather than as a separate memory event,
360
+ // so the explanation visually anchors to the plan it describes.
361
+ controller.push.plan(items, explanation);
362
+ tickStatus('Thinking');
363
+ },
364
+ onChildToolStart: (event) => {
365
+ const key = `${event.childId}:${event.tool}`;
366
+ childToolArgs.set(key, event.args ?? {});
367
+ const prior = runningChildren.get(event.childId);
368
+ runningChildren.set(event.childId, { role: event.role, tool: event.tool });
369
+ // Live status row so the user sees WHICH children are alive while
370
+ // the parent is waiting. Quiet-mode rule: still surface long-running
371
+ // child state — it's the user's only signal that the parent isn't stuck.
372
+ const row = formatRunningChildrenRow();
373
+ if (row)
374
+ controller.push.setStatus(row);
375
+ // First-tool notice: emit a one-line "child started" row so the
376
+ // scrollback shows the child began before any tool finishes. Quiet
377
+ // mode suppresses this; the paired end row below is enough.
378
+ if (!prior && !isQuiet()) {
379
+ const idShort = event.childId.slice(0, 8);
380
+ const idLabel = event.childId.startsWith('agent-') ? event.childId.slice(0, 14) : 'agent-' + idShort;
381
+ controller.push.notice(`▶ ${idLabel} (${event.role}) running...`, 'info');
382
+ }
383
+ },
384
+ onChildToolEnd: (event) => {
385
+ const key = `${event.childId}:${event.tool}`;
386
+ const args = childToolArgs.get(key);
387
+ childToolArgs.delete(key);
388
+ // Tool finished — null out the tool field so the running-children
389
+ // status row stops showing a stale tool name.
390
+ const cur = runningChildren.get(event.childId);
391
+ if (cur)
392
+ runningChildren.set(event.childId, { role: cur.role, tool: undefined });
393
+ const idShort = event.childId.slice(0, 8);
394
+ const idLabel = event.childId.startsWith('agent-') ? event.childId.slice(0, 14) : 'agent-' + idShort;
395
+ const inner = formatToolCall(event.tool, args);
396
+ const header = `[${idLabel} ${event.role}] ${inner}`;
397
+ // Quiet-mode rule (carried from R1): hide noisy success previews,
398
+ // but still print the paired row so the user has a visible signal
399
+ // that the child made progress.
400
+ controller.push.tool(header, event.ok, {
401
+ preview: !isQuiet() ? event.preview : undefined,
402
+ durationMs: event.durationMs,
403
+ });
404
+ tickStatus('Thinking');
405
+ },
406
+ onChildComplete: (event) => {
407
+ runningChildren.delete(event.childId);
408
+ const ok = event.status === 'completed';
409
+ const head = ok
410
+ ? `🏁 Agent ${event.childId} (${event.role}) completed`
411
+ : `💥 Agent ${event.childId} (${event.role}) failed`;
412
+ const tail = ok && event.preview
413
+ ? ` — ${event.preview}`
414
+ : event.error ? ` — ${event.error}` : '';
415
+ controller.push.notice(head + tail, ok ? 'info' : 'error');
416
+ tickStatus('Thinking');
417
+ },
418
+ onMemoryEvent: (event) => {
419
+ if (isQuiet() && event.kind !== 'contradiction')
420
+ return;
421
+ let line;
422
+ let level = 'info';
423
+ if (event.kind === 'briefing') {
424
+ const src = event.sources.length > 0 ? event.sources.join(', ') : '(none)';
425
+ line = `🧠 Briefing: ${event.recordCount} record${event.recordCount === 1 ? '' : 's'} from ${src}`;
426
+ }
427
+ else if (event.kind === 'capture') {
428
+ const sensory = event.sensoryRecorded ?? event.messageCount;
429
+ const extracted = event.extractedCount;
430
+ const triggered = event.extractionTriggered;
431
+ const sk = event.sessionKey.slice(0, 12);
432
+ if (event.extractionWarning) {
433
+ line = `💾 Captured ${sensory} sensory msg(s) in ${sk}… — ⚠️ ${event.extractionWarning}`;
434
+ level = 'warn';
435
+ }
436
+ else if (triggered && typeof extracted === 'number') {
437
+ line = extracted > 0
438
+ ? `💾 Captured ${sensory} msg(s) → ${extracted} cognitive record(s) extracted (${sk}…)`
439
+ : `💾 Captured ${sensory} msg(s) → no new memories worth promoting (${sk}…)`;
440
+ }
441
+ else if (triggered === false) {
442
+ line = `💾 Captured ${sensory} msg(s) → sensory buffer (${sk}…)`;
443
+ }
444
+ else {
445
+ line = `💾 Captured ${sensory} msg(s) → memory (${sk}…)`;
446
+ }
447
+ }
448
+ else if (event.kind === 'citation' && event.recordIds.length > 0) {
449
+ line = `📌 Reinforced ${event.recordIds.length} record${event.recordIds.length === 1 ? '' : 's'}: ${event.recordIds.slice(0, 3).join(', ')}${event.recordIds.length > 3 ? '…' : ''}`;
450
+ }
451
+ else if (event.kind === 'contradiction') {
452
+ line = `⚠️ Memory contradiction: ${event.warning.slice(0, 140)}`;
453
+ level = 'warn';
454
+ }
455
+ if (line)
456
+ controller.push.memory(level, line);
457
+ tickStatus('Thinking');
458
+ },
459
+ });
460
+ parentDone = true;
461
+ const elapsed = Date.now() - startedAt;
462
+ const u = agent.lastTurnUsage;
463
+ // Pass the raw answer to ChatApp; ChatApp's ScrollbackRow renders
464
+ // it through marked-terminal unless `raw: true` is set. Honors the
465
+ // user's rawScrollback preference exactly like the readline path.
466
+ const prefsForRender = readPreferences(agent.workspaceRoot);
467
+ controller.push.assistant(answer, {
468
+ raw: prefsForRender.rawScrollback === true,
469
+ durationMs: elapsed,
470
+ tokensIn: u.promptTokens,
471
+ tokensOut: u.completionTokens,
472
+ calls: u.calls,
473
+ });
474
+ const warning = agent.takeContradictionWarning();
475
+ if (warning) {
476
+ controller.push.memory('warn', `Memory: ${warning}`);
477
+ controller.push.memory('info', `Use /memory or /briefing to investigate, /forget <id> to archive obsolete records.`);
478
+ }
479
+ // Goal continuation lives at the bottom of the success path so a
480
+ // failed turn doesn't trigger it (we don't want auto-retry loops).
481
+ scheduleGoalContinuation(rawInput, answer);
482
+ }
483
+ catch (err) {
484
+ parentDone = true;
485
+ controller.push.notice(`✗ Execution failed: ${err?.message ?? err}`, 'error');
486
+ }
487
+ finally {
488
+ isProcessing = false;
489
+ controller.push.setPhase('idle');
490
+ controller.push.setStatus('');
491
+ agent.activeSkill = undefined;
492
+ agent.refreshSystemPrompt();
493
+ refreshFooter();
494
+ armIdleHint();
495
+ }
496
+ };
497
+ // Background `/schedule` ticker. Single in-process timer; fires due
498
+ // cron/one-shot jobs by re-injecting their slash command through the
499
+ // same dispatcher the user uses. Filtered by sessionKey so a tick
500
+ // only fires jobs owned by THIS REPL — schedules registered in a
501
+ // different session sit idle until that session is open. Stops in
502
+ // the `waitUntilExit` handlers below so /exit and ^C clean up.
503
+ let scheduleTicker = null;
504
+ const startTicker = () => {
505
+ if (scheduleTicker)
506
+ return;
507
+ scheduleTicker = startScheduleTicker({
508
+ workspaceRoot: agent.workspaceRoot,
509
+ sessionKey: agent.sessionKey,
510
+ fire: (command, sched) => {
511
+ if (!controller)
512
+ return;
513
+ if (isProcessing) {
514
+ // Catch-up rule: only fire ONCE per missed window. The ticker
515
+ // has already advanced nextRun past `now`, so silently
516
+ // dropping a busy-session fire is correct — it won't refire
517
+ // for the same minute.
518
+ controller.push.notice(`(schedule ${sched.id} fired while a turn was in flight — skipped)`, 'warn');
519
+ return;
520
+ }
521
+ const parts = command.trim().split(/\s+/);
522
+ const cmd = parts[0].toLowerCase();
523
+ const args = parts.slice(1);
524
+ controller.push.notice(`⏰ Schedule ${sched.id} → ${command}`, 'info');
525
+ void dispatchSlash(cmd, args, shim);
526
+ },
527
+ onError: (msg) => controller?.push.notice(`[schedule] ${msg}`, 'warn'),
528
+ });
529
+ };
530
+ // Mount Ink. We DON'T set `patchConsole: false` — Ink's default
531
+ // (patchConsole enabled) is exactly what we want: legacy slash
532
+ // commands that still write via chalk + console.log have their
533
+ // output promoted ABOVE Ink's redraw region instead of clobbering it.
534
+ return new Promise((resolve) => {
535
+ const { instance, cleanupResizeClear } = renderWithResizeClear(_jsx(ChatApp, { initialBanner: '\n' + banner, initialOfflineWarning: offlineWarning, initialHint: hint, slashCommands: slashCatalog, promptLabel: `brainrouter[${agent.getAccessMode()}]`, initialAccessMode: agent.getAccessMode(), initialFooter: {
536
+ model: agent.getModel(),
537
+ session: agent.sessionKey,
538
+ effort: readPreferences(agent.workspaceRoot).effort,
539
+ }, onReady: (ctrl) => {
540
+ controller = ctrl;
541
+ // Publish the shim so cliPrompt's askYesNo can find an "active
542
+ // readline" while the Ink REPL owns stdin. Without this, every
543
+ // mid-turn yes/no prompt returns its default silently.
544
+ setActiveReadline(shim);
545
+ // Publish the controller so runPicker / runTextField route their
546
+ // UI through the chat's overlay slot instead of mounting a
547
+ // second Ink instance (which would race for stdin + terminal
548
+ // state). See ambientChat.ts for the rationale.
549
+ setAmbientChat({
550
+ showOverlay: ctrl.showOverlay,
551
+ clearOverlay: ctrl.clearOverlay,
552
+ });
553
+ refreshFooter();
554
+ armIdleHint();
555
+ startTicker();
556
+ }, onAccessModeCycle: () => {
557
+ const cycle = ['read', 'write', 'shell'];
558
+ const current = agent.getAccessMode();
559
+ const next = cycle[(cycle.indexOf(current) + 1) % cycle.length];
560
+ agent.setAccessMode(next);
561
+ refreshFooter();
562
+ return next;
563
+ }, onSubmit: async (text, push) => {
564
+ // Any in-flight goal continuation is cancelled by user input,
565
+ // regardless of whether the input is a slash or a prompt.
566
+ if (pendingContinuation) {
567
+ pendingContinuation = false;
568
+ push.notice('(goal continuation cancelled by user input)');
569
+ }
570
+ clearIdleHint();
571
+ // If a slash command's handler had called `rl.question(cb)`,
572
+ // the very next submission belongs to `cb` — not the dispatcher.
573
+ if (questionCallback) {
574
+ const cb = questionCallback;
575
+ questionCallback = undefined;
576
+ cb(text);
577
+ return;
578
+ }
579
+ // Bare `?` → help (mirrors the readline REPL — the idle hint
580
+ // advertises it, so make it actually work).
581
+ if (text === '?') {
582
+ await dispatchSlash('/help', [], shim);
583
+ return;
584
+ }
585
+ if (text.startsWith('/')) {
586
+ const parts = text.trim().split(/\s+/);
587
+ const command = parts[0].toLowerCase();
588
+ const args = parts.slice(1);
589
+ await dispatchSlash(command, args, shim);
590
+ return;
591
+ }
592
+ if (isProcessing) {
593
+ push.notice('A previous turn is still running. Wait for the prompt before sending another message.');
594
+ return;
595
+ }
596
+ await runChatTurn(text);
597
+ } }), { exitOnCtrlC: true, patchConsole: true });
598
+ instance.waitUntilExit().then(async () => {
599
+ exited = true;
600
+ setActiveReadline(undefined);
601
+ setAmbientChat(undefined);
602
+ cleanupResizeClear();
603
+ clearIdleHint();
604
+ try {
605
+ scheduleTicker?.stop();
606
+ }
607
+ catch { /* noop */ }
608
+ scheduleTicker = null;
609
+ try {
610
+ await mcpClient.close();
611
+ }
612
+ catch { /* already closed */ }
613
+ // Goodbye line is intentionally printed AFTER Ink unmounts so it
614
+ // doesn't get caught inside the redraw region.
615
+ process.stdout.write(chalk.bold.hex('#CC9166')('Goodbye!\n'));
616
+ resolve();
617
+ }).catch(async () => {
618
+ exited = true;
619
+ setActiveReadline(undefined);
620
+ setAmbientChat(undefined);
621
+ cleanupResizeClear();
622
+ clearIdleHint();
623
+ try {
624
+ scheduleTicker?.stop();
625
+ }
626
+ catch { /* noop */ }
627
+ scheduleTicker = null;
628
+ try {
629
+ await mcpClient.close();
630
+ }
631
+ catch { /* already closed */ }
632
+ resolve();
633
+ });
634
+ });
635
+ async function dispatchSlash(command, args, rl) {
636
+ if (!controller)
637
+ return;
638
+ try {
639
+ const captured = await captureConsoleOutput(() => handleSlashCommand(command, args, agent, mcpClient, config, rl, {
640
+ refreshPromptForMode: refreshFooter,
641
+ replaceBanner: (text) => controller?.replaceBanner(text),
642
+ isProcessing: () => isProcessing,
643
+ runAgentTurn: (prompt) => { void runChatTurn(prompt); },
644
+ runAgentTurnAsync: (prompt) => runChatTurn(prompt),
645
+ }));
646
+ const output = captured.output.trimEnd();
647
+ if (output) {
648
+ controller.push.raw(output);
649
+ }
650
+ }
651
+ catch (err) {
652
+ controller.push.notice(`Slash command "${command}" failed: ${err?.message ?? err}`, 'error');
653
+ }
654
+ finally {
655
+ // Pull any preferences / model / branch / effort changes the
656
+ // command made (e.g. /effort, /model, /theme, /statusline) so
657
+ // the footer reflects them immediately rather than waiting for
658
+ // the next chat turn to refresh.
659
+ refreshFooter();
660
+ }
661
+ }
662
+ }
663
+ function createReadlineShim(hooks) {
664
+ const emitter = new EventEmitter();
665
+ const shim = emitter;
666
+ shim.close = () => { hooks.closeChat(); };
667
+ shim.prompt = (_preserveCursor) => { };
668
+ shim.pause = () => shim;
669
+ shim.resume = () => shim;
670
+ shim.write = (text) => { hooks.onWriteToComposer(text); };
671
+ shim.setPrompt = (_text) => { };
672
+ // Promise-shaped `question` for askYesNo: print the prompt text via
673
+ // console.log (Ink's patchConsole bubbles it above the redraw region)
674
+ // and stash the callback for the next submission.
675
+ shim.question = (q, cb) => {
676
+ process.stdout.write(q);
677
+ hooks.waitForLine(cb);
678
+ };
679
+ shim.line = '';
680
+ shim.cursor = 0;
681
+ return shim;
682
+ }