@kinqs/brainrouter-cli 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/dist/agent/agent.d.ts +12 -1
- package/dist/agent/agent.js +134 -18
- package/dist/cli/banner.d.ts +20 -0
- package/dist/cli/banner.js +47 -14
- package/dist/cli/cliPrompt.d.ts +40 -3
- package/dist/cli/cliPrompt.js +52 -25
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +13 -11
- package/dist/cli/commands/mcp.js +239 -74
- package/dist/cli/commands/orchestration.js +18 -0
- package/dist/cli/commands/ui.js +117 -58
- package/dist/cli/commands/workflow.d.ts +2 -0
- package/dist/cli/commands/workflow.js +54 -8
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +65 -0
- package/dist/cli/ink/Picker.js +133 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +571 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +73 -0
- package/dist/cli/ink/toolFormat.js +180 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +43 -712
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +12 -0
- package/dist/config/config.js +45 -3
- package/dist/index.js +148 -206
- package/dist/memory/briefing.d.ts +1 -1
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +10 -1
- package/dist/orchestration/tools.js +48 -4
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.js +5 -1
- package/dist/runtime/mcpClient.js +14 -11
- package/dist/runtime/mcpPool.d.ts +162 -0
- package/dist/runtime/mcpPool.js +423 -0
- package/dist/runtime/mcpUtils.d.ts +3 -1
- package/package.json +8 -2
- package/.env.example +0 -116
|
@@ -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';
|