@kinqs/brainrouter-cli 0.3.5 → 0.3.6

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 (49) hide show
  1. package/.env.example +55 -48
  2. package/bin/cli.cjs +71 -0
  3. package/dist/agent/agent.d.ts +212 -2
  4. package/dist/agent/agent.js +428 -38
  5. package/dist/cli/banner.d.ts +60 -0
  6. package/dist/cli/banner.js +199 -0
  7. package/dist/cli/cliPrompt.d.ts +69 -0
  8. package/dist/cli/cliPrompt.js +287 -0
  9. package/dist/cli/commands/_helpers.js +6 -6
  10. package/dist/cli/commands/guard.js +75 -10
  11. package/dist/cli/commands/mcp.d.ts +17 -0
  12. package/dist/cli/commands/mcp.js +121 -0
  13. package/dist/cli/commands/memory.js +2 -2
  14. package/dist/cli/commands/obs.js +22 -22
  15. package/dist/cli/commands/session.js +13 -5
  16. package/dist/cli/commands/ui.js +97 -45
  17. package/dist/cli/commands/workflow.d.ts +18 -0
  18. package/dist/cli/commands/workflow.js +314 -43
  19. package/dist/cli/repl.js +219 -132
  20. package/dist/cli/spinner.d.ts +34 -0
  21. package/dist/cli/spinner.js +36 -0
  22. package/dist/cli/statusline.d.ts +67 -0
  23. package/dist/cli/statusline.js +204 -0
  24. package/dist/cli/theme.d.ts +79 -0
  25. package/dist/cli/theme.js +106 -0
  26. package/dist/cli/whereView.d.ts +81 -0
  27. package/dist/cli/whereView.js +245 -0
  28. package/dist/config/config.d.ts +40 -0
  29. package/dist/config/config.js +45 -73
  30. package/dist/index.js +80 -13
  31. package/dist/memory/briefing.d.ts +10 -0
  32. package/dist/memory/briefing.js +69 -1
  33. package/dist/prompt/breadthHint.d.ts +5 -0
  34. package/dist/prompt/breadthHint.js +44 -0
  35. package/dist/prompt/systemPrompt.d.ts +34 -0
  36. package/dist/prompt/systemPrompt.js +124 -108
  37. package/dist/runtime/dangerousCommand.d.ts +53 -0
  38. package/dist/runtime/dangerousCommand.js +105 -0
  39. package/dist/runtime/mcpClient.d.ts +38 -1
  40. package/dist/runtime/mcpClient.js +90 -2
  41. package/dist/state/goalStore.d.ts +98 -17
  42. package/dist/state/goalStore.js +132 -42
  43. package/dist/state/preferencesStore.d.ts +67 -3
  44. package/dist/state/preferencesStore.js +84 -1
  45. package/dist/state/workflowArtifacts.d.ts +63 -2
  46. package/dist/state/workflowArtifacts.js +120 -8
  47. package/dist/tests/_helpers.d.ts +31 -0
  48. package/dist/tests/_helpers.js +91 -0
  49. package/package.json +5 -4
