@kinqs/brainrouter-cli 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/bin/cli.cjs +71 -0
  8. package/dist/agent/agent.d.ts +224 -3
  9. package/dist/agent/agent.js +561 -55
  10. package/dist/cli/banner.d.ts +80 -0
  11. package/dist/cli/banner.js +232 -0
  12. package/dist/cli/cliPrompt.d.ts +106 -0
  13. package/dist/cli/cliPrompt.js +314 -0
  14. package/dist/cli/commands/_context.d.ts +3 -1
  15. package/dist/cli/commands/_helpers.d.ts +1 -1
  16. package/dist/cli/commands/_helpers.js +6 -6
  17. package/dist/cli/commands/config.d.ts +46 -0
  18. package/dist/cli/commands/config.js +1042 -0
  19. package/dist/cli/commands/guard.js +75 -10
  20. package/dist/cli/commands/init.d.ts +20 -0
  21. package/dist/cli/commands/init.js +64 -0
  22. package/dist/cli/commands/login.d.ts +13 -0
  23. package/dist/cli/commands/login.js +179 -0
  24. package/dist/cli/commands/mcp.d.ts +19 -0
  25. package/dist/cli/commands/mcp.js +286 -0
  26. package/dist/cli/commands/memory.js +2 -2
  27. package/dist/cli/commands/obs.js +22 -22
  28. package/dist/cli/commands/orchestration.js +18 -0
  29. package/dist/cli/commands/session.js +13 -5
  30. package/dist/cli/commands/ui.js +202 -91
  31. package/dist/cli/commands/workflow.d.ts +20 -0
  32. package/dist/cli/commands/workflow.js +368 -51
  33. package/dist/cli/ink/ChatApp.d.ts +206 -0
  34. package/dist/cli/ink/ChatApp.js +493 -0
  35. package/dist/cli/ink/Frame.d.ts +26 -0
  36. package/dist/cli/ink/Frame.js +5 -0
  37. package/dist/cli/ink/Picker.d.ts +65 -0
  38. package/dist/cli/ink/Picker.js +133 -0
  39. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  40. package/dist/cli/ink/SlashPalette.js +136 -0
  41. package/dist/cli/ink/TextField.d.ts +34 -0
  42. package/dist/cli/ink/TextField.js +47 -0
  43. package/dist/cli/ink/WizardApp.d.ts +7 -0
  44. package/dist/cli/ink/WizardApp.js +422 -0
  45. package/dist/cli/ink/ambientChat.d.ts +34 -0
  46. package/dist/cli/ink/ambientChat.js +7 -0
  47. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  48. package/dist/cli/ink/consoleCapture.js +33 -0
  49. package/dist/cli/ink/markdownRender.d.ts +41 -0
  50. package/dist/cli/ink/markdownRender.js +278 -0
  51. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  52. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  53. package/dist/cli/ink/runChat.d.ts +34 -0
  54. package/dist/cli/ink/runChat.js +571 -0
  55. package/dist/cli/ink/runPicker.d.ts +31 -0
  56. package/dist/cli/ink/runPicker.js +139 -0
  57. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  58. package/dist/cli/ink/runSlashPalette.js +33 -0
  59. package/dist/cli/ink/runWizard.d.ts +22 -0
  60. package/dist/cli/ink/runWizard.js +133 -0
  61. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  62. package/dist/cli/ink/stdinHandoff.js +78 -0
  63. package/dist/cli/ink/toolFormat.d.ts +73 -0
  64. package/dist/cli/ink/toolFormat.js +180 -0
  65. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  66. package/dist/cli/ink/useTerminalSize.js +26 -0
  67. package/dist/cli/repl.d.ts +25 -3
  68. package/dist/cli/repl.js +64 -646
  69. package/dist/cli/slashSuggest.d.ts +32 -0
  70. package/dist/cli/slashSuggest.js +146 -0
  71. package/dist/cli/spinner.d.ts +34 -0
  72. package/dist/cli/spinner.js +36 -0
  73. package/dist/cli/statusline.d.ts +67 -0
  74. package/dist/cli/statusline.js +204 -0
  75. package/dist/cli/theme.d.ts +79 -0
  76. package/dist/cli/theme.js +106 -0
  77. package/dist/cli/whereView.d.ts +81 -0
  78. package/dist/cli/whereView.js +245 -0
  79. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  80. package/dist/cli/wizard/modelsApi.js +166 -0
  81. package/dist/cli/wizard/picker.d.ts +202 -0
  82. package/dist/cli/wizard/picker.js +547 -0
  83. package/dist/cli/wizard/providers.d.ts +86 -0
  84. package/dist/cli/wizard/providers.js +190 -0
  85. package/dist/cli/wizard/runner.d.ts +13 -0
  86. package/dist/cli/wizard/runner.js +488 -0
  87. package/dist/cli/wizard/types.d.ts +122 -0
  88. package/dist/cli/wizard/types.js +109 -0
  89. package/dist/config/config.d.ts +52 -0
  90. package/dist/config/config.js +89 -75
  91. package/dist/index.js +215 -206
  92. package/dist/memory/briefing.d.ts +11 -1
  93. package/dist/memory/briefing.js +69 -1
  94. package/dist/memory/consolidation.d.ts +1 -1
  95. package/dist/orchestration/agentRegistry.d.ts +36 -0
  96. package/dist/orchestration/agentRegistry.js +64 -0
  97. package/dist/orchestration/orchestrator.d.ts +7 -0
  98. package/dist/orchestration/orchestrator.js +2 -0
  99. package/dist/orchestration/tools.d.ts +10 -1
  100. package/dist/orchestration/tools.js +48 -4
  101. package/dist/prompt/breadthHint.d.ts +5 -0
  102. package/dist/prompt/breadthHint.js +44 -0
  103. package/dist/prompt/skillCatalog.d.ts +11 -0
  104. package/dist/prompt/skillCatalog.js +134 -0
  105. package/dist/prompt/skillRunner.d.ts +2 -2
  106. package/dist/prompt/skillRunner.js +2 -31
  107. package/dist/prompt/systemPrompt.d.ts +34 -0
  108. package/dist/prompt/systemPrompt.js +128 -108
  109. package/dist/runtime/dangerousCommand.d.ts +53 -0
  110. package/dist/runtime/dangerousCommand.js +105 -0
  111. package/dist/runtime/mcpClient.d.ts +38 -1
  112. package/dist/runtime/mcpClient.js +104 -13
  113. package/dist/runtime/mcpPool.d.ts +162 -0
  114. package/dist/runtime/mcpPool.js +423 -0
  115. package/dist/runtime/mcpUtils.d.ts +3 -1
  116. package/dist/state/goalStore.d.ts +98 -17
  117. package/dist/state/goalStore.js +132 -42
  118. package/dist/state/preferencesStore.d.ts +67 -3
  119. package/dist/state/preferencesStore.js +84 -1
  120. package/dist/state/workflowArtifacts.d.ts +63 -2
  121. package/dist/state/workflowArtifacts.js +120 -8
  122. package/dist/tests/_helpers.d.ts +31 -0
  123. package/dist/tests/_helpers.js +91 -0
  124. package/package.json +12 -5
  125. package/.env.example +0 -109
