@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
package/dist/index.js CHANGED
@@ -1,171 +1,106 @@
1
1
  #!/usr/bin/env node
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import url from 'node:url';
5
- import { Command } from 'commander';
6
- import inquirer from 'inquirer';
7
- import chalk from 'chalk';
8
- import { loadConfig, saveConfig } from './config/config.js';
9
- import { McpClientWrapper } from './runtime/mcpClient.js';
10
- import { Agent } from './agent/agent.js';
11
- import { startREPL } from './cli/repl.js';
12
- import { applyWorkspaceRoot, findWorkspaceRoot } from './config/workspace.js';
13
2
  /**
14
- * Load `.env` files into the CLI's `process.env`.
15
- *
16
- * The CLI and the MCP server have separate concerns and now ship separate
17
- * config files:
18
- *
19
- * - `brainrouter-cli/.env` — CLI-only knobs (chat LLM, tool loop,
20
- * sandbox, web search, trace log).
21
- * - `brainrouter/.env` — MCP-only knobs (extraction LLM, embeddings,
22
- * reranker, memory engine, server auth).
3
+ * Filter out Node.js platform warnings that the user has no way to act on
4
+ * and that scroll real CLI banner content off-screen on short terminals.
23
5
  *
24
- * Loading order:
25
- * 1) `brainrouter-cli/.env` (PRIMARY for CLI process).
26
- * 2) `brainrouter/.env` (FALLBACK only for the LLM credentials, so
27
- * a user who set up only the MCP config still
28
- * gets a working CLI agent and vice versa).
6
+ * - `ExperimentalWarning: SQLite is an experimental feature` — emitted by
7
+ * `node:sqlite`. The CLI itself no longer imports sqlite, but the
8
+ * stdio MCP child process does, and its warnings surface on the parent's
9
+ * stderr. Stable in Node 22+ in practice; the warning is correct but
10
+ * uninformative.
11
+ * - `DeprecationWarning: ... dotenv ...` — dotenv@16 prints a teaser for
12
+ * its hosted product on every load on newer Node releases.
29
13
  *
30
- * Shell env (anything already in `process.env`) wins over both — explicit
31
- * env > .env file, as is conventional.
14
+ * BrainRouter's own warnings flow through unchanged. `NODE_NO_WARNINGS=1`
15
+ * would silence those too, so we intercept selectively instead.
32
16
  *
33
- * The MCP child uses `import "dotenv/config"` which resolves relative to
34
- * `process.cwd()`. The CLI sets the spawned child's cwd to the MCP package
35
- * directory (see runtime/mcpClient.ts), so `brainrouter/.env` is loaded by
36
- * the child directly the CLI does NOT need to pre-load it for the MCP's
37
- * sake.
17
+ * Two interception points: (1) remove Node's built-in `warning` listener
18
+ * and add our own filtered one this catches warnings emitted from
19
+ * subprocesses or transitive imports during ESM resolution; (2) replace
20
+ * `process.emitWarning` so future direct callers also get the filter.
21
+ * Both are needed because ESM hoists imports above any code in this file,
22
+ * so an emitWarning override alone misses import-time warnings.
38
23
  */