@@ -0,0 +1,60 @@
1
+ import type { Config } from '../config/config.js';
2
+ import type { Goal } from '../state/goalStore.js';
3
+ import { type Theme } from './theme.js';
4
+ export interface BannerInputs {
5
+ workspaceRoot: string;
6
+ /** "local-http", "stdio", "custom" — config.activeServer. */
7
+ mcpProfile: string;
8
+ /** "stdio" | "http". */
9
+ mcpTransport: string;
10
+ /** True when the MCP handshake succeeded. */
11
+ mcpOnline: boolean;
12
+ /**
13
+ * 10c: which MCP this profile actually IS — drives the distinct "brain"
14
+ * row when the active MCP is BrainRouter (or unknown, which we treat as
15
+ * "likely brain"). When the active MCP is explicitly third-party, the
16
+ * brain row is omitted entirely so the box stays compact.
17
+ */
18
+ mcpIdentity?: 'brainrouter' | 'third-party' | 'unknown';
19
+ /** Resolved sessionKey for this CLI process. */
20
+ sessionKey: string;
21
+ /** Chat-LLM model name (e.g. gpt-4o-mini). */
22
+ model: string;
23
+ /** Slug + status of the currently-bound workflow, if any. */
24
+ workflow?: {
25
+ slug: string;
26
+ status: string;
27
+ };
28
+ /**
29
+ * Slug of the last workflow that was active in this workspace, surfaced
30
+ * only when the current session has NO workflow bound. Rendered as a
31
+ * one-line hint so the user can `/workflow switch <slug>` to resume.
32
+ * Doesn't auto-bind anything — workflows are pure storage now (goals
33
+ * are session-scoped runtime state). Empty / matching the active
34
+ * workflow → no hint row.
35
+ */
36
+ lastUsedWorkflow?: string;
37
+ /** Goal-store snapshot, if any. */
38
+ goal?: Goal;
39
+ /** Version override (test fixture). */
40
+ version?: string;
41
+ }
42
+ /**
43
+ * Pure renderer — returns the box as a single newline-joined string with
44
+ * ANSI sequences from `theme`. Caller appends the trailing newline.
45
+ */
46
+ export declare function renderBanner(inputs: BannerInputs, theme: Theme): string;
47
+ /**
48
+ * Convenience: assemble the inputs from live agent + config + workspace
49
+ * state. Pure read; no side effects. Anything that throws while reading the
50
+ * goal or current-workflow files is treated as "not set" so a half-set-up
51
+ * workspace doesn't crash the banner.
52
+ */
53
+ export declare function buildBannerInputs(config: Config, agent: {
54
+ sessionKey: string;
55
+ workspaceRoot: string;
56
+ getModel: () => string;
57
+ }, mcpClient: {
58
+ isConnected: () => boolean;
59
+ getIdentity?: () => 'brainrouter' | 'third-party' | 'unknown';
60
+ }): BannerInputs;
@@ -0,0 +1,199 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { formatBudget } from '../state/goalStore.js';
4
+ import { getCurrentWorkflow, getLastUsedWorkflow } from '../state/workflowArtifacts.js';
5
+ import { readGoal } from '../state/goalStore.js';
6
+ import { BOX } from './theme.js';
7
+ /**
8
+ * Compose the boxed startup banner. Replaces the prior three-line text dump
9
+ * (chalk title + workspace line + connecting-to line) with a single visually
10
+ * scannable block:
11
+ *
12
+ * ╭─ 🧠 BrainRouter CLI 0.3.5 ──────────────────────────────╮
13
+ * │ workspace BrainRouter · c5b8c12d │
14
+ * │ mcp local-http · http · online │
15
+ * │ workflow cli-shell-redesign (in-progress) │
16
+ * │ goal active 3 of unlimited iterations │
17
+ * │ session 7f3a1e0c │
18
+ * │ model gpt-4o-mini │
19
+ * ╰─────────────────────────────────────────────────────────╯
20
+ *
21
+ * Designed so a glance tells the user where they are: which repo, which MCP
22
+ * profile, what's the in-flight goal, which model is running. Anything not
23
+ * applicable (e.g. no goal set, no current workflow) is silently omitted —
24
+ * the box shrinks to fit instead of showing "—" placeholders.
25
+ *
26
+ * The function returns a single string with embedded ANSI; the caller prints
27
+ * it once. Pure-function so tests can assert against the rendered output.
28
+ */
29
+ const VERSION = '0.3.6';
30
+ const TITLE = '🧠 BrainRouter CLI';
31
+ const MIN_WIDTH = 56;
32
+ const MAX_WIDTH = 100;
33
+ function shortHash(absPath) {
34
+ return crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 8);
35
+ }
36
+ function formatGoalSummary(goal) {
37
+ const cap = formatBudget(goal.budget.maxIterations);
38
+ const used = goal.budget.iterationsUsed;
39
+ // Status verbs read naturally inline. "usage_limited" → "limited".
40
+ const statusWord = goal.status === 'usage_limited' ? 'limited' :
41
+ goal.status === 'active' ? 'active' :
42
+ goal.status;
43
+ if (goal.status === 'complete')
44
+ return 'complete';
45
+ if (goal.status === 'blocked')
46
+ return `blocked — ${goal.blockedReason?.slice(0, 40) ?? 'see /goal'}`;
47
+ return `${statusWord} ${used} of ${cap} iterations`;
48
+ }
49
+ /**
50
+ * Workspace label — basename of the workspace root, with a short hash so
51
+ * two repos with the same basename (e.g. two clones of "playground") don't
52
+ * look identical.
53
+ */
54
+ function formatWorkspace(workspaceRoot) {
55
+ const base = path.basename(workspaceRoot) || workspaceRoot;
56
+ return `${base} · ${shortHash(workspaceRoot)}`;
57
+ }
58
+ function formatMcp(profile, transport, online) {
59
+ const dot = online ? 'online' : 'offline';
60
+ return `${profile} · ${transport} · ${dot}`;
61
+ }
62
+ /**
63
+ * 10c: brain row — distinct from the generic MCP row so the user can tell
64
+ * "the BrainRouter cloud brain is down" from "a third-party MCP is down".
65
+ * Renders only when the active MCP is BrainRouter (or unknown). Returns
66
+ * `undefined` when there's nothing meaningful to say (e.g. user only has a
67
+ * third-party MCP connected).
68
+ */
69
+ function formatBrain(identity, online) {
70
+ if (identity === 'third-party')
71
+ return undefined;
72
+ if (identity === 'unknown')
73
+ return undefined; // wait for tool-signature detection
74
+ if (online)
75
+ return '🟢 online';
76
+ return '🔴 offline · cloud unreachable';
77
+ }
78
+ function formatWorkflow(workflow) {
79
+ if (!workflow)
80
+ return undefined;
81
+ return `${workflow.slug} (${workflow.status})`;
82
+ }
83
+ function clipValue(value, width) {
84
+ if (value.length <= width)
85
+ return value;
86
+ if (width <= 1)
87
+ return value.slice(0, width);
88
+ return value.slice(0, width - 1) + '…';
89
+ }
90
+ function padRight(s, width) {
91
+ return s.length >= width ? s : s + ' '.repeat(width - s.length);
92
+ }
93
+ /**
94
+ * Pure renderer — returns the box as a single newline-joined string with
95
+ * ANSI sequences from `theme`. Caller appends the trailing newline.
96
+ */
97
+ export function renderBanner(inputs, theme) {
98
+ const rows = [];
99
+ rows.push({ label: 'workspace', value: formatWorkspace(inputs.workspaceRoot) });
100
+ rows.push({ label: 'mcp', value: formatMcp(inputs.mcpProfile, inputs.mcpTransport, inputs.mcpOnline) });
101
+ // 10c: brain status sits below the mcp row — same level of visibility,
102
+ // but distinct so multi-MCP setups (Item 11) won't be ambiguous.
103
+ const brain = formatBrain(inputs.mcpIdentity, inputs.mcpOnline);
104
+ if (brain)
105
+ rows.push({ label: 'brain', value: brain });
106
+ const wf = formatWorkflow(inputs.workflow);
107
+ if (wf) {
108
+ rows.push({ label: 'workflow', value: wf });
109
+ }
110
+ else if (inputs.lastUsedWorkflow) {
111
+ // Fresh session with no current workflow but a known last-used
112
+ // workflow in this workspace — offer the resume incantation without
113
+ // auto-binding. Quiet so the user notices but isn't pushed into it.
114
+ rows.push({ label: 'last on', value: `${inputs.lastUsedWorkflow} /workflow switch ${inputs.lastUsedWorkflow}` });
115
+ }
116
+ if (inputs.goal)
117
+ rows.push({ label: 'goal', value: formatGoalSummary(inputs.goal) });
118
+ rows.push({ label: 'session', value: inputs.sessionKey.slice(0, 8) });
119
+ rows.push({ label: 'model', value: inputs.model });
120
+ const version = inputs.version ?? VERSION;
121
+ const titleText = `${TITLE} ${version}`;
122
+ const labelWidth = rows.reduce((w, r) => Math.max(w, r.label.length), 0);
123
+ // Inner width is the widest "label + 2 spaces + value", clamped.
124
+ const naturalInner = rows.reduce((w, r) => Math.max(w, labelWidth + 2 + r.value.length), titleText.length + 4);
125
+ const targetCols = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : MAX_WIDTH;
126
+ // Reserve 2 columns for the side borders.
127
+ const innerWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.min(naturalInner, targetCols - 2)));
128
+ const top = (() => {
129
+ // ╭─ <title> ──╮ — title sits inline at the top border.
130
+ const titlePiece = ` ${titleText} `;
131
+ const horizontalFill = Math.max(0, innerWidth - 1 - titlePiece.length);
132
+ return theme.primary(BOX.topLeft + BOX.horizontal + titlePiece + BOX.horizontal.repeat(horizontalFill) + BOX.topRight);
133
+ })();
134
+ const bodyLines = rows.map((row) => {
135
+ const valueWidth = innerWidth - labelWidth - 3; // 1 left pad + 2 gap
136
+ const clipped = clipValue(row.value, valueWidth);
137
+ const inside = ' ' + theme.muted(padRight(row.label, labelWidth)) + ' ' + theme.plain(padRight(clipped, valueWidth));
138
+ return theme.primary(BOX.vertical) + inside + theme.primary(BOX.vertical);
139
+ });
140
+ const bottom = theme.primary(BOX.bottomLeft + BOX.horizontal.repeat(innerWidth) + BOX.bottomRight);
141
+ return [top, ...bodyLines, bottom].join('\n');
142
+ }
143
+ /**
144
+ * Convenience: assemble the inputs from live agent + config + workspace
145
+ * state. Pure read; no side effects. Anything that throws while reading the
146
+ * goal or current-workflow files is treated as "not set" so a half-set-up
147
+ * workspace doesn't crash the banner.
148
+ */
149
+ export function buildBannerInputs(config, agent, mcpClient) {
150
+ const profile = config.activeServer;
151
+ const server = config.servers[profile];
152
+ const transport = server?.type ?? 'unknown';
153
+ // 10c: identity comes from the live wrapper when present; fall back to
154
+ // the config field for callers that pass a thin stub.
155
+ const mcpIdentity = mcpClient.getIdentity ? mcpClient.getIdentity() : (server?.identity ?? 'unknown');
156
+ let workflow;
157
+ let lastUsedWorkflow;
158
+ try {
159
+ // 9d-bugfix: read the session-scoped binding so a fresh CLI session
160
+ // shows no workflow row even when another CLI in the same workspace
161
+ // has one bound.
162
+ const slug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
163
+ if (slug) {
164
+ // We don't crack open workflowArtifacts.listWorkflows here — just the
165
+ // pointer file. Status would require parsing meta.json, which has its
166
+ // own cost on a slow disk; "bound" is enough to communicate state.
167
+ workflow = { slug, status: 'bound' };
168
+ }
169
+ else {
170
+ // Fresh session in a workspace where a previous CLI was on
171
+ // workflow X — surface that as a hint so the user can rebind via
172
+ // `/workflow switch X` if they want continuity. Doesn't auto-bind
173
+ // (per the decoupling design — workflows are storage, goals are
174
+ // runtime, the two have orthogonal lifecycles).
175
+ try {
176
+ lastUsedWorkflow = getLastUsedWorkflow(agent.workspaceRoot);
177
+ }
178
+ catch { /* ignore */ }
179
+ }
180
+ }
181
+ catch { /* ignore — no workflow bound */ }
182
+ let goal;
183
+ try {
184
+ goal = readGoal(agent.workspaceRoot, agent.sessionKey) ?? undefined;
185
+ }
186
+ catch { /* ignore — no goal yet */ }
187
+ return {
188
+ workspaceRoot: agent.workspaceRoot,
189
+ mcpProfile: profile,
190
+ mcpTransport: transport,
191
+ mcpOnline: mcpClient.isConnected(),
192
+ mcpIdentity,
193
+ sessionKey: agent.sessionKey,
194
+ model: agent.getModel(),
195
+ workflow,
196
+ lastUsedWorkflow,
197
+ goal,
198
+ };
199
+ }
@@ -1,12 +1,81 @@
1
1
  import readline from 'node:readline';
2
2
  export declare function setActiveReadline(rl: readline.Interface | undefined): void;
3
3
  export declare function getActiveReadline(): readline.Interface | undefined;
4
+ export declare function isPickerActive(): boolean;
4
5
  /**
5
6
  * One-shot yes/no question. Returns true only when the user types y/yes
6
7
  * (case-insensitive). Returns the supplied default when stdin isn't a TTY
7
8
  * (e.g. piped non-interactive runs).
8
9
  */
9
10
  export declare function askYesNo(question: string, defaultValue?: boolean): Promise<boolean>;
11
+ /**
12
+ * Surfaced when `askChoice` is called outside an interactive TTY. The tool
13
+ * wrapper turns this into a tool-call error so the LLM falls back to deciding
14
+ * itself — silently picking option 1 for the agent in CI / piped runs would
15
+ * make a load-bearing decision the user never saw.
16
+ */
17
+ export declare class NoTTYError extends Error {
18
+ constructor(message: string);
19
+ }
20
+ /**
21
+ * Surfaced when the user pressed Esc / q / Ctrl+C inside the picker.
22
+ * The tool wrapper converts this into a tool-call error so the LLM knows
23
+ * the user declined to commit and can re-plan instead of guessing.
24
+ */
25
+ export declare class CancelledChoiceError extends Error {
26
+ constructor(message?: string);
27
+ }
28
+ export interface ChoiceOption {
29
+ label: string;
30
+ description: string;
31
+ }
32
+ export interface PickerState {
33
+ /** Includes the synthetic Other entry at the last index. */
34
+ options: ChoiceOption[];
35
+ cursor: number;
36
+ multiSelect: boolean;
37
+ /** Indices of options the user has toggled on (multi-select only). */
38
+ selected: Set<number>;
39
+ /** True once the user confirmed Other and we're collecting free text. */
40
+ awaitingOther: boolean;
41
+ /** Accumulated free-text for the Other prompt. */
42
+ otherText: string;
43
+ done: boolean;
44
+ cancelled: boolean;
45
+ /** Final resolved value when `done && !cancelled`. */
46
+ result: string | string[] | null;
47
+ }
48
+ /** Normalized keystroke shape the reducer consumes. Decoupled from Node's
49
+ * raw `keypress` event so the reducer can be driven by test inputs that
50
+ * don't go through `emitKeypressEvents`. */
51
+ export interface PickerKey {
52
+ name?: string;
53
+ ctrl?: boolean;
54
+ sequence?: string;
55
+ /** A single printable character for free-text capture (Other phase). */
56
+ char?: string;
57
+ }
58
+ export declare function initPickerState(options: ChoiceOption[], multiSelect: boolean): PickerState;
59
+ export declare function reducePicker(state: PickerState, key: PickerKey): PickerState;
60
+ export declare function renderPicker(state: PickerState, question: string, header?: string): string;
61
+ /**
62
+ * Mid-turn multi-choice prompt with arrow-key navigation, a checkbox UI
63
+ * for multi-select, and an always-on "Other" option that drops to free-text
64
+ * input. Pause/resume the parent REPL the same way `askYesNo` does, so it
65
+ * composes cleanly with the existing readline bridge.
66
+ *
67
+ * Non-TTY behavior is strict: throws `NoTTYError` instead of defaulting to
68
+ * option 1. The agent calling this is asking the human for judgment; making
69
+ * the call for them in CI / piped / `brainrouter run` would silently commit
70
+ * to a path the user never saw.
71
+ *
72
+ * User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
73
+ * tool wrapper can surface "user declined to commit" as a tool-call error.
74
+ */
75
+ export declare function askChoice(question: string, options: ChoiceOption[], opts?: {
76
+ multiSelect?: boolean;
77
+ header?: string;
78
+ }): Promise<string | string[]>;
10
79
  /**
11
80
  * Print a line of output while the prompt is showing, then redraw the prompt
12
81
  * with whatever the user was mid-typing. Used by callbacks that fire while the
@@ -1,3 +1,4 @@
1
+ import readline from 'node:readline';
1
2
  /**
2
3
  * Shared bridge between the REPL's readline interface and modules outside
3
4
  * repl.ts that need to (a) write above the prompt without scrambling input,
@@ -19,6 +20,13 @@ export function setActiveReadline(rl) {
19
20
  export function getActiveReadline() {
20
21
  return activeReadline;
21
22
  }
23
+ /**
24
+ * True while `askChoice` is rendering its raw-mode picker. The REPL's own
25
+ * keypress handler (shift+tab access-mode cycle) checks this and yields,
26
+ * so the picker has uncontested control of stdin while it's active.
27
+ */
28
+ let pickerActive = false;
29
+ export function isPickerActive() { return pickerActive; }
22
30
  /**
23
31
  * One-shot yes/no question. Returns true only when the user types y/yes
24
32
  * (case-insensitive). Returns the supplied default when stdin isn't a TTY
@@ -41,6 +49,285 @@ export function askYesNo(question, defaultValue = false) {
41
49
  });
42
50
  });
43
51
  }
52
+ /**
53
+ * Surfaced when `askChoice` is called outside an interactive TTY. The tool
54
+ * wrapper turns this into a tool-call error so the LLM falls back to deciding
55
+ * itself — silently picking option 1 for the agent in CI / piped runs would
56
+ * make a load-bearing decision the user never saw.
57
+ */
58
+ export class NoTTYError extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = 'NoTTYError';
62
+ }
63
+ }
64
+ /**
65
+ * Surfaced when the user pressed Esc / q / Ctrl+C inside the picker.
66
+ * The tool wrapper converts this into a tool-call error so the LLM knows
67
+ * the user declined to commit and can re-plan instead of guessing.
68
+ */
69
+ export class CancelledChoiceError extends Error {
70
+ constructor(message = 'ask_user_choice was cancelled by the user before they picked an option.') {
71
+ super(message);
72
+ this.name = 'CancelledChoiceError';
73
+ }
74
+ }
75
+ // --- Pure picker state machine -------------------------------------------
76
+ // Split out as exported pure functions so they're trivial to unit-test
77
+ // without faking a TTY or piping through keypress events. The orchestrator
78
+ // (`askChoice`) only owns the side-effecting bits: wiring stdin keypress
79
+ // events into the reducer and re-rendering the screen.
80
+ /** Synthetic always-on "Other" option appended to every picker. */
81
+ const OTHER_LABEL = 'Other';
82
+ const OTHER_DESCRIPTION = 'Type a free-form answer not listed above';
83
+ export function initPickerState(options, multiSelect) {
84
+ const augmented = [...options, { label: OTHER_LABEL, description: OTHER_DESCRIPTION }];
85
+ return {
86
+ options: augmented,
87
+ cursor: 0,
88
+ multiSelect,
89
+ selected: new Set(),
90
+ awaitingOther: false,
91
+ otherText: '',
92
+ done: false,
93
+ cancelled: false,
94
+ result: null,
95
+ };
96
+ }
97
+ function finalizeWithOther(state, text) {
98
+ if (state.multiSelect) {
99
+ const otherIdx = state.options.length - 1;
100
+ const indices = Array.from(state.selected).sort((a, b) => a - b);
101
+ const labels = indices.map((i) => (i === otherIdx ? text : state.options[i].label));
102
+ return { ...state, done: true, result: labels, otherText: text };
103
+ }
104
+ return { ...state, done: true, result: text, otherText: text };
105
+ }
106
+ export function reducePicker(state, key) {
107
+ if (state.done)
108
+ return state;
109
+ // Ctrl+C always cancels, in any phase. Don't gate on `key.name === 'c'`
110
+ // alone — some terminals send the sequence without a named binding.
111
+ if (key.ctrl && (key.name === 'c' || key.sequence === '')) {
112
+ return { ...state, done: true, cancelled: true };
113
+ }
114
+ // --- Free-text "Other" phase ------------------------------------------
115
+ if (state.awaitingOther) {
116
+ if (key.name === 'return' || key.sequence === '\r' || key.sequence === '\n') {
117
+ const text = state.otherText.trim();
118
+ if (!text)
119
+ return state; // empty ENTER is a no-op so the user can retry
120
+ return finalizeWithOther(state, text);
121
+ }
122
+ if (key.name === 'backspace') {
123
+ return { ...state, otherText: state.otherText.slice(0, -1) };
124
+ }
125
+ if (key.name === 'escape') {
126
+ // Bail back to the picker so a stray ENTER on Other isn't a one-way trip.
127
+ return { ...state, awaitingOther: false, otherText: '' };
128
+ }
129
+ if (key.char && key.char.length === 1) {
130
+ return { ...state, otherText: state.otherText + key.char };
131
+ }
132
+ return state;
133
+ }
134
+ // --- Picker phase -----------------------------------------------------
135
+ switch (key.name) {
136
+ case 'up':
137
+ return { ...state, cursor: (state.cursor - 1 + state.options.length) % state.options.length };
138
+ case 'down':
139
+ return { ...state, cursor: (state.cursor + 1) % state.options.length };
140
+ case 'space': {
141
+ if (!state.multiSelect)
142
+ return state;
143
+ const next = new Set(state.selected);
144
+ if (next.has(state.cursor))
145
+ next.delete(state.cursor);
146
+ else
147
+ next.add(state.cursor);
148
+ return { ...state, selected: next };
149
+ }
150
+ case 'return': {
151
+ const otherIdx = state.options.length - 1;
152
+ if (state.multiSelect) {
153
+ // Confirming with nothing selected is a no-op — the user must SPACE
154
+ // at least one row first. Bailing here keeps "I pressed ENTER too
155
+ // soon" from silently committing to an empty array.
156
+ if (state.selected.size === 0)
157
+ return state;
158
+ if (state.selected.has(otherIdx)) {
159
+ return { ...state, awaitingOther: true };
160
+ }
161
+ const indices = Array.from(state.selected).sort((a, b) => a - b);
162
+ return { ...state, done: true, result: indices.map((i) => state.options[i].label) };
163
+ }
164
+ if (state.cursor === otherIdx) {
165
+ return { ...state, awaitingOther: true };
166
+ }
167
+ return { ...state, done: true, result: state.options[state.cursor].label };
168
+ }
169
+ case 'escape':
170
+ case 'q':
171
+ return { ...state, done: true, cancelled: true };
172
+ }
173
+ return state;
174
+ }
175
+ export function renderPicker(state, question, header) {
176
+ const lines = [];
177
+ if (header)
178
+ lines.push(`[${header}]`);
179
+ lines.push(question);
180
+ lines.push('');
181
+ for (let i = 0; i < state.options.length; i++) {
182
+ const opt = state.options[i];
183
+ const cursor = i === state.cursor ? '▶' : ' ';
184
+ const mark = state.multiSelect ? (state.selected.has(i) ? '☑ ' : '☐ ') : '';
185
+ lines.push(` ${cursor} ${mark}${opt.label} — ${opt.description}`);
186
+ }
187
+ lines.push('');
188
+ if (state.awaitingOther) {
189
+ lines.push('[Other] Type your answer and press ENTER · Backspace to edit · Esc to go back');
190
+ lines.push(`> ${state.otherText}_`);
191
+ }
192
+ else if (state.multiSelect) {
193
+ lines.push('↑/↓ navigate · SPACE toggle · ENTER confirm · q to cancel');
194
+ }
195
+ else {
196
+ lines.push('↑/↓ navigate · ENTER confirm · q to cancel');
197
+ }
198
+ return lines.join('\n');
199
+ }
200
+ /**
201
+ * Mid-turn multi-choice prompt with arrow-key navigation, a checkbox UI
202
+ * for multi-select, and an always-on "Other" option that drops to free-text
203
+ * input. Pause/resume the parent REPL the same way `askYesNo` does, so it
204
+ * composes cleanly with the existing readline bridge.
205
+ *
206
+ * Non-TTY behavior is strict: throws `NoTTYError` instead of defaulting to
207
+ * option 1. The agent calling this is asking the human for judgment; making
208
+ * the call for them in CI / piped / `brainrouter run` would silently commit
209
+ * to a path the user never saw.
210
+ *
211
+ * User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
212
+ * tool wrapper can surface "user declined to commit" as a tool-call error.
213
+ */
214
+ export function askChoice(question, options, opts = {}) {
215
+ // Input-shape validation first — bad shape is a caller bug regardless of
216
+ // TTY availability, and surfacing it as "no TTY" would misdirect the agent
217
+ // toward "decide yourself" when the real fix is "re-emit the call with a
218
+ // valid options array".
219
+ if (!Array.isArray(options) || options.length < 2 || options.length > 4) {
220
+ const count = Array.isArray(options) ? options.length : 'invalid';
221
+ return Promise.reject(new Error(`ask_user_choice requires 2–4 options; received ${count}.`));
222
+ }
223
+ // Reject duplicate labels (case-insensitive). The picker shows labels as
224
+ // the human-readable identifier and returns them as the result, so two
225
+ // options with the same label make the return value ambiguous and downstream
226
+ // branching unreliable. Catch it here, not after the picker is half-drawn.
227
+ // The synthetic "Other" option also collides with a user-supplied "other",
228
+ // so reject that too.
229
+ const seen = new Set();
230
+ for (const o of options) {
231
+ const key = (o?.label ?? '').toLowerCase();
232
+ if (key === OTHER_LABEL.toLowerCase()) {
233
+ return Promise.reject(new Error(`ask_user_choice cannot use "${o.label}" as a label — "${OTHER_LABEL}" is reserved for the always-on free-text fallback.`));
234
+ }
235
+ if (seen.has(key)) {
236
+ return Promise.reject(new Error(`ask_user_choice options must have unique labels; "${o.label}" appears more than once (case-insensitive).`));
237
+ }
238
+ seen.add(key);
239
+ }
240
+ if (!activeReadline || !process.stdin.isTTY) {
241
+ return Promise.reject(new NoTTYError('ask_user_choice requires an interactive TTY (no readline interface is active or stdin is not a TTY). ' +
242
+ 'Fall back to deciding yourself based on the available context, and state which option you picked and why in your reply.'));
243
+ }
244
+ return runPicker(question, options, opts);
245
+ }
246
+ function runPicker(question, options, opts) {
247
+ return new Promise((resolve, reject) => {
248
+ const rl = activeReadline;
249
+ const stdout = process.stdout;
250
+ let state = initPickerState(options, !!opts.multiSelect);
251
+ let renderedLines = 0;
252
+ // Pause the parent rl so its `line` handler doesn't fire on our ENTER
253
+ // press. We restore on cleanup.
254
+ rl.pause();
255
+ // readline.createInterface already calls emitKeypressEvents and sets raw
256
+ // mode for a TTY input; this is belt-and-suspenders for cases where the
257
+ // parent code disabled raw mode somewhere along the way.
258
+ readline.emitKeypressEvents(process.stdin);
259
+ try {
260
+ process.stdin.setRawMode?.(true);
261
+ }
262
+ catch { /* not a real TTY */ }
263
+ process.stdin.resume();
264
+ // Hide cursor while the picker is on screen — keeps the rendering tight.
265
+ stdout.write('\x1b[?25l');
266
+ pickerActive = true;
267
+ const clear = () => {
268
+ if (renderedLines > 0) {
269
+ // Move cursor up `renderedLines` then clear to end of screen.
270
+ stdout.write(`\x1b[${renderedLines}A\r\x1b[J`);
271
+ }
272
+ };
273
+ const render = () => {
274
+ clear();
275
+ const text = renderPicker(state, question, opts.header);
276
+ stdout.write(text + '\n');
277
+ renderedLines = text.split('\n').length;
278
+ };
279
+ const cleanup = () => {
280
+ process.stdin.removeListener('keypress', onKeypress);
281
+ // Restore cursor visibility. Leave raw mode TRUE — the REPL expects it
282
+ // on (Backspace + arrow keys + readline's editing all rely on raw mode)
283
+ // and a previous version that restored a captured `wasRaw` flipped raw
284
+ // mode back to false in terminals where readline's auto-init never
285
+ // fully engaged, which manifested as Backspace echoing `^?` after the
286
+ // picker exited. Picker is the one component that's GUARANTEED to know
287
+ // raw mode is needed, so it's the right place to assert the invariant.
288
+ stdout.write('\x1b[?25h');
289
+ try {
290
+ process.stdin.setRawMode?.(true);
291
+ }
292
+ catch { /* noop */ }
293
+ pickerActive = false;
294
+ // Don't auto-resume the parent rl — runAgentTurn paused it intentionally
295
+ // and will resume on its own schedule.
296
+ };
297
+ const onKeypress = (str, key) => {
298
+ const named = key?.name;
299
+ const isPrintable = typeof str === 'string'
300
+ && str.length === 1
301
+ && !key?.ctrl
302
+ && named !== 'return'
303
+ && named !== 'escape'
304
+ && named !== 'backspace'
305
+ && named !== 'tab';
306
+ const pk = {
307
+ name: named,
308
+ ctrl: !!key?.ctrl,
309
+ sequence: key?.sequence,
310
+ char: isPrintable ? str : undefined,
311
+ };
312
+ const nextState = reducePicker(state, pk);
313
+ if (nextState === state)
314
+ return;
315
+ state = nextState;
316
+ render();
317
+ if (state.done) {
318
+ cleanup();
319
+ if (state.cancelled) {
320
+ reject(new CancelledChoiceError());
321
+ }
322
+ else {
323
+ resolve(state.result);
324
+ }
325
+ }
326
+ };
327
+ process.stdin.on('keypress', onKeypress);
328
+ render();
329
+ });
330
+ }
44
331
  /**
45
332
  * Print a line of output while the prompt is showing, then redraw the prompt
46
333
  * with whatever the user was mid-typing. Used by callbacks that fire while the