@@ -0,0 +1,139 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Picker } from './Picker.js';
3
+ import { TextField } from './TextField.js';
4
+ import { NoTTYError } from '../cliPrompt.js';
5
+ import { resetStdinForReadline, snapshotStdinListeners } from './stdinHandoff.js';
6
+ import { getAmbientChat } from './ambientChat.js';
7
+ import { renderWithResizeClear } from './renderWithResizeClear.js';
8
+ /**
9
+ * One-shot Ink mount helpers. Used by `/config`, `/login`, and any
10
+ * slash command that needs a single picker / text prompt without
11
+ * managing Ink lifecycle by hand.
12
+ *
13
+ * Two paths:
14
+ *
15
+ * 1. **Overlay path** — when the Ink chat REPL is running, the
16
+ * ambient ChatController is set (see ambientChat.ts +
17
+ * runChat.tsx). We render <Picker> inside the chat's overlay
18
+ * slot, NOT as a second Ink mount — that would race the chat
19
+ * for stdin + terminal state and break the picker's interaction.
20
+ *
21
+ * 2. **Standalone path** — for the legacy readline REPL, mount a
22
+ * fresh Ink instance. Unmount via `instance.unmount()` from
23
+ * outside the React tree (no `useApp().exit()`) so the wrapper
24
+ * doesn't risk exiting the wrong Ink instance if something goes
25
+ * sideways. The Picker/TextField components are exit-agnostic;
26
+ * they call onResolve and trust the caller to handle unmount.
27
+ *
28
+ * Stdin handoff for the standalone path: snapshot + detach existing
29
+ * listeners before mount, restore them and reset stdin state after
30
+ * Ink unmounts (matches the pattern in runWizard.tsx / runSlashPalette).
31
+ */
32
+ // --- Picker -----------------------------------------------------------
33
+ export async function runPicker(opts) {
34
+ // OVERLAY PATH — running inside the Ink chat REPL.
35
+ const ambient = getAmbientChat();
36
+ if (ambient) {
37
+ return new Promise((resolve) => {
38
+ let resolved = false;
39
+ const overlayNode = (_jsx(Picker, { ...opts, onResolve: (r) => {
40
+ if (resolved)
41
+ return;
42
+ resolved = true;
43
+ ambient.clearOverlay();
44
+ resolve(r);
45
+ } }));
46
+ ambient.showOverlay(overlayNode).catch(() => {
47
+ if (!resolved) {
48
+ resolved = true;
49
+ resolve({ kind: 'cancelled' });
50
+ }
51
+ });
52
+ });
53
+ }
54
+ // STANDALONE PATH — readline REPL fallback. Mounts a fresh Ink.
55
+ if (!process.stdin.isTTY) {
56
+ throw new NoTTYError('runPicker requires an interactive TTY.');
57
+ }
58
+ const snap = snapshotStdinListeners(['keypress', 'data']);
59
+ return new Promise((resolve) => {
60
+ let captured;
61
+ // We need `instance` inside the onResolve closure but it doesn't
62
+ // exist yet when we build the JSX. Use a forward-declared variable
63
+ // that the closure captures by reference and we assign before the
64
+ // user can possibly interact with the picker. instance.unmount()
65
+ // is cleaner than useApp().exit() — works even if the Picker
66
+ // somehow ends up rendered in the wrong Ink tree.
67
+ let instance;
68
+ const node = (_jsx(Picker, { ...opts, onResolve: (r) => {
69
+ if (!captured)
70
+ captured = r;
71
+ if (instance)
72
+ instance.unmount();
73
+ } }));
74
+ const mounted = renderWithResizeClear(node, { exitOnCtrlC: true });
75
+ instance = mounted.instance;
76
+ instance.waitUntilExit().then(() => {
77
+ mounted.cleanupResizeClear();
78
+ snap.restore();
79
+ resetStdinForReadline();
80
+ resolve(captured ?? { kind: 'cancelled' });
81
+ }).catch(() => {
82
+ mounted.cleanupResizeClear();
83
+ snap.restore();
84
+ resetStdinForReadline();
85
+ resolve(captured ?? { kind: 'cancelled' });
86
+ });
87
+ });
88
+ }
89
+ // --- TextField --------------------------------------------------------
90
+ export async function runTextField(opts) {
91
+ // OVERLAY PATH
92
+ const ambient = getAmbientChat();
93
+ if (ambient) {
94
+ return new Promise((resolve) => {
95
+ let resolved = false;
96
+ const overlayNode = (_jsx(TextField, { ...opts, onResolve: (r) => {
97
+ if (resolved)
98
+ return;
99
+ resolved = true;
100
+ ambient.clearOverlay();
101
+ resolve(r);
102
+ } }));
103
+ ambient.showOverlay(overlayNode).catch(() => {
104
+ if (!resolved) {
105
+ resolved = true;
106
+ resolve({ kind: 'cancelled' });
107
+ }
108
+ });
109
+ });
110
+ }
111
+ // STANDALONE PATH
112
+ if (!process.stdin.isTTY) {
113
+ throw new NoTTYError('runTextField requires an interactive TTY.');
114
+ }
115
+ const snap = snapshotStdinListeners(['keypress', 'data']);
116
+ return new Promise((resolve) => {
117
+ let captured;
118
+ let instance;
119
+ const node = (_jsx(TextField, { ...opts, onResolve: (r) => {
120
+ if (!captured)
121
+ captured = r;
122
+ if (instance)
123
+ instance.unmount();
124
+ } }));
125
+ const mounted = renderWithResizeClear(node, { exitOnCtrlC: true });
126
+ instance = mounted.instance;
127
+ instance.waitUntilExit().then(() => {
128
+ mounted.cleanupResizeClear();
129
+ snap.restore();
130
+ resetStdinForReadline();
131
+ resolve(captured ?? { kind: 'cancelled' });
132
+ }).catch(() => {
133
+ mounted.cleanupResizeClear();
134
+ snap.restore();
135
+ resetStdinForReadline();
136
+ resolve(captured ?? { kind: 'cancelled' });
137
+ });
138
+ });
139
+ }
@@ -0,0 +1,23 @@
1
+ import { type SlashCommandDef, type SlashPaletteResult } from './SlashPalette.js';
2
+ /**
3
+ * Mount the slash palette Ink app and await the user's selection.
4
+ *
5
+ * Returns:
6
+ * { kind: 'submit', text } — full command line to feed to the REPL
7
+ * { kind: 'cancelled' } — user pressed Esc / backspaced past `/`
8
+ *
9
+ * Stdin handoff (matters):
10
+ * - Before mount: snapshot + remove ALL `keypress` / `data`
11
+ * listeners on process.stdin. Otherwise readline (which is paused
12
+ * but still listening) AND Ink both consume bytes — arrow keys
13
+ * get split between them and the palette appears frozen.
14
+ * - After Ink unmount: restore the snapshotted listeners + run
15
+ * `resetStdinForReadline` so the surrounding REPL doesn't exit
16
+ * due to Ink's `stdin.unref()` cleanup.
17
+ */
18
+ export interface RunSlashPaletteOptions {
19
+ initialQuery: string;
20
+ commands: SlashCommandDef[];
21
+ accentColor?: string;
22
+ }
23
+ export declare function runSlashPalette(opts: RunSlashPaletteOptions): Promise<SlashPaletteResult>;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { SlashPalette } from './SlashPalette.js';
3
+ import { resetStdinForReadline, snapshotStdinListeners } from './stdinHandoff.js';
4
+ import { renderWithResizeClear } from './renderWithResizeClear.js';
5
+ export async function runSlashPalette(opts) {
6
+ if (!process.stdin.isTTY) {
7
+ return { kind: 'cancelled' };
8
+ }
9
+ // Snapshot + detach existing stdin listeners so Ink owns stdin
10
+ // alone for its mount lifetime.
11
+ const snap = snapshotStdinListeners(['keypress', 'data']);
12
+ return new Promise((resolve) => {
13
+ let captured;
14
+ const { instance, cleanupResizeClear } = renderWithResizeClear(_jsx(SlashPalette, { initialQuery: opts.initialQuery, commands: opts.commands, accentColor: opts.accentColor, onResolve: (r) => {
15
+ // Capture; the actual `resolve()` runs after Ink's unmount
16
+ // finishes via `waitUntilExit().then`.
17
+ if (captured)
18
+ return;
19
+ captured = r;
20
+ } }), { exitOnCtrlC: false });
21
+ instance.waitUntilExit().then(() => {
22
+ cleanupResizeClear();
23
+ snap.restore();
24
+ resetStdinForReadline();
25
+ resolve(captured ?? { kind: 'cancelled' });
26
+ }).catch(() => {
27
+ cleanupResizeClear();
28
+ snap.restore();
29
+ resetStdinForReadline();
30
+ resolve(captured ?? { kind: 'cancelled' });
31
+ });
32
+ });
33
+ }
@@ -0,0 +1,22 @@
1
+ import type { WizardState } from '../wizard/types.js';
2
+ import { type Config } from '../../config/config.js';
3
+ export declare function isOnboarded(): boolean;
4
+ export declare function markOnboarded(): void;
5
+ export interface WizardRunOptions {
6
+ workspaceRoot: string;
7
+ }
8
+ export interface WizardRunResult {
9
+ state: WizardState;
10
+ config?: Config;
11
+ }
12
+ /**
13
+ * Mount the Ink wizard and wait for it to finish. Returns the final
14
+ * `WizardState` (which includes `committed` / `aborted` flags) and,
15
+ * when committed, the freshly-saved Config.
16
+ *
17
+ * Why Ink instead of the previous raw-stdout runner? Ink owns the
18
+ * render loop and diffs the cell grid between frames, so we don't
19
+ * track cursor positions ourselves. Every redraw bug the previous
20
+ * approach had (creep, stacking, off-by-one) is eliminated by design.
21
+ */
22
+ export declare function runWizard(opts: WizardRunOptions): Promise<WizardRunResult>;
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { WizardApp } from './WizardApp.js';
6
+ import { writePreferences } from '../../state/preferencesStore.js';
7
+ import { loadOrInitConfig, saveConfig } from '../../config/config.js';
8
+ import { initAgentMd } from '../../prompt/initAgentMd.js';
9
+ import { NoTTYError } from '../cliPrompt.js';
10
+ import { resetStdinForReadline } from './stdinHandoff.js';
11
+ import { renderWithResizeClear } from './renderWithResizeClear.js';
12
+ const ONBOARDED_MARKER = path.join(os.homedir(), '.config', 'brainrouter', '.onboarded');
13
+ export function isOnboarded() {
14
+ try {
15
+ return fs.existsSync(ONBOARDED_MARKER);
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export function markOnboarded() {
22
+ try {
23
+ fs.mkdirSync(path.dirname(ONBOARDED_MARKER), { recursive: true });
24
+ fs.writeFileSync(ONBOARDED_MARKER, '', 'utf8');
25
+ }
26
+ catch {
27
+ /* non-fatal */
28
+ }
29
+ }
30
+ /**
31
+ * Mount the Ink wizard and wait for it to finish. Returns the final
32
+ * `WizardState` (which includes `committed` / `aborted` flags) and,
33
+ * when committed, the freshly-saved Config.
34
+ *
35
+ * Why Ink instead of the previous raw-stdout runner? Ink owns the
36
+ * render loop and diffs the cell grid between frames, so we don't
37
+ * track cursor positions ourselves. Every redraw bug the previous
38
+ * approach had (creep, stacking, off-by-one) is eliminated by design.
39
+ */
40
+ export async function runWizard(opts) {
41
+ if (!process.stdin.isTTY) {
42
+ throw new NoTTYError('BrainRouter has no config and stdin is not a TTY — run `brainrouter` in an interactive terminal at least once to complete the setup wizard.');
43
+ }
44
+ const finalState = await new Promise((resolve) => {
45
+ let captured;
46
+ const { instance, cleanupResizeClear } = renderWithResizeClear(_jsx(WizardApp, { workspaceRoot: opts.workspaceRoot, onFinish: (s) => {
47
+ // Capture but DON'T resolve yet — we want to wait for Ink's
48
+ // own unmount to complete (next tick) before handing stdin
49
+ // back to readline. Resolving prematurely would let our
50
+ // caller try to read stdin while Ink is still tearing down,
51
+ // which breaks the next readline.createInterface.
52
+ captured = s;
53
+ } }), {
54
+ // Don't put Ink's output into the alt-screen buffer — we want
55
+ // the final frame (Done summary) to stay in scrollback after
56
+ // unmount. Ink's default is `exitOnCtrlC: true` which is fine
57
+ // for our Ctrl+C abort path.
58
+ exitOnCtrlC: true,
59
+ });
60
+ instance.waitUntilExit().then(() => {
61
+ cleanupResizeClear();
62
+ // Hand stdin back to the caller in a state where readline (or
63
+ // anything else) can take it. Ink leaves stdin unref'd, in raw
64
+ // mode false, with its 'readable' listener removed; without
65
+ // this reset the post-wizard REPL would print its banner and
66
+ // then immediately exit because nothing kept the event loop
67
+ // alive. See cli/ink/stdinHandoff.ts for the full rationale.
68
+ resetStdinForReadline();
69
+ resolve(captured ?? { aborted: true, committed: false, currentStep: 'welcome', draft: {}, warnings: [] });
70
+ }).catch(() => {
71
+ cleanupResizeClear();
72
+ resetStdinForReadline();
73
+ resolve(captured ?? { aborted: true, committed: false, currentStep: 'welcome', draft: {}, warnings: [] });
74
+ });
75
+ });
76
+ let savedConfig;
77
+ if (finalState.committed) {
78
+ savedConfig = commitWizardDraft(finalState.draft, opts.workspaceRoot);
79
+ markOnboarded();
80
+ }
81
+ return { state: finalState, config: savedConfig };
82
+ }
83
+ function commitWizardDraft(draft, workspaceRoot) {
84
+ const config = loadOrInitConfig();
85
+ if (draft.provider) {
86
+ config.llm = {
87
+ provider: 'openai',
88
+ apiKey: draft.apiKey ?? '',
89
+ model: draft.model ?? draft.provider.defaultModel,
90
+ endpoint: draft.customEndpoint ?? draft.provider.endpoint,
91
+ };
92
+ }
93
+ if (draft.mcp && draft.mcp.kind !== 'skip') {
94
+ const profileName = draft.mcp.kind === 'remote-http' ? 'remote' : draft.mcp.kind === 'local-http' ? 'local-http' : 'local-stdio';
95
+ const serverConfig = mcpPickToServerConfig(draft.mcp);
96
+ if (serverConfig) {
97
+ config.servers[profileName] = serverConfig;
98
+ config.activeServer = profileName;
99
+ }
100
+ }
101
+ else if (draft.mcp?.kind === 'skip') {
102
+ // Skip means skip — clear any previously-active profile so the CLI doesn't
103
+ // silently re-spawn an MCP child from a stale config. The user can re-add
104
+ // a profile via `/login` later.
105
+ config.activeServer = '';
106
+ }
107
+ saveConfig(config);
108
+ if (draft.theme) {
109
+ try {
110
+ writePreferences(workspaceRoot, { theme: draft.theme });
111
+ }
112
+ catch { /* non-fatal */ }
113
+ }
114
+ if (draft.writeAgentMd) {
115
+ try {
116
+ initAgentMd(workspaceRoot);
117
+ }
118
+ catch { /* non-fatal */ }
119
+ }
120
+ return config;
121
+ }
122
+ function mcpPickToServerConfig(pick) {
123
+ if (pick.kind === 'local-stdio') {
124
+ return { type: 'stdio', command: 'brainrouter-mcp', args: [], identity: 'brainrouter' };
125
+ }
126
+ if (pick.kind === 'local-http') {
127
+ return { type: 'http', url: 'http://localhost:3747/mcp', apiKey: pick.apiKey, identity: 'brainrouter' };
128
+ }
129
+ if (pick.kind === 'remote-http') {
130
+ return { type: 'http', url: pick.url, apiKey: pick.apiKey, identity: 'brainrouter' };
131
+ }
132
+ return undefined;
133
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Restore stdin to a state where readline (or any other consumer) can
3
+ * own it, AFTER an Ink app has unmounted.
4
+ *
5
+ * Why this is necessary:
6
+ * Ink's `App.js` calls `stdin.unref()` during its `disableRawMode`
7
+ * cleanup (node_modules/ink/build/components/App.js:137). `unref()`
8
+ * removes the stdin handle from the event-loop refcount. Once Ink
9
+ * hands stdin back, NOTHING in the event loop keeps Node alive —
10
+ * readline's 'readable' listener does NOT auto-ref the stream.
11
+ * Node sees zero refs, fires `beforeExit`, and exits cleanly with
12
+ * no `close` event.
13
+ *
14
+ * Symptom: the post-wizard REPL printed its banner ("Type /help…")
15
+ * and then the process exited to bash. No `Goodbye!` from
16
+ * readline's close handler — readline never got a chance to run.
17
+ *
18
+ * Fix order (matters):
19
+ * 1. `process.stdin.ref()` — counter Ink's unref so the event loop
20
+ * doesn't drain.
21
+ * 2. `process.stdin.resume()` — re-enable flowing/readable events.
22
+ * Ink may have paused via `stdin.read()` draining.
23
+ * 3. `setRawMode(true)` on TTY — readline expects raw mode for
24
+ * keypress events. Ink restored it to false on unmount.
25
+ * 4. `readline.emitKeypressEvents(process.stdin)` — re-arm the
26
+ * keypress decoder. Ink's `clearInputState` removed the
27
+ * 'readable' listener (App.js:126); we want our handler back.
28
+ *
29
+ * Call this on the next `setImmediate` after `instance.unmount()` /
30
+ * `waitUntilExit()` resolves — Ink writes its final unmount barrier
31
+ * on the next tick (Ink.js:549-554).
32
+ */
33
+ export declare function resetStdinForReadline(): void;
34
+ /**
35
+ * Snapshot every listener attached to a stdin event so we can remove
36
+ * them while Ink owns stdin and re-attach them on Ink unmount.
37
+ *
38
+ * Without this, readline's keypress + 'data' listeners stay subscribed
39
+ * while Ink is mounted — both consumers fight for the same bytes,
40
+ * arrow keys go missing, the picker doesn't see Enter, etc. (See
41
+ * `cli-prompt.test.ts` notes on why we use `rl.pause()` AND remove
42
+ * stdin listeners during ask_user_choice.)
43
+ *
44
+ * Usage:
45
+ * const snap = snapshotStdinListeners(['keypress', 'data']);
46
+ * try { await runInk(...); } finally { snap.restore(); resetStdinForReadline(); }
47
+ */
48
+ export interface StdinListenerSnapshot {
49
+ restore(): void;
50
+ }
51
+ export declare function snapshotStdinListeners(events?: readonly string[]): StdinListenerSnapshot;
@@ -0,0 +1,78 @@
1
+ import readline from 'node:readline';
2
+ /**
3
+ * Restore stdin to a state where readline (or any other consumer) can
4
+ * own it, AFTER an Ink app has unmounted.
5
+ *
6
+ * Why this is necessary:
7
+ * Ink's `App.js` calls `stdin.unref()` during its `disableRawMode`
8
+ * cleanup (node_modules/ink/build/components/App.js:137). `unref()`
9
+ * removes the stdin handle from the event-loop refcount. Once Ink
10
+ * hands stdin back, NOTHING in the event loop keeps Node alive —
11
+ * readline's 'readable' listener does NOT auto-ref the stream.
12
+ * Node sees zero refs, fires `beforeExit`, and exits cleanly with
13
+ * no `close` event.
14
+ *
15
+ * Symptom: the post-wizard REPL printed its banner ("Type /help…")
16
+ * and then the process exited to bash. No `Goodbye!` from
17
+ * readline's close handler — readline never got a chance to run.
18
+ *
19
+ * Fix order (matters):
20
+ * 1. `process.stdin.ref()` — counter Ink's unref so the event loop
21
+ * doesn't drain.
22
+ * 2. `process.stdin.resume()` — re-enable flowing/readable events.
23
+ * Ink may have paused via `stdin.read()` draining.
24
+ * 3. `setRawMode(true)` on TTY — readline expects raw mode for
25
+ * keypress events. Ink restored it to false on unmount.
26
+ * 4. `readline.emitKeypressEvents(process.stdin)` — re-arm the
27
+ * keypress decoder. Ink's `clearInputState` removed the
28
+ * 'readable' listener (App.js:126); we want our handler back.
29
+ *
30
+ * Call this on the next `setImmediate` after `instance.unmount()` /
31
+ * `waitUntilExit()` resolves — Ink writes its final unmount barrier
32
+ * on the next tick (Ink.js:549-554).
33
+ */
34
+ export function resetStdinForReadline() {
35
+ const stdin = process.stdin;
36
+ try {
37
+ stdin.ref?.();
38
+ }
39
+ catch { /* node version w/o ref */ }
40
+ try {
41
+ stdin.resume();
42
+ }
43
+ catch { /* already resumed */ }
44
+ if (stdin.isTTY) {
45
+ try {
46
+ stdin.setRawMode?.(true);
47
+ }
48
+ catch { /* not a real TTY */ }
49
+ }
50
+ try {
51
+ readline.emitKeypressEvents(stdin);
52
+ }
53
+ catch { /* already wired */ }
54
+ }
55
+ export function snapshotStdinListeners(events = ['keypress', 'data']) {
56
+ const stdin = process.stdin;
57
+ const captured = [];
58
+ for (const event of events) {
59
+ const listeners = (stdin.listeners?.(event) ?? []);
60
+ for (const listener of listeners) {
61
+ captured.push({ event, listener });
62
+ try {
63
+ stdin.removeListener(event, listener);
64
+ }
65
+ catch { /* ignore */ }
66
+ }
67
+ }
68
+ return {
69
+ restore() {
70
+ for (const { event, listener } of captured) {
71
+ try {
72
+ stdin.on(event, listener);
73
+ }
74
+ catch { /* ignore */ }
75
+ }
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Tool call display formatters — claude-code-style semantic rendering.
3
+ *
4
+ * The raw `tool_name({"path": "...", ...})` JSON dump that fell out of
5
+ * `agent.runTurn`'s onToolStart callback is hostile to quick scanning
6
+ * during a long turn. claude-code's transcript format is
7
+ *
8
+ * ⏺ Read(src/auth/login.ts)
9
+ * ⏺ Bash(npm test)
10
+ * ⏺ Grep("authenticate")
11
+ *
12
+ * — one-line, identity-revealing, no JSON. These helpers do the same
13
+ * mapping for our built-in LOCAL_TOOLS (cli/../agent/agent.ts) + MCP
14
+ * tool names (which carry an `mcp__<server>__` namespace prefix that
15
+ * the user doesn't care about).
16
+ *
17
+ * Reference for the convention: claude-code transcripts (see
18
+ * openSrc/claude-code/CHANGELOG.md mentions throughout; the format is
19
+ * not formally documented but used in every claude-code session).
20
+ */
21
+ /**
22
+ * Format a tool call as a one-line `Function(args)` summary.
23
+ *
24
+ * Examples:
25
+ * formatToolCall('read_file', { path: 'src/foo.ts' })
26
+ * → "Read(src/foo.ts)"
27
+ * formatToolCall('run_command', { command: 'npm test' })
28
+ * → "Bash(npm test)"
29
+ * formatToolCall('grep_search', { query: 'authenticate', path: '.' })
30
+ * → 'Grep("authenticate")'
31
+ * formatToolCall('mcp__brainrouter__memory_search', { q: 'auth' })
32
+ * → 'MemorySearch("auth")'
33
+ * formatToolCall('spawn_agent', { role: 'researcher', prompt: '...' })
34
+ * → 'Spawn(researcher, "...")'
35
+ */
36
+ export declare function formatToolCall(name: string, args: Record<string, any> | undefined): string;
37
+ /**
38
+ * Strip the `mcp__<server>__` or `mcp_<server>_` namespace prefix from MCP tool
39
+ * names. Server ids may contain underscores (e.g. `my_server`), so the
40
+ * double-underscore form uses a lazy match. Both prefix conventions are in use
41
+ * across the multi-MCP codepaths until naming is unified.
42
+ * `mcp__brainrouter__memory_search` → `memory_search`
43
+ * `mcp__my_server__memory_search` → `memory_search`
44
+ * `mcp_brainrouter_memory_search` → `memory_search`
45
+ */
46
+ export declare function stripMcpPrefix(name: string): string;
47
+ /**
48
+ * Convert snake_case to PascalCase for readable display names.
49
+ * `memory_search` → `MemorySearch`.
50
+ */
51
+ export declare function snakeToPascal(name: string): string;
52
+ /**
53
+ * Quote + truncate a free-form string argument to fit on one line.
54
+ * Returns `""` for missing input — the empty-quote pair signals "empty
55
+ * arg" rather than "no arg" (a no-arg call would not call this at all).
56
+ */
57
+ export declare function quoteShort(s: unknown, max: number): string;
58
+ /** Collapse whitespace + truncate a string to one line bounded by `max`. */
59
+ export declare function truncateOneLine(s: unknown, max: number): string;
60
+ /**
61
+ * Classify a tool-result preview line as part of a unified diff so the
62
+ * renderer can color it (red for removals, green for additions, gray
63
+ * for context). Detects file headers (`+++`, `---`), hunk headers
64
+ * (`@@`), and the per-line +/- gutter sign. Returns undefined when the
65
+ * line doesn't look like diff content so callers can leave it alone.
66
+ */
67
+ export declare function classifyDiffLine(line: string): 'header' | 'hunk' | 'add' | 'del' | 'context' | undefined;
68
+ /**
69
+ * True when a multi-line preview looks like a unified diff (at least one
70
+ * `@@` hunk header OR multiple +/- gutter lines). Used by the tool-result
71
+ * renderer to decide whether to apply diff coloring to the whole block.
72
+ */
73
+ export declare function looksLikeDiff(preview: string): boolean;