24
+ function isSuppressibleWarning(message, type) {
25
+ const looksExperimental = type === 'ExperimentalWarning' ||
26
+ /experimental feature|SQLite is an experimental/i.test(message);
27
+ const looksDotenvNoise = /dotenv@\d|dotenvx|dotenv\.org/i.test(message);
28
+ return looksExperimental || looksDotenvNoise;
29
+ }
30
+ // Detach Node's default warning printer and replace with a filtered one.
31
+ // process.listeners returns each Function attached; the default one is a
32
+ // single internal listener that does the stderr printing.
33
+ for (const listener of process.listeners('warning')) {
34
+ process.removeListener('warning', listener);
35
+ }
36
+ process.on('warning', (warning) => {
37
+ if (isSuppressibleWarning(warning?.message ?? '', warning?.name ?? ''))
38
+ return;
39
+ // Mirror Node's default formatting for everything else so users see the
40
+ // familiar "(node:PID) <Name>: <message>" shape.
41
+ process.stderr.write(`(node:${process.pid}) ${warning?.name ?? 'Warning'}: ${warning?.message ?? warning}\n`);
42
+ });
43
+ const originalEmitWarning = process.emitWarning.bind(process);
44
+ process.emitWarning = ((warning, ...rest) => {
45
+ const message = typeof warning === 'string' ? warning : warning?.message ?? '';
46
+ const type = typeof rest[0] === 'string' ? rest[0]
47
+ : (rest[0] && typeof rest[0] === 'object' && 'type' in rest[0]) ? rest[0].type
48
+ : (warning instanceof Error ? warning.name : '');
49
+ if (isSuppressibleWarning(message, type))
50
+ return;
51
+ return originalEmitWarning(warning, ...rest);
52
+ });
39
53
  /**
40
- * Vars the CLI process consumes from a sibling `brainrouter/.env` fallback.
41
- *
42
- * LLM credentials are deliberately EXCLUDED `~/.config/brainrouter/config.json`
43
- * is the canonical source for chat-LLM creds, endpoint, and model (set via
44
- * `brainrouter login` or `brainrouter config`). Pulling them from `.env` in
45
- * parallel created a silent precedence bug: env would shadow `config.json`
46
- * because `loadBrainrouterEnv()` runs at module-load time before
47
- * `loadConfig()`, and downstream callers like `mcpClient.connect()` check
48
- * `mergedEnv.BRAINROUTER_LLM_ENDPOINT` before falling back to `llmConfig`.
49
- *
50
- * The only var we still allow through the fallback is `BRAINROUTER_API_KEY`
51
- * — that's MCP-server auth (not LLM), and stdio mode propagates it from the
52
- * CLI's process.env into the spawned child. If your `config.json` server
53
- * profile already carries the API key in its `env` block, you don't need
54
- * this fallback either, and it can go away in a follow-up cleanup.
54
+ * Crash diagnostics surface ANY exit reason so the user (or we) can
55
+ * see WHY the process died if the REPL ever silently quits. The
56
+ * symptom the user reported was "REPL prints banner, then bash prompt"
57
+ * with no error. If that happens again under any future regression,
58
+ * one of these handlers will catch it and print the cause.
55
59
  *
56
- * Anything outside this set is a pure MCP-server knob (embedding endpoint,
57
- * JWT secret, extraction sweep config, prewarming, graph timeouts, admin
58
- * creds) that just pollutes the CLI's environment with no effect — the MCP
59
- * child loads `brainrouter/.env` directly via its own `dotenv/config`.
60
+ * `BRAINROUTER_DEBUG_EXIT=1` (default off) enables verbose exit tracing
61
+ * including the beforeExit event so we can see whether the event loop
62
+ * drained (= stdin refcount issue) vs explicit process.exit (= bug).
60
63
  */
61
- const CLI_FALLBACK_ALLOWLIST = new Set([
62
- 'BRAINROUTER_API_KEY',
63
- ]);
64
- function loadEnvFile(file, allowlist) {
65
- try {
66
- const raw = fs.readFileSync(file, 'utf8');
67
- let count = 0;
68
- for (const line of raw.split('\n')) {
69
- const trimmed = line.trim();
70
- if (!trimmed || trimmed.startsWith('#'))
71
- continue;
72
- const eq = trimmed.indexOf('=');
73
- if (eq <= 0)
74
- continue;
75
- const key = trimmed.slice(0, eq).trim();
76
- let value = trimmed.slice(eq + 1).trim();
77
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
78
- value = value.slice(1, -1);
79
- }
80
- // Allowlist gate: when loading the MCP fallback file, only adopt vars
81
- // the CLI actually reads. Primary CLI .env loads pass no allowlist and
82
- // accept everything (it's the CLI's own config).
83
- if (allowlist && !allowlist.has(key))
84
- continue;
85
- if (key && !(key in process.env)) {
86
- process.env[key] = value;
87
- count++;
88
- }
89
- }
90
- return count;
91
- }
92
- catch {
93
- return 0;
94
- }
95
- }
96
- function loadBrainrouterEnv() {
97
- const here = path.dirname(url.fileURLToPath(import.meta.url));
98
- let count = 0;
99
- let primary;
100
- let fallback;
101
- // PRIMARY: brainrouter-cli/.env (this package's own config).
102
- // dist/index.js → ../.. = brainrouter-cli/, so .env sits next to package.json.
103
- const cliCandidates = [
104
- path.resolve(here, '..', '..', '.env'), // monorepo: brainrouter-cli/.env
105
- path.resolve(here, '..', '..', '..', 'brainrouter-cli', '.env'), // installed/nested
106
- path.resolve(process.cwd(), 'brainrouter-cli', '.env'), // running from repo root
107
- ];
108
- for (const file of cliCandidates) {
109
- if (fs.existsSync(file)) {
110
- primary = file;
111
- count += loadEnvFile(file);
112
- break;
113
- }
114
- }
115
- // FALLBACK: brainrouter/.env (MCP-side config). Only used to backstop the
116
- // LLM credentials so a partial setup still works. The MCP child loads
117
- // brainrouter/.env on its own anyway via cwd hint, so we don't need to
118
- // import its server-only knobs (embedding endpoint, JWT secret, sweep
119
- // intervals, prewarming) — those just clutter the CLI's process.env. The
120
- // allowlist limits the fallback to vars the CLI actually reads.
121
- //
122
- // Only record the fallback in the result when it actually contributed at
123
- // least one new var. If the primary file already set all the LLM creds,
124
- // mentioning the fallback path in the startup banner is noise — the user
125
- // already has the CLI fully configured locally and doesn't need to know
126
- // a sibling .env was read but ignored.
127
- const mcpCandidates = [
128
- path.resolve(here, '..', '..', '..', 'brainrouter', '.env'),
129
- path.resolve(process.cwd(), 'brainrouter', '.env'),
130
- ];
131
- for (const file of mcpCandidates) {
132
- if (fs.existsSync(file)) {
133
- const added = loadEnvFile(file, CLI_FALLBACK_ALLOWLIST);
134
- if (added > 0) {
135
- fallback = file;
136
- count += added;
137
- }
138
- break;
139
- }
140
- }
141
- return { primary, fallback, count };
142
- }
143
- const envLoadResult = loadBrainrouterEnv();
144
- if (envLoadResult.primary || envLoadResult.fallback) {
145
- // Something contributed at least one var — show what loaded so the user can
146
- // trace where runtime knobs (sandbox, timeouts, trace log, web search) are
147
- // coming from. LLM creds intentionally do NOT flow through this path; they
148
- // live in ~/.config/brainrouter/config.json.
149
- const sources = [];
150
- if (envLoadResult.primary)
151
- sources.push(envLoadResult.primary);
152
- if (envLoadResult.fallback)
153
- sources.push(`${envLoadResult.fallback} (fallback)`);
154
- const tag = envLoadResult.count > 0
155
- ? chalk.gray(` (${envLoadResult.count} new var${envLoadResult.count === 1 ? '' : 's'})`)
156
- : chalk.gray(' (all keys already set in shell)');
157
- console.error(chalk.gray(`env: loaded ${sources.join(', ')}`) + tag);
64
+ process.on('uncaughtException', (err) => {
65
+ process.stderr.write(`\n[brainrouter] Uncaught exception killed the process:\n${err?.stack ?? err}\n`);
66
+ process.exit(1);
67
+ });
68
+ process.on('unhandledRejection', (reason) => {
69
+ process.stderr.write(`\n[brainrouter] Unhandled promise rejection killed the process:\n${reason?.stack ?? reason}\n`);
70
+ process.exit(1);
71
+ });
72
+ if (process.env.BRAINROUTER_DEBUG_EXIT === '1') {
73
+ process.on('beforeExit', (code) => {
74
+ process.stderr.write(`[brainrouter:debug] beforeExit code=${code} (event loop drained — likely Ink stdin.unref leak)\n`);
75
+ });
76
+ process.on('exit', (code) => {
77
+ process.stderr.write(`[brainrouter:debug] exit code=${code}\n`);
78
+ });
158
79
  }
159
- // No banner when nothing loaded — that's the normal case for users who
160
- // configured the CLI via `brainrouter login` / `brainrouter config`. The old
161
- // "set BRAINROUTER_LLM_API_KEY in your shell" hint contradicted the
162
- // config.json-is-canonical design and confused users who already had a
163
- // fully populated config.
80
+ import fs from 'node:fs';
81
+ import { Command } from 'commander';
82
+ import inquirer from 'inquirer';
83
+ import chalk from 'chalk';
84
+ import { loadConfig, loadOrInitConfig, saveConfig, getConfigPath } from './config/config.js';
85
+ import { McpClientWrapper } from './runtime/mcpClient.js';
86
+ import { McpClientPool, selectMcpServerIds } from './runtime/mcpPool.js';
87
+ import { Agent } from './agent/agent.js';
88
+ import { runChat } from './cli/ink/runChat.js';
89
+ import { applyWorkspaceRoot, findWorkspaceRoot } from './config/workspace.js';
90
+ import { runWizard, isOnboarded } from './cli/ink/runWizard.js';
91
+ // The CLI deliberately does NOT load any `.env` file. Source of truth for
92
+ // runtime config is `~/.config/brainrouter/config.json` (LLM creds, MCP
93
+ // server profiles, theme, etc.), set interactively via the wizard / `/login`
94
+ // / `/config`. The MCP server is a separate concern and loads its own
95
+ // `server.env` from its own working directory — that's the server's
96
+ // business, not the CLI's. Shell env (real `process.env`) still flows
97
+ // through normally for everything that reads it (e.g. `OPENAI_API_KEY`
98
+ // fallback inside `callOpenAI`).
164
99
  const program = new Command();
165
100
  program
166
101
  .name('brainrouter')
167
102
  .description('BrainRouter CLI — Premium interactive terminal-based agent client.')
168
- .version('0.3.5');
103
+ .version('0.3.7');
169
104
  // Chat Command (default)
170
105
  program
171
106
  .command('chat', { isDefault: true })
@@ -174,29 +109,81 @@ program
174
109
  .option('-m, --model <name>', 'LLM model override')
175
110
  .option('-w, --workspace <path>', 'Workspace root for files, commands, memory session, and MCP --root')
176
111
  .option('--strict-mcp', 'Exit if the MCP server is unreachable (default: continue in offline mode with local tools only)')
112
+ .option('--quiet', 'Suppress recall tables, briefing dumps, and tool-completion previews (model prose only). Toggle in-session with /quiet.')
177
113
  .action(async (options) => {
178
114
  if (options.workspace) {
179
115
  process.env.BRAINROUTER_WORKSPACE = options.workspace;
180
116
  }
117
+ if (options.quiet) {
118
+ // Quiet mode is durable in preferences, but `--quiet` should turn it
119
+ // on for THIS session without permanently flipping the user's saved
120
+ // setting. Set a process env that the REPL preference-merger checks.
121
+ process.env.BRAINROUTER_QUIET = '1';
122
+ }
181
123
  const workspace = findWorkspaceRoot();
182
124
  applyWorkspaceRoot(workspace.workspaceRoot);
183
- console.log(chalk.gray(`Workspace: ${workspace.workspaceRoot} (${workspace.reason})`));
125
+ // Workspace path + detection reason intentionally NOT printed here — the
126
+ // boxed startup banner shows the workspace row, and `/workspace` exposes
127
+ // the launch CWD + detection reason on demand. Keeping a duplicate
128
+ // stale-chrome line above the banner undermines the banner-first design.
129
+ // 0.3.7 — first-run auto-trigger. When no config exists OR the
130
+ // onboarded marker is missing, drop the user straight into the
131
+ // wizard before constructing the Agent / MCP client. This replaces
132
+ // the pre-0.3.7 "Error: No BrainRouter config found ... run
133
+ // `brainrouter login`" exit-with-error path. The wizard owns its
134
+ // own readline for the wizard's lifetime; when it returns we
135
+ // continue into the REPL with the freshly-saved config.
136
+ if (!fs.existsSync(getConfigPath()) || !isOnboarded()) {
137
+ try {
138
+ const wizardResult = await runWizard({
139
+ workspaceRoot: workspace.workspaceRoot,
140
+ });
141
+ if (wizardResult.state.aborted) {
142
+ console.error(chalk.gray('Wizard aborted before saving — exiting. Run `brainrouter` again any time to retry.'));
143
+ process.exit(0);
144
+ }
145
+ }
146
+ catch (err) {
147
+ console.error(chalk.red(`Wizard failed: ${err?.message ?? err}`));
148
+ process.exit(1);
149
+ }
150
+ }
184
151
  const config = loadConfig();
185
- const profileName = options.profile || config.activeServer;
186
- const configuredServer = config.servers[profileName];
187
- if (!configuredServer) {
188
- console.error(chalk.red(`Error: Profile "${profileName}" not found in config.`));
152
+ // 0.3.7 multi-MCP support. Third-party MCPs are additive and all
153
+ // connect concurrently. BrainRouter MCPs are different: users may store
154
+ // several BrainRouter profiles (local/staging/remote/self-hosted), but
155
+ // only one brain should be active at a time. `activeServer` selects that
156
+ // BrainRouter profile when it points at one; otherwise we use the first
157
+ // configured BrainRouter profile. `--profile <name>` still scopes the run
158
+ // to exactly one server for explicit single-server mode.
159
+ const requestedProfile = options.profile;
160
+ const allServerIds = Object.keys(config.servers);
161
+ if (allServerIds.length === 0) {
162
+ console.error(chalk.red('Error: No MCP server profiles in config.'));
163
+ console.error(chalk.gray('Run `/login` inside the REPL or `brainrouter login` to add a profile.'));
189
164
  process.exit(1);
190
165
  }
191
- const serverConfig = { ...configuredServer };
192
- if (serverConfig.type === 'stdio') {
193
- const args = serverConfig.args ?? [];
194
- const rootIndex = args.indexOf('--root');
195
- serverConfig.args = rootIndex >= 0
196
- ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
197
- : [...args, '--root', workspace.workspaceRoot];
166
+ if (requestedProfile && !config.servers[requestedProfile]) {
167
+ console.error(chalk.red(`Error: Profile "${requestedProfile}" not found in config.`));
168
+ console.error(chalk.gray(`Available profiles: ${allServerIds.join(', ')}.`));
169
+ process.exit(1);
170
+ }
171
+ const targetIds = selectMcpServerIds(config.servers, config.activeServer, requestedProfile);
172
+ // Pre-process each target's serverConfig to thread workspaceRoot
173
+ // into the stdio `--root` arg shape the MCP server expects.
174
+ const targetServers = {};
175
+ for (const id of targetIds) {
176
+ const cloned = { ...config.servers[id] };
177
+ if (cloned.type === 'stdio') {
178
+ const args = cloned.args ?? [];
179
+ const rootIndex = args.indexOf('--root');
180
+ cloned.args = rootIndex >= 0
181
+ ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
182
+ : [...args, '--root', workspace.workspaceRoot];
183
+ }
184
+ targetServers[id] = cloned;
185
+ config.servers[id] = cloned;
198
186
  }
199
- config.servers[profileName] = serverConfig;
200
187
  const llm = config.llm || {
201
188
  provider: 'openai',
202
189
  model: 'gpt-4o-mini',
@@ -205,34 +192,34 @@ program
205
192
  if (options.model) {
206
193
  llm.model = options.model;
207
194
  }
208
- const mcpClient = new McpClientWrapper();
209
- console.log(chalk.gray(`Connecting to MCP server profile "${profileName}"...`));
210
- try {
211
- await mcpClient.connect(serverConfig, llm);
212
- console.log(chalk.green('Successfully connected to BrainRouter MCP Server!'));
213
- }
214
- catch (err) {
215
- // Degraded "offline mode": the MCP server is the cognitive memory layer
216
- // (recall, skills, capture, citations) losing it is painful but not
217
- // fatal. Local tools (read_file, write_file, list_dir, grep_search,
218
- // run_command, spawn_agent) still work, and the agent's runTurn already
219
- // try/catches every MCP call. Keep the REPL up so the user can edit
220
- // code, drive shell commands, and recover when the server comes back.
221
- // Pass --strict-mcp to flip back to hard-fail (useful in CI).
222
- console.error(chalk.red(`Failed to connect to MCP server: ${err.message}`));
195
+ // Connect everyone concurrently — offline servers don't block.
196
+ // "Connecting..." status lines intentionally dropped (see prior
197
+ // comment); the banner's per-server row is the success signal.
198
+ const mcpClient = new McpClientPool();
199
+ const statuses = await mcpClient.connectAll(targetServers, llm, { timeoutMs: 5_000 });
200
+ const failures = statuses.filter((s) => s.status === 'failed');
201
+ if (failures.length === statuses.length) {
202
+ // Every server failed equivalent to the pre-0.3.7 "MCP
203
+ // unreachable" path; same --strict-mcp semantics apply.
204
+ const summary = failures.map((s) => `${s.serverId}: ${s.error ?? 'unknown error'}`).join('\n ');
205
+ console.error(chalk.red(`Failed to connect to any MCP server:\n ${summary}`));
223
206
  if (options.strictMcp) {
224
207
  console.error(chalk.gray('--strict-mcp set; exiting.'));
225
208
  process.exit(1);
226
209
  }
227
- console.warn(chalk.yellow('⚠️ Continuing in OFFLINE MODE memory recall, skills, and capture are disabled.\n' +
228
- ' Local tools (file edits, shell, web fetch, spawn_agent) remain available.\n' +
229
- ' Start the MCP server and restart the CLI to restore full functionality.\n'));
210
+ // Falls through to offline-mode REPL banner shows the warning.
211
+ }
212
+ else if (failures.length > 0) {
213
+ // Partial failure — surface the failing server names without
214
+ // exiting; user can /mcp reconnect <id> later.
215
+ const failed = failures.map((s) => s.serverId).join(', ');
216
+ console.error(chalk.yellow(`⚠ ${failures.length} of ${statuses.length} MCP servers offline: ${failed}. Other servers connected; use /mcp to inspect.`));
230
217
  }
231
218
  const agent = new Agent(mcpClient, llm, {
232
219
  workspaceRoot: workspace.workspaceRoot,
233
220
  launchCwd: workspace.launchCwd,
234
221
  });
235
- startREPL(agent, mcpClient, config, workspace);
222
+ await runChat({ agent, mcpClient, config, workspace });
236
223
  });
237
224
  // One-shot non-interactive run — pipe-friendly for scripting/CI.
238
225
  // brainrouter run "summarize the changes in src/"
@@ -284,28 +271,41 @@ program
284
271
  const workspace = findWorkspaceRoot();
285
272
  applyWorkspaceRoot(workspace.workspaceRoot);
286
273
  const config = loadConfig();
287
- const profileName = options.profile || config.activeServer;
288
- const serverConfig = { ...config.servers[profileName] };
289
- if (!serverConfig) {
290
- console.error(`Error: Profile "${profileName}" not found.`);
274
+ // Multi-MCP: like `chat`, connect third-party servers concurrently but
275
+ // only one BrainRouter MCP profile at a time. `--profile <name>` scopes
276
+ // to exactly one.
277
+ const requestedProfile = options.profile;
278
+ const allServerIds = Object.keys(config.servers);
279
+ if (allServerIds.length === 0) {
280
+ console.error('Error: No MCP server profiles in config.');
291
281
  process.exit(1);
292
282
  }
293
- if (serverConfig.type === 'stdio') {
294
- const args = serverConfig.args ?? [];
295
- const rootIndex = args.indexOf('--root');
296
- serverConfig.args = rootIndex >= 0
297
- ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
298
- : [...args, '--root', workspace.workspaceRoot];
283
+ if (requestedProfile && !config.servers[requestedProfile]) {
284
+ console.error(`Error: Profile "${requestedProfile}" not found.`);
285
+ process.exit(1);
286
+ }
287
+ const targetIds = selectMcpServerIds(config.servers, config.activeServer, requestedProfile);
288
+ const targetServers = {};
289
+ for (const id of targetIds) {
290
+ const cloned = { ...config.servers[id] };
291
+ if (cloned.type === 'stdio') {
292
+ const args = cloned.args ?? [];
293
+ const rootIndex = args.indexOf('--root');
294
+ cloned.args = rootIndex >= 0
295
+ ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
296
+ : [...args, '--root', workspace.workspaceRoot];
297
+ }
298
+ targetServers[id] = cloned;
299
299
  }
300
300
  const llm = config.llm ?? { provider: 'openai', model: 'gpt-4o-mini', apiKey: '' };
301
301
  if (options.model)
302
302
  llm.model = options.model;
303
- const mcpClient = new McpClientWrapper();
304
- try {
305
- await mcpClient.connect(serverConfig, llm);
306
- }
307
- catch (err) {
308
- console.error(`MCP connect failed: ${err.message}`);
303
+ const mcpClient = new McpClientPool();
304
+ const statuses = await mcpClient.connectAll(targetServers, llm, { timeoutMs: 5_000 });
305
+ const allFailed = statuses.length > 0 && statuses.every((s) => s.status === 'failed');
306
+ if (allFailed) {
307
+ const summary = statuses.map((s) => `${s.serverId}: ${s.error ?? 'unknown'}`).join('; ');
308
+ console.error(`MCP connect failed (all servers): ${summary}`);
309
309
  if (options.strictMcp)
310
310
  process.exit(1);
311
311
  // Offline mode for one-shot: same rationale as the chat command — local
@@ -314,6 +314,12 @@ program
314
314
  // summarize" while the MCP server is down. CI can pass --strict-mcp.
315
315
  console.error('Continuing in offline mode (no memory recall / skills). Pass --strict-mcp to exit instead.');
316
316
  }
317
+ else {
318
+ const failed = statuses.filter((s) => s.status === 'failed');
319
+ if (failed.length > 0) {
320
+ process.stderr.write(`[mcp] ${failed.length} of ${statuses.length} servers offline: ${failed.map((f) => f.serverId).join(', ')}\n`);
321
+ }
322
+ }
317
323
  const agent = new Agent(mcpClient, llm, {
318
324
  workspaceRoot: workspace.workspaceRoot,
319
325
  launchCwd: workspace.launchCwd,
@@ -395,10 +401,11 @@ program
395
401
  type: 'http',
396
402
  url: answers.url,
397
403
  apiKey: answers.apiKey || undefined
398
- });
404
+ }, undefined, answers.profileName);
399
405
  await mcpClient.close();
400
- // Save to config
401
- const config = loadConfig();
406
+ // Save to config — `loadOrInitConfig` lets first-run users build a
407
+ // fresh config.json instead of hitting the strict no-config error.
408
+ const config = loadOrInitConfig();
402
409
  config.servers[answers.profileName] = {
403
410
  type: 'http',
404
411
  url: answers.url,
@@ -419,7 +426,9 @@ program
419
426
  .command('config')
420
427
  .description('Interactively configure your LLM provider and MCP servers')
421
428
  .action(async () => {
422
- const config = loadConfig();
429
+ // `loadOrInitConfig` because this command IS the first-run setup
430
+ // wizard — it must work even when no config.json exists yet.
431
+ const config = loadOrInitConfig();
423
432
  const menu = await inquirer.prompt([
424
433
  {
425
434
  type: 'list',
@@ -1,4 +1,4 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClientPool as McpClientWrapper } from '../runtime/mcpPool.js';
2
2
  export interface BriefingInputs {
3
3
  mcpClient: McpClientWrapper;
4
4
  mcpTools: Array<{
@@ -10,6 +10,16 @@ export interface BriefingInputs {
10
10
  activeSkill?: string;
11
11
  /** Cap on injected briefing content per source — guards against runaway payloads eating the context window. */
12
12
  maxCharsPerSource?: number;
13
+ /**
14
+ * Set by the caller when a `goal-anchor` system message is already
15
+ * carrying the current objective. The briefing skips `memory_task_state`
16
+ * in that case to avoid double-injecting the "what we're doing right
17
+ * now" context — the goal-anchor is the authoritative owner. When
18
+ * there is no active goal (pre-goal exploration, after `/goal pause`,
19
+ * silent child agents) the task-state surface still fires so handover
20
+ * notes and prior blockers stay visible. Part of 0.3.6 item 9d.
21
+ */
22
+ hasActiveGoal?: boolean;
13
23
  }
14
24
  export interface RecalledRecord {
15
25
  recordId: string;
@@ -17,7 +17,7 @@ export async function buildMemoryBriefing(inputs) {
17
17
  if (toolNames.has('memory_working_context')) {
18
18
  tasks.push(callSafe('memory_working_context', { sessionKey, workspacePath: workspaceRoot }, mcpClient, maxChars));
19
19
  }
20
- if (toolNames.has('memory_task_state')) {
20
+ if (toolNames.has('memory_task_state') && !inputs.hasActiveGoal) {
21
21
  tasks.push(callSafe('memory_task_state', { query }, mcpClient, maxChars));
22
22
  }
23
23
  const results = await Promise.all(tasks);
@@ -28,6 +28,16 @@ export async function buildMemoryBriefing(inputs) {
28
28
  if (!r.text)
29
29
  continue;
30
30
  sourcesQueried.push(r.source);
31
+ if (r.source === 'memory_working_context') {
32
+ const workingSection = renderWorkingMemorySection(r.text);
33
+ if (workingSection) {
34
+ sections.push(workingSection);
35
+ continue;
36
+ }
37
+ // Fall through to the opaque-dump branch when the payload didn't
38
+ // match the expected shape — that path runs redactText and keeps
39
+ // the secrets test honest.
40
+ }
31
41
  if (r.records && r.records.length > 0) {
32
42
  // Render structured cards instead of dumping the raw JSON. The previous
33
43
  // form emitted ~2-4KB of `recallExplanation`/`sparkedNodes`/etc. per
@@ -147,6 +157,64 @@ function prettyLabel(toolName) {
147
157
  default: return toolName;
148
158
  }
149
159
  }
160
+ /**
161
+ * 0.3.6 item 2c — structurally surface working-memory steps in the
162
+ * briefing. Two slices:
163
+ * - the recentSteps tail the MCP already injected (last 5–10 steps,
164
+ * regardless of kind), which gives the model the latest tool
165
+ * outputs in order; and
166
+ * - up to 3 most-recent reasoning-kind steps from the full step log,
167
+ * which keeps the "why" trail visible even after a chatty tool
168
+ * burst has pushed reasoning off the tail.
169
+ *
170
+ * Returns null when the payload doesn't look like a working-context
171
+ * JSON blob — caller falls back to the opaque-dump branch so secrets
172
+ * still get redacted on unstructured text.
173
+ */
174
+ function renderWorkingMemorySection(text) {
175
+ let parsed;
176
+ try {
177
+ parsed = JSON.parse(text);
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ if (!parsed || typeof parsed !== 'object')
183
+ return null;
184
+ const recentSteps = Array.isArray(parsed?.state?.injectedState?.recentSteps)
185
+ ? parsed.state.injectedState.recentSteps
186
+ : [];
187
+ const allSteps = Array.isArray(parsed?.steps) ? parsed.steps : recentSteps;
188
+ if (recentSteps.length === 0 && allSteps.length === 0)
189
+ return null;
190
+ const renderStep = (step) => {
191
+ const kind = step.kind ? `[${step.kind}] ` : '';
192
+ const title = (step.title ?? '').replace(/\s+/g, ' ').trim() || '(no title)';
193
+ const summary = (step.summary ?? '').replace(/\s+/g, ' ').trim();
194
+ const preview = summary.length > 200 ? summary.slice(0, 199) + '…' : summary;
195
+ return `- ${kind}${title}${preview ? ` — ${preview}` : ''}`;
196
+ };
197
+ const lines = [`### ${prettyLabel('memory_working_context')}`];
198
+ if (recentSteps.length > 0) {
199
+ lines.push('Recent steps:');
200
+ for (const step of recentSteps)
201
+ lines.push(renderStep(step));
202
+ }
203
+ // Surface up to 3 most-recent reasoning-kind steps that the recentSteps
204
+ // tail didn't already include. Cap on purpose — without it a turn that
205
+ // offloaded reasoning every batch would stuff the briefing with its own
206
+ // past commentary.
207
+ const recentNodeIds = new Set(recentSteps.map((s) => s.nodeId).filter(Boolean));
208
+ const reasoningTail = allSteps
209
+ .filter((s) => s.kind === 'reasoning' && (!s.nodeId || !recentNodeIds.has(s.nodeId)))
210
+ .slice(-3);
211
+ if (reasoningTail.length > 0) {
212
+ lines.push('', 'Recent reasoning (why-trail):');
213
+ for (const step of reasoningTail)
214
+ lines.push(renderStep(step));
215
+ }
216
+ return redactText(lines.join('\n'));
217
+ }
150
218
  function dedupe(items) {
151
219
  return Array.from(new Set(items));
152
220
  }
@@ -1,4 +1,4 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClientPool as McpClientWrapper } from '../runtime/mcpPool.js';
2
2
  /**
3
3
  * Filesystem memory consolidation — the human-readable companion to the
4
4
  * cognitive memory database.