@kinqs/brainrouter-cli 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) 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/dist/agent/agent.d.ts +12 -1
  8. package/dist/agent/agent.js +134 -18
  9. package/dist/cli/banner.d.ts +20 -0
  10. package/dist/cli/banner.js +47 -14
  11. package/dist/cli/cliPrompt.d.ts +40 -3
  12. package/dist/cli/cliPrompt.js +52 -25
  13. package/dist/cli/commands/_context.d.ts +3 -1
  14. package/dist/cli/commands/_helpers.d.ts +1 -1
  15. package/dist/cli/commands/config.d.ts +46 -0
  16. package/dist/cli/commands/config.js +1042 -0
  17. package/dist/cli/commands/init.d.ts +20 -0
  18. package/dist/cli/commands/init.js +64 -0
  19. package/dist/cli/commands/login.d.ts +13 -0
  20. package/dist/cli/commands/login.js +179 -0
  21. package/dist/cli/commands/mcp.d.ts +13 -11
  22. package/dist/cli/commands/mcp.js +239 -74
  23. package/dist/cli/commands/orchestration.js +18 -0
  24. package/dist/cli/commands/ui.js +117 -58
  25. package/dist/cli/commands/workflow.d.ts +2 -0
  26. package/dist/cli/commands/workflow.js +54 -8
  27. package/dist/cli/ink/ChatApp.d.ts +206 -0
  28. package/dist/cli/ink/ChatApp.js +493 -0
  29. package/dist/cli/ink/Frame.d.ts +26 -0
  30. package/dist/cli/ink/Frame.js +5 -0
  31. package/dist/cli/ink/Picker.d.ts +65 -0
  32. package/dist/cli/ink/Picker.js +133 -0
  33. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  34. package/dist/cli/ink/SlashPalette.js +136 -0
  35. package/dist/cli/ink/TextField.d.ts +34 -0
  36. package/dist/cli/ink/TextField.js +47 -0
  37. package/dist/cli/ink/WizardApp.d.ts +7 -0
  38. package/dist/cli/ink/WizardApp.js +422 -0
  39. package/dist/cli/ink/ambientChat.d.ts +34 -0
  40. package/dist/cli/ink/ambientChat.js +7 -0
  41. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  42. package/dist/cli/ink/consoleCapture.js +33 -0
  43. package/dist/cli/ink/markdownRender.d.ts +41 -0
  44. package/dist/cli/ink/markdownRender.js +278 -0
  45. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  46. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  47. package/dist/cli/ink/runChat.d.ts +34 -0
  48. package/dist/cli/ink/runChat.js +571 -0
  49. package/dist/cli/ink/runPicker.d.ts +31 -0
  50. package/dist/cli/ink/runPicker.js +139 -0
  51. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  52. package/dist/cli/ink/runSlashPalette.js +33 -0
  53. package/dist/cli/ink/runWizard.d.ts +22 -0
  54. package/dist/cli/ink/runWizard.js +133 -0
  55. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  56. package/dist/cli/ink/stdinHandoff.js +78 -0
  57. package/dist/cli/ink/toolFormat.d.ts +73 -0
  58. package/dist/cli/ink/toolFormat.js +180 -0
  59. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  60. package/dist/cli/ink/useTerminalSize.js +26 -0
  61. package/dist/cli/repl.d.ts +25 -3
  62. package/dist/cli/repl.js +43 -712
  63. package/dist/cli/slashSuggest.d.ts +32 -0
  64. package/dist/cli/slashSuggest.js +146 -0
  65. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  66. package/dist/cli/wizard/modelsApi.js +166 -0
  67. package/dist/cli/wizard/picker.d.ts +202 -0
  68. package/dist/cli/wizard/picker.js +547 -0
  69. package/dist/cli/wizard/providers.d.ts +86 -0
  70. package/dist/cli/wizard/providers.js +190 -0
  71. package/dist/cli/wizard/runner.d.ts +13 -0
  72. package/dist/cli/wizard/runner.js +488 -0
  73. package/dist/cli/wizard/types.d.ts +122 -0
  74. package/dist/cli/wizard/types.js +109 -0
  75. package/dist/config/config.d.ts +12 -0
  76. package/dist/config/config.js +45 -3
  77. package/dist/index.js +148 -206
  78. package/dist/memory/briefing.d.ts +1 -1
  79. package/dist/memory/consolidation.d.ts +1 -1
  80. package/dist/orchestration/agentRegistry.d.ts +36 -0
  81. package/dist/orchestration/agentRegistry.js +64 -0
  82. package/dist/orchestration/orchestrator.d.ts +7 -0
  83. package/dist/orchestration/orchestrator.js +2 -0
  84. package/dist/orchestration/tools.d.ts +10 -1
  85. package/dist/orchestration/tools.js +48 -4
  86. package/dist/prompt/skillCatalog.d.ts +11 -0
  87. package/dist/prompt/skillCatalog.js +134 -0
  88. package/dist/prompt/skillRunner.d.ts +2 -2
  89. package/dist/prompt/skillRunner.js +2 -31
  90. package/dist/prompt/systemPrompt.js +5 -1
  91. package/dist/runtime/mcpClient.js +14 -11
  92. package/dist/runtime/mcpPool.d.ts +162 -0
  93. package/dist/runtime/mcpPool.js +423 -0
  94. package/dist/runtime/mcpUtils.d.ts +3 -1
  95. package/package.json +8 -2
  96. package/.env.example +0 -116
package/README.md CHANGED
@@ -6,8 +6,6 @@ recall, skills, and capture.
6
6
 
7
7
  Ships the `brainrouter` binary.
8
8
 
9
- ---
10
-
11
9
  ## Install
12
10
 
13
11
  ```bash
@@ -23,52 +21,44 @@ command not found`.
23
21
 
24
22
  | How Node is installed | Use `sudo`? |
25
23
  |---|---|
26
- | Homebrew (`brew install node`) | No — global prefix is user-writable |
27
- | nvm / asdf / fnm | No — same reason |
28
- | System Node on macOS / Linux | Yes — global prefix is `/usr/local/...` |
29
-
30
- Check yours:
31
-
32
- ```bash
33
- npm config get prefix
34
- ```
24
+ | Homebrew (`brew install node`) | No — global prefix is user-writable |
25
+ | nvm / asdf / fnm | No — same reason |
26
+ | System Node on macOS / Linux | Yes — global prefix is `/usr/local/...` |
35
27
 
36
- If the path is under `/Users/...`, `/opt/homebrew/...`, or your home dir
37
- no sudo. If it's `/usr/local/...` use sudo.
28
+ Check yours: `npm config get prefix`. If the path is under `/Users/...`,
29
+ `/opt/homebrew/...`, or your home dir — no sudo. If it's `/usr/local/...` use sudo.
38
30
 
39
31
  Verify the install:
40
32
 
41
33
  ```bash
42
34
  which brainrouter # prints the path to the binary
43
- brainrouter --version # prints 0.3.5
35
+ brainrouter --version # prints 0.3.7
44
36
  ```
45
37
 
46
- ---
47
-
48
38
  ## Configure
49
39
 
50
- Two configuration surfaces, both one-time:
51
-
52
- ### 1. The chat LLM and MCP server profile
40
+ Run `brainrouter` for the first time and the **setup wizard** starts
41
+ automatically:
53
42
 
54
- ```bash
55
- brainrouter config # interactive set LLM provider, model, key, endpoint
56
- brainrouter login # interactive — set MCP server URL + API key
43
+ ```
44
+ Welcome Theme Provider API key → Model → MCP → AGENT.md → Done
57
45
  ```
58
46
 
59
- Both write to `~/.config/brainrouter/config.json`.
47
+ It writes everything to `~/.config/brainrouter/config.json` — no manual
48
+ file editing needed.
60
49
 
61
- For local-model setups (LM Studio / Ollama), point the LLM endpoint at
62
- `http://localhost:1234/v1/chat/completions` or `http://localhost:11434/v1/chat/completions`.
50
+ To re-run the wizard later: type `/init` inside the REPL.
63
51
 
64
- ### 2. (Optional) Runtime knobs `~/.config/brainrouter/cli.env` or `./brainrouter-cli.env`
52
+ To change a single setting: use `/config <key> <value>` or the `/config`
53
+ home panel. To re-configure the MCP server connection: use `/login`.
65
54
 
66
- Only needed if you want to tune sandbox, tool-loop limits, trace logging,
67
- or web-search backend. See the [`.env.example`](.env.example) bundled with
68
- this package for the full list. LLM credentials do **not** go here — they
69
- live in `config.json`.
55
+ For local-model setups (LM Studio / Ollama), point the LLM endpoint at
56
+ `http://localhost:1234/v1/chat/completions` or `http://localhost:11434/v1/chat/completions`.
70
57
 
71
- ---
58
+ **Runtime knobs** (sandbox, trace log, web-search backend, tool-loop limits)
59
+ are set as shell environment variables. See
60
+ [`brainrouter-docs/configuration.md`](https://github.com/kinqsradiollc/BrainRouter/blob/main/brainrouter-docs/configuration.md)
61
+ for the full list.
72
62
 
73
63
  ## Run
74
64
 
@@ -83,22 +73,15 @@ Inside the REPL, type `/help` for the full slash-command list (60+
83
73
  commands across session / memory / workflow / orchestration / observability
84
74
  surfaces).
85
75
 
86
- ### Offline mode
87
-
88
- If the MCP server isn't reachable, the CLI still boots — but only local
89
- tools (file edits, shell, web fetch, `spawn_agent`) work. Memory recall,
90
- capture, and skills are disabled until the server is back. The startup
91
- banner shows `⚠️ OFFLINE MODE` when this happens. Pass `--strict-mcp` to
76
+ **Offline mode** — if the MCP server isn't reachable, the CLI still boots
77
+ with only local tools (file edits, shell, web fetch, `spawn_agent`). Memory
78
+ recall, capture, and skills are disabled until the server is back. The
79
+ startup banner shows `offline` when this happens. Pass `--strict-mcp` to
92
80
  make the CLI exit instead of degrading.
93
81
 
94
- ### Stdio mode
95
-
96
- If you'd rather have the CLI spawn the MCP server as a child process
97
- instead of running it separately, use `brainrouter config` → "Set Active
98
- Server Profile" → `default` (the bundled stdio profile). You don't need
99
- to run anything else — the CLI manages the server's lifecycle.
100
-
101
- ---
82
+ **Stdio mode** — to have the CLI spawn the MCP server as a child process
83
+ instead of running it separately: open `/config`, go to MCP settings, and
84
+ pick the bundled `stdio` profile. The CLI manages the server's lifecycle.
102
85
 
103
86
  ## Workspace detection
104
87
 
@@ -113,8 +96,6 @@ BRAINROUTER_WORKSPACE=/absolute/path/to/project brainrouter
113
96
 
114
97
  Inside the REPL, run `/workspace` to confirm the active root and session key.
115
98
 
116
- ---
117
-
118
99
  ## What you also probably want
119
100
 
120
101
  A BrainRouter MCP server for the cognitive memory. The CLI works without
@@ -127,9 +108,7 @@ $EDITOR ~/.config/brainrouter/server.env # set BRAINROUTER_LLM_API_KEY,
127
108
  brainrouter-mcp --http --port 3747 # in a separate terminal
128
109
  ```
129
110
 
130
- Then `brainrouter login` and point at `http://localhost:3747/mcp`.
131
-
132
- ---
111
+ Then run `/login` inside the REPL and point at `http://localhost:3747/mcp`.
133
112
 
134
113
  ## Docs
135
114
 
@@ -138,8 +117,6 @@ Then `brainrouter login` and point at `http://localhost:3747/mcp`.
138
117
  - **Maintainer runbook**: [SETUP.md](https://github.com/kinqsradiollc/BrainRouter/blob/main/SETUP.md)
139
118
  - **Bugs / requests**: <https://github.com/kinqsradiollc/BrainRouter/issues>
140
119
 
141
- ---
142
-
143
120
  ## License
144
121
 
145
122
  MIT
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "architect",
3
+ "displayName": "Architect",
4
+ "whenToUse": "Design alternatives and tradeoffs for a feature or system change. No file writes.",
5
+ "prompt": "## Role: Architect\nYou design solutions; you do not write production code.\n\n### Memory-first opening (mandatory)\n- `memory_search` and `memory_graph_query` for the feature/domain — past architecture decisions often constrain new ones.\n- `memory_contradictions` (action: list) — if prior designs contradict the proposed change, flag it.\n- Cite any architecture_decision records you find with their recordId.\n\nAlways present at least two design alternatives with explicit tradeoffs (complexity, blast radius, reversibility, test cost).\nEnd with a clear recommendation and the smallest first vertical slice.",
6
+ "model": null,
7
+ "effort": null,
8
+ "defaultAccess": "read",
9
+ "toolScope": { "local": ["*"], "mcp": ["memory_*"] },
10
+ "disallowedTools": [],
11
+ "maxIterations": 30,
12
+ "timeoutMs": 120000,
13
+ "maxResultChars": 8000,
14
+ "subagents": [],
15
+ "delegateName": "delegate_architect",
16
+ "tier": "reasoning",
17
+ "outputContract": null
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "explorer",
3
+ "displayName": "Explorer",
4
+ "whenToUse": "Read-only codebase investigation. Returns concrete file paths, line ranges, and a short list of facts.",
5
+ "prompt": "## Role: Explorer\nYou are a read-only investigator. Do not edit files or run shell commands.\nGoal: map the relevant code, return concrete file paths with line ranges, and surface the few facts the parent needs to decide.\n\n### Memory-first opening (mandatory)\n- Step 1: `memory_search` for the topic of investigation. Past explorers may have mapped this already — do not re-discover what BrainRouter already knows.\n- Step 2: `memory_graph_query` with the dominant feature/entity name to surface related memories across 2 hops.\n- Step 3: `memory_file_history` for any file the parent specifically mentions.\n- Cite every recordId you build on. Your output begins with a `### Memory consulted` block listing the record IDs and what they told you.\n\nOutput structure: 1) Memory consulted, 2) Summary (3-5 bullets), 3) Key files with line ranges, 4) Open questions, 5) Suggested next probe.\nNever claim work is complete without naming actual files you read AND showing the memory you consulted.",
6
+ "model": null,
7
+ "effort": null,
8
+ "defaultAccess": "read",
9
+ "toolScope": { "local": ["*"], "mcp": ["memory_*"] },
10
+ "disallowedTools": [],
11
+ "maxIterations": 30,
12
+ "timeoutMs": 120000,
13
+ "maxResultChars": 8000,
14
+ "subagents": [],
15
+ "delegateName": "delegate_explorer",
16
+ "tier": "reasoning",
17
+ "outputContract": null
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "reviewer",
3
+ "displayName": "Reviewer",
4
+ "whenToUse": "Code review stance; findings first, severity-ordered. Read-only.",
5
+ "prompt": "## Role: Reviewer\nYou review changes critically. Findings first; severity-ordered (blocker, major, minor, nit).\n\n### Memory-first opening (mandatory)\n- `memory_search` for prior reviews on the same files or feature — never re-flag an issue another reviewer already decided is acceptable.\n- `memory_file_history` for each file in the diff — known regressions and prior bug fixes inform your verdict.\n- Cite related recordIds inline in each finding so the parent can see the precedent.\n\nFor each finding: file:line, what is wrong, why it matters, suggested fix.\nDo not make edits. The parent will decide what to apply.",
6
+ "model": null,
7
+ "effort": null,
8
+ "defaultAccess": "read",
9
+ "toolScope": { "local": ["*"], "mcp": ["memory_*"] },
10
+ "disallowedTools": [],
11
+ "maxIterations": 30,
12
+ "timeoutMs": 120000,
13
+ "maxResultChars": 8000,
14
+ "subagents": [],
15
+ "delegateName": "delegate_reviewer",
16
+ "tier": "reasoning",
17
+ "outputContract": null
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "verifier",
3
+ "displayName": "Verifier",
4
+ "whenToUse": "Runs tests and checks; reports pass/fail with evidence.",
5
+ "prompt": "## Role: Verifier\nYou verify that recent changes work. Run the smallest useful set of tests/typechecks.\n\n### Memory-first opening (mandatory)\n- `memory_search` for prior failure modes on these tests — flaky tests, environment caveats, and known-bad commands live in memory.\n- `memory_file_history` for any test file involved — past fixes for the same suite are highly relevant.\n\nReport: which command(s) you ran, exit codes, failing output (trimmed), and a clear PASS/FAIL verdict.\nNever claim PASS without actually executing a check. On failure, call `memory_task_update` with the blocker so the next worker can pick it up.",
6
+ "model": null,
7
+ "effort": null,
8
+ "defaultAccess": "shell",
9
+ "toolScope": { "local": ["*"], "mcp": ["memory_*"] },
10
+ "disallowedTools": [],
11
+ "maxIterations": 30,
12
+ "timeoutMs": 120000,
13
+ "maxResultChars": 8000,
14
+ "subagents": [],
15
+ "delegateName": "delegate_verifier",
16
+ "tier": "worker",
17
+ "outputContract": null
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "worker",
3
+ "displayName": "Worker",
4
+ "whenToUse": "Implementation-focused. May edit files when granted write access.",
5
+ "prompt": "## Role: Worker\nYou implement a single bounded task. Keep edits minimal and scoped.\n\n### Memory-first opening (mandatory)\n- `memory_recall` for the task topic — past instructions, conventions, and tool_preference records often dictate HOW to implement.\n- `memory_file_history` for the files you intend to touch — known fragility lives there.\n- If the parent gave you `seedRecordIds`, treat those as authoritative context.\n- `memory_task_state` if this looks like a continuation — pick up where prior work left off.\n\nRead before editing. Prefer edit_file over write_file when possible. Prefer apply_patch for multi-file edits.\nOn completion call `memory_task_update` with the outcome, then report exactly which files you changed and any follow-ups the verifier should run.",
6
+ "model": null,
7
+ "effort": null,
8
+ "defaultAccess": "write",
9
+ "toolScope": { "local": ["*"], "mcp": ["memory_*"] },
10
+ "disallowedTools": [],
11
+ "maxIterations": 30,
12
+ "timeoutMs": 120000,
13
+ "maxResultChars": 8000,
14
+ "subagents": [],
15
+ "delegateName": "delegate_worker",
16
+ "tier": "worker",
17
+ "outputContract": null
18
+ }
@@ -1,4 +1,4 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClientPool as McpClientWrapper } from '../runtime/mcpPool.js';
2
2
  import type { LLMConfig } from '../config/config.js';
3
3
  import type { AccessMode } from '../orchestration/roles.js';
4
4
  import { type RecalledRecord } from '../memory/briefing.js';
@@ -104,6 +104,10 @@ export interface AgentOptions {
104
104
  */
105
105
  parentTraceId?: string;
106
106
  parentSpanId?: string;
107
+ /** Agent tier — propagated from the definition so hierarchy checks work in grandchildren. */
108
+ tier?: 'chat' | 'reasoning' | 'worker';
109
+ /** Nesting depth in the spawn chain; 0 = direct child of the chat root (default). */
110
+ agentDepth?: number;
107
111
  }
108
112
  export declare const LOCAL_TOOLS: ({
109
113
  name: string;
@@ -744,11 +748,18 @@ export declare class Agent {
744
748
  readonly agentId: string;
745
749
  /** agent_id of the parent (set by spawn_agent for children). */
746
750
  private parentAgentId?;
751
+ /** Agent tier — forwarded to OrchestrationContext so grandchildren can inherit hierarchy checks. */
752
+ readonly tier?: 'chat' | 'reasoning' | 'worker';
753
+ /** Spawn-chain depth (0 = direct chat-root child). Forwarded to hierarchy checks. */
754
+ readonly agentDepth: number;
747
755
  constructor(mcpClient: McpClientWrapper, llmConfig: LLMConfig, options: AgentOptions);
748
756
  /** Expose for orchestration so spawn_agent can record the parent linkage. */
749
757
  getAgentId(): string;
750
758
  /** Internal — used by spawn_agent to record which parent dispatched us. */
751
759
  setParentAgentId(id: string | undefined): void;
760
+ private isModelVisibleMcpTool;
761
+ private rawMcpToolName;
762
+ private serverIdFromMcpToolName;
752
763
  private allowedToolsForAccess;
753
764
  runTurn(prompt: string, callbacks: RunTurnCallbacks): Promise<string>;
754
765
  /** Rough token estimate (1 token ≈ 4 characters of English / code). */
@@ -23,6 +23,53 @@ import { renderCompactSystemMessage, runCompaction } from '../prompt/compactor.j
23
23
  import { buildFanOutHint, shouldSuggestFanOut } from '../prompt/breadthHint.js';
24
24
  const execPromise = promisify(exec);
25
25
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.DS_Store', '.next']);
26
+ function parseJsonObject(text) {
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ function collectChildIds(value) {
36
+ if (!value || typeof value !== 'object')
37
+ return [];
38
+ const ids = [];
39
+ const maybeRecord = value;
40
+ if (typeof maybeRecord.id === 'string')
41
+ ids.push(maybeRecord.id);
42
+ if (Array.isArray(maybeRecord.agents)) {
43
+ for (const entry of maybeRecord.agents) {
44
+ if (entry && typeof entry === 'object' && typeof entry.id === 'string') {
45
+ ids.push(entry.id);
46
+ }
47
+ }
48
+ }
49
+ return [...new Set(ids)];
50
+ }
51
+ function trackChildObservation(toolName, args, resultText, spawned, waited) {
52
+ if (toolName === 'spawn_agent' || toolName === 'spawn_agents') {
53
+ const ids = collectChildIds(parseJsonObject(resultText));
54
+ for (const id of ids) {
55
+ spawned.add(id);
56
+ if (toolName === 'spawn_agent' && args?.wait)
57
+ waited.add(id);
58
+ }
59
+ return;
60
+ }
61
+ if (toolName === 'wait_agent') {
62
+ const id = typeof args?.id === 'string' ? args.id : undefined;
63
+ if (id)
64
+ waited.add(id);
65
+ return;
66
+ }
67
+ if (toolName === 'wait_agents') {
68
+ const ids = Array.isArray(args?.ids) ? args.ids.filter((id) => typeof id === 'string') : [];
69
+ for (const id of ids)
70
+ waited.add(id);
71
+ }
72
+ }
26
73
  export const LOCAL_TOOLS = [
27
74
  {
28
75
  name: 'read_file',
@@ -414,6 +461,10 @@ export class Agent {
414
461
  agentId = `agent-${Math.random().toString(36).slice(2, 8)}`;
415
462
  /** agent_id of the parent (set by spawn_agent for children). */
416
463
  parentAgentId;
464
+ /** Agent tier — forwarded to OrchestrationContext so grandchildren can inherit hierarchy checks. */
465
+ tier;
466
+ /** Spawn-chain depth (0 = direct chat-root child). Forwarded to hierarchy checks. */
467
+ agentDepth;
417
468
  constructor(mcpClient, llmConfig, options) {
418
469
  this.mcpClient = mcpClient;
419
470
  this.llmConfig = llmConfig;
@@ -437,6 +488,8 @@ export class Agent {
437
488
  this.systemPromptOverride = options.systemPromptOverride;
438
489
  this.parentTraceId = options.parentTraceId;
439
490
  this.parentSpanId = options.parentSpanId;
491
+ this.tier = options.tier;
492
+ this.agentDepth = options.agentDepth ?? 0;
440
493
  }
441
494
  /** Expose for orchestration so spawn_agent can record the parent linkage. */
442
495
  getAgentId() {
@@ -446,6 +499,47 @@ export class Agent {
446
499
  setParentAgentId(id) {
447
500
  this.parentAgentId = id;
448
501
  }
502
+ isModelVisibleMcpTool(tool) {
503
+ const hiddenBrainrouterTools = new Set([
504
+ 'memory_capture_turn',
505
+ 'memory_mark_cited',
506
+ 'memory_resolve_session',
507
+ 'memory_register_skill_hints',
508
+ 'memory_hook_register',
509
+ 'memory_hook_status',
510
+ ]);
511
+ const name = String(tool?.name ?? '');
512
+ const rawName = String(tool?.__rawName ?? this.rawMcpToolName(name));
513
+ if (!hiddenBrainrouterTools.has(rawName))
514
+ return true;
515
+ const serverId = typeof tool?.__serverId === 'string'
516
+ ? tool.__serverId
517
+ : this.serverIdFromMcpToolName(name);
518
+ const status = serverId && typeof this.mcpClient.getStatus === 'function'
519
+ ? this.mcpClient.getStatus(serverId)
520
+ : undefined;
521
+ // Hide only BrainRouter auto-pipeline/admin tools. Third-party MCP tools
522
+ // with coincidentally similar names stay visible.
523
+ return status?.identity !== 'brainrouter';
524
+ }
525
+ rawMcpToolName(name) {
526
+ const serverId = this.serverIdFromMcpToolName(name);
527
+ return serverId ? name.slice(`mcp__${serverId}__`.length) : name;
528
+ }
529
+ serverIdFromMcpToolName(name) {
530
+ if (!name.startsWith('mcp__'))
531
+ return undefined;
532
+ const rest = name.slice('mcp__'.length);
533
+ if (typeof this.mcpClient.getServerIds === 'function') {
534
+ const ids = this.mcpClient.getServerIds();
535
+ for (const id of ids.sort((a, b) => b.length - a.length)) {
536
+ if (rest.startsWith(`${id}__`))
537
+ return id;
538
+ }
539
+ }
540
+ const idx = rest.indexOf('__');
541
+ return idx >= 0 ? rest.slice(0, idx) : undefined;
542
+ }
449
543
  allowedToolsForAccess() {
450
544
  // Lifecycle / inspection tools are always available regardless of access
451
545
  // mode — they don't touch the workspace and the agent needs them to end
@@ -504,27 +598,20 @@ export class Agent {
504
598
  // whenever the inventory shape changed (online → offline or vice
505
599
  // versa) so the next LLM call sees the correct system message.
506
600
  const prevTools = this.lastKnownMcpTools?.map((t) => t.name).sort().join(',');
507
- this.lastKnownMcpTools = mcpTools.map((t) => ({ name: t.name }));
601
+ this.lastKnownMcpTools = mcpTools.map((t) => ({
602
+ name: String(t?.__rawName ?? this.rawMcpToolName(String(t?.name ?? ''))),
603
+ }));
508
604
  const newTools = this.lastKnownMcpTools.map((t) => t.name).sort().join(',');
509
605
  if (prevTools !== newTools && this.chatHistory.length > 0 && this.chatHistory[0].role === 'system') {
510
606
  this.chatHistory[0] = this.createSystemMessage();
511
607
  }
512
608
  const allowed = this.allowedToolsForAccess();
513
609
  const filteredLocalTools = LOCAL_TOOLS.filter(t => allowed.has(t.name));
514
- // Hide MCP tools we already call automatically. Small models otherwise
515
- // try to invoke them with the wrong arguments (most commonly
516
- // memory_capture_turn "Required, Required" comes from missing
517
- // sessionKey + messages). These tools are still callable; the CLI just
518
- // doesn't tell the LLM about them since the auto-pipeline owns them.
519
- const HIDDEN_FROM_LLM = new Set([
520
- 'memory_capture_turn', // called automatically post-turn
521
- 'memory_mark_cited', // called automatically with real citation IDs
522
- 'memory_resolve_session', // called automatically at bootstrap
523
- 'memory_register_skill_hints', // boot-time, not turn-level
524
- 'memory_hook_register', // managed via /hooks
525
- 'memory_hook_status',
526
- ]);
527
- const visibleMcpTools = mcpTools.filter((t) => !HIDDEN_FROM_LLM.has(t.name));
610
+ // Multi-MCP parity: expose every connected third-party MCP tool and the
611
+ // model-safe BrainRouter MCP tools in one turn, using the pool's
612
+ // `mcp__<serverId>__<tool>` namespaces. BrainRouter's auto-pipeline/admin
613
+ // tools stay hidden because the CLI owns those flows.
614
+ const visibleMcpTools = mcpTools.filter((t) => this.isModelVisibleMcpTool(t));
528
615
  const allTools = [...filteredLocalTools, ...visibleMcpTools];
529
616
  callbacks.onStatusUpdate(`Loaded ${filteredLocalTools.length} local tools and ${mcpTools.length} MCP tools.`);
530
617
  // Auto-compact: if the chat history has grown past the configured token
@@ -612,6 +699,9 @@ export class Agent {
612
699
  // signatures so we can interrupt the loop with corrective feedback.
613
700
  const recentToolSignatures = [];
614
701
  const REPEAT_GUARD_LIMIT = 3;
702
+ const spawnedChildIdsThisTurn = new Set();
703
+ const waitedChildIdsThisTurn = new Set();
704
+ let spawnWaitGuardInjected = false;
615
705
  while (loopCount < maxLoops) {
616
706
  loopCount++;
617
707
  callbacks.onStatusUpdate(`Thinking (turn ${loopCount})...`);
@@ -639,6 +729,21 @@ export class Agent {
639
729
  this.chatHistory.push(assistantMsg);
640
730
  this.recordTranscript(assistantMsg);
641
731
  if (!response.toolCalls || response.toolCalls.length === 0) {
732
+ const unobservedChildIds = [...spawnedChildIdsThisTurn].filter((id) => !waitedChildIdsThisTurn.has(id));
733
+ if (unobservedChildIds.length > 0 && !spawnWaitGuardInjected) {
734
+ spawnWaitGuardInjected = true;
735
+ const waitTool = unobservedChildIds.length === 1 ? 'wait_agent' : 'wait_agents';
736
+ const correction = [
737
+ `You spawned ${unobservedChildIds.length} child agent${unobservedChildIds.length === 1 ? '' : 's'} in this turn but have not waited for their outputs yet.`,
738
+ `Call \`${waitTool}\` now for: ${unobservedChildIds.join(', ')}.`,
739
+ 'Do not tell the user you are waiting in prose; use the tool call, then synthesize the returned child output.',
740
+ ].join(' ');
741
+ const guardMsg = { role: 'user', content: correction };
742
+ this.chatHistory.push(guardMsg);
743
+ this.recordTranscript(guardMsg);
744
+ callbacks.onStatusUpdate(`Waiting required for ${unobservedChildIds.length} child agent${unobservedChildIds.length === 1 ? '' : 's'}...`);
745
+ continue;
746
+ }
642
747
  finalAnswer = response.content;
643
748
  exitedCleanly = true;
644
749
  break;
@@ -758,6 +863,8 @@ export class Agent {
758
863
  parentTraceId: turnSpan.traceId,
759
864
  parentSpanId: turnSpan.spanId,
760
865
  parentAgentId: this.agentId,
866
+ parentTier: this.tier,
867
+ depth: this.agentDepth,
761
868
  mcpClient: this.mcpClient,
762
869
  llmConfig: this.llmConfig,
763
870
  launchCwd: this.launchCwd,
@@ -772,6 +879,7 @@ export class Agent {
772
879
  },
773
880
  });
774
881
  summary = getToolSummary(name, args, resultText);
882
+ trackChildObservation(name, args, resultText, spawnedChildIdsThisTurn, waitedChildIdsThisTurn);
775
883
  }
776
884
  else if (isLocal) {
777
885
  resultText = await this.executeLocalTool(name, args);
@@ -1104,7 +1212,7 @@ export class Agent {
1104
1212
  try {
1105
1213
  const res = await fetch(url, {
1106
1214
  headers: {
1107
- 'User-Agent': 'Mozilla/5.0 (compatible; BrainRouterCLI/0.3.5)'
1215
+ 'User-Agent': 'Mozilla/5.0 (compatible; BrainRouterCLI/0.3.7)'
1108
1216
  }
1109
1217
  });
1110
1218
  if (!res.ok) {
@@ -1712,7 +1820,7 @@ async function runWebSearch(query, maxResults) {
1712
1820
  }
1713
1821
  try {
1714
1822
  const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
1715
- const res = await fetch(url, { headers: { 'User-Agent': 'BrainRouterCLI/0.3.5' } });
1823
+ const res = await fetch(url, { headers: { 'User-Agent': 'BrainRouterCLI/0.3.7' } });
1716
1824
  if (!res.ok) {
1717
1825
  return `web_search failed: DuckDuckGo returned ${res.status} ${res.statusText}.`;
1718
1826
  }
@@ -2267,7 +2375,15 @@ export function buildChatCompletionPayload(config, messages, tools, options = {}
2267
2375
  return body;
2268
2376
  }
2269
2377
  export async function callOpenAI(config, messages, tools, options = {}) {
2270
- const endpoint = config.endpoint || 'https://api.openai.com/v1';
2378
+ // Normalize the endpoint to a base URL (everything UP TO `/chat/completions`
2379
+ // exclusive). Earlier callers stored the full chat-completions URL in
2380
+ // `config.endpoint` (e.g. "https://api.openai.com/v1/chat/completions")
2381
+ // because the in-terminal wizard's provider catalog wrote the full path.
2382
+ // We then re-append `/chat/completions` below, producing a duplicate
2383
+ // `/chat/completions/chat/completions` and a 404. Strip the suffix
2384
+ // defensively so both shapes (full URL or base URL) work.
2385
+ const rawEndpoint = config.endpoint || 'https://api.openai.com/v1';
2386
+ const endpoint = rawEndpoint.replace(/\/+$/, '').replace(/\/chat\/completions$/, '');
2271
2387
  let apiKey = config.apiKey || process.env.OPENAI_API_KEY || '';
2272
2388
  const isLocal = endpoint.includes('localhost') || endpoint.includes('127.0.0.1');
2273
2389
  if (!apiKey && !isLocal) {
@@ -39,6 +39,12 @@ export interface BannerInputs {
39
39
  /** Version override (test fixture). */
40
40
  version?: string;
41
41
  }
42
+ export interface DisplayedMcpState {
43
+ profile: string;
44
+ transport: string;
45
+ online: boolean;
46
+ identity: 'brainrouter' | 'third-party' | 'unknown';
47
+ }
42
48
  /**
43
49
  * Pure renderer — returns the box as a single newline-joined string with
44
50
  * ANSI sequences from `theme`. Caller appends the trailing newline.
@@ -57,4 +63,18 @@ export declare function buildBannerInputs(config: Config, agent: {
57
63
  }, mcpClient: {
58
64
  isConnected: () => boolean;
59
65
  getIdentity?: () => 'brainrouter' | 'third-party' | 'unknown';
66
+ getStatus?: (serverId: string) => {
67
+ status: string;
68
+ identity: 'brainrouter' | 'third-party' | 'unknown';
69
+ } | undefined;
70
+ getActiveBrainrouterServerId?: () => string | undefined;
60
71
  }): BannerInputs;
72
+ export declare function resolveDisplayedMcpState(config: Config, mcpClient: {
73
+ isConnected: () => boolean;
74
+ getIdentity?: () => 'brainrouter' | 'third-party' | 'unknown';
75
+ getStatus?: (serverId: string) => {
76
+ status: string;
77
+ identity: 'brainrouter' | 'third-party' | 'unknown';
78
+ } | undefined;
79
+ getActiveBrainrouterServerId?: () => string | undefined;
80
+ }): DisplayedMcpState;
@@ -9,7 +9,7 @@ import { BOX } from './theme.js';
9
9
  * (chalk title + workspace line + connecting-to line) with a single visually
10
10
  * scannable block:
11
11
  *
12
- * ╭─ 🧠 BrainRouter CLI 0.3.5 ──────────────────────────────╮
12
+ * ╭─ 🧠 BrainRouter CLI 0.3.7 ──────────────────────────────╮
13
13
  * │ workspace BrainRouter · c5b8c12d │
14
14
  * │ mcp local-http · http · online │
15
15
  * │ workflow cli-shell-redesign (in-progress) │
@@ -26,10 +26,19 @@ import { BOX } from './theme.js';
26
26
  * The function returns a single string with embedded ANSI; the caller prints
27
27
  * it once. Pure-function so tests can assert against the rendered output.
28
28
  */
29
- const VERSION = '0.3.6';
29
+ const VERSION = '0.3.7';
30
30
  const TITLE = '🧠 BrainRouter CLI';
31
- const MIN_WIDTH = 56;
31
+ // Width floor for the BOXED banner. Below this we fall through to the
32
+ // `renderPlainBanner` plaintext format. Was 56 — that caused the box to
33
+ // overflow on terminals narrower than 58 cols (each row wrapped to
34
+ // multiple terminal rows with broken border alignment). 38 fits a
35
+ // 40-col terminal (the smallest realistic phone / split-pane width).
36
+ const MIN_BOX_WIDTH = 38;
32
37
  const MAX_WIDTH = 100;
38
+ // Below this width we skip the box entirely and render the rows as
39
+ // "label: value" lines. The boxed format with horizontal borders +
40
+ // title is meaningless when each border row wraps.
41
+ const PLAIN_TEXT_THRESHOLD = 38;
33
42
  function shortHash(absPath) {
34
43
  return crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 8);
35
44
  }
@@ -123,8 +132,14 @@ export function renderBanner(inputs, theme) {
123
132
  // Inner width is the widest "label + 2 spaces + value", clamped.
124
133
  const naturalInner = rows.reduce((w, r) => Math.max(w, labelWidth + 2 + r.value.length), titleText.length + 4);
125
134
  const targetCols = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : MAX_WIDTH;
135
+ // Below the plaintext threshold the boxed layout is hostile (each
136
+ // border row wraps and looks chaotic). Fall back to a label:value
137
+ // text dump that the terminal can wrap naturally.
138
+ if (targetCols < PLAIN_TEXT_THRESHOLD) {
139
+ return renderPlainBanner(titleText, rows, theme);
140
+ }
126
141
  // Reserve 2 columns for the side borders.
127
- const innerWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.min(naturalInner, targetCols - 2)));
142
+ const innerWidth = Math.max(MIN_BOX_WIDTH, Math.min(MAX_WIDTH, Math.min(naturalInner, targetCols - 2)));
128
143
  const top = (() => {
129
144
  // ╭─ <title> ──╮ — title sits inline at the top border.
130
145
  const titlePiece = ` ${titleText} `;
@@ -140,6 +155,17 @@ export function renderBanner(inputs, theme) {
140
155
  const bottom = theme.primary(BOX.bottomLeft + BOX.horizontal.repeat(innerWidth) + BOX.bottomRight);
141
156
  return [top, ...bodyLines, bottom].join('\n');
142
157
  }
158
+ /**
159
+ * Compact label:value text banner — used on terminals narrower than
160
+ * PLAIN_TEXT_THRESHOLD cols where the boxed layout's border rows
161
+ * would wrap and look broken. Same information, no chrome.
162
+ */
163
+ function renderPlainBanner(titleText, rows, theme) {
164
+ const labelWidth = rows.reduce((w, r) => Math.max(w, r.label.length), 0);
165
+ const headerLine = theme.primary(titleText);
166
+ const bodyLines = rows.map((row) => theme.muted(padRight(row.label, labelWidth) + ' ') + theme.plain(row.value));
167
+ return [headerLine, ...bodyLines].join('\n');
168
+ }
143
169
  /**
144
170
  * Convenience: assemble the inputs from live agent + config + workspace
145
171
  * state. Pure read; no side effects. Anything that throws while reading the
@@ -147,12 +173,7 @@ export function renderBanner(inputs, theme) {
147
173
  * workspace doesn't crash the banner.
148
174
  */
149
175
  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');
176
+ const displayedMcp = resolveDisplayedMcpState(config, mcpClient);
156
177
  let workflow;
157
178
  let lastUsedWorkflow;
158
179
  try {
@@ -186,10 +207,10 @@ export function buildBannerInputs(config, agent, mcpClient) {
186
207
  catch { /* ignore — no goal yet */ }
187
208
  return {
188
209
  workspaceRoot: agent.workspaceRoot,
189
- mcpProfile: profile,
190
- mcpTransport: transport,
191
- mcpOnline: mcpClient.isConnected(),
192
- mcpIdentity,
210
+ mcpProfile: displayedMcp.profile,
211
+ mcpTransport: displayedMcp.transport,
212
+ mcpOnline: displayedMcp.online,
213
+ mcpIdentity: displayedMcp.identity,
193
214
  sessionKey: agent.sessionKey,
194
215
  model: agent.getModel(),
195
216
  workflow,
@@ -197,3 +218,15 @@ export function buildBannerInputs(config, agent, mcpClient) {
197
218
  goal,
198
219
  };
199
220
  }
221
+ export function resolveDisplayedMcpState(config, mcpClient) {
222
+ const liveBrain = mcpClient.getActiveBrainrouterServerId?.();
223
+ const profile = liveBrain || config.activeServer;
224
+ const server = config.servers[profile];
225
+ const status = profile ? mcpClient.getStatus?.(profile) : undefined;
226
+ return {
227
+ profile,
228
+ transport: server?.type ?? 'unknown',
229
+ online: status ? status.status === 'connected' : mcpClient.isConnected(),
230
+ identity: status?.identity ?? server?.identity ?? mcpClient.getIdentity?.() ?? 'unknown',
231
+ };
232
+ }