@kinqs/brainrouter-cli 0.3.7 → 0.3.8

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 (59) hide show
  1. package/changelog/0.2.0.md +15 -0
  2. package/changelog/0.3.0.md +20 -0
  3. package/changelog/0.3.1.md +22 -0
  4. package/changelog/0.3.2.md +15 -0
  5. package/changelog/0.3.3.md +19 -0
  6. package/changelog/0.3.4.md +20 -0
  7. package/changelog/0.3.5.md +9 -0
  8. package/changelog/0.3.6.md +9 -0
  9. package/changelog/0.3.7.md +20 -0
  10. package/changelog/0.3.8.md +30 -0
  11. package/changelog/README.md +41 -0
  12. package/dist/agent/agent.d.ts +22 -0
  13. package/dist/agent/agent.js +259 -82
  14. package/dist/agent/toolCallRecovery.d.ts +57 -0
  15. package/dist/agent/toolCallRecovery.js +130 -0
  16. package/dist/agent/toolSafety.d.ts +17 -0
  17. package/dist/agent/toolSafety.js +102 -0
  18. package/dist/cli/banner.js +2 -2
  19. package/dist/cli/cliPrompt.js +65 -0
  20. package/dist/cli/commands/config.js +1 -1
  21. package/dist/cli/commands/mcp.d.ts +1 -1
  22. package/dist/cli/commands/mcp.js +29 -7
  23. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  24. package/dist/cli/commands/mcpInstall.js +87 -0
  25. package/dist/cli/commands/orchestration.js +33 -0
  26. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  27. package/dist/cli/commands/releaseNotes.js +109 -0
  28. package/dist/cli/commands/schedule.d.ts +18 -0
  29. package/dist/cli/commands/schedule.js +189 -0
  30. package/dist/cli/commands/ui.js +2 -2
  31. package/dist/cli/ink/Picker.d.ts +6 -0
  32. package/dist/cli/ink/Picker.js +41 -6
  33. package/dist/cli/ink/runChat.js +112 -1
  34. package/dist/cli/ink/toolFormat.d.ts +11 -9
  35. package/dist/cli/ink/toolFormat.js +42 -16
  36. package/dist/cli/repl.d.ts +1 -1
  37. package/dist/cli/repl.js +9 -2
  38. package/dist/config/config.d.ts +1 -1
  39. package/dist/index.js +10 -1
  40. package/dist/memory/briefing.js +4 -4
  41. package/dist/orchestration/tools.d.ts +95 -2
  42. package/dist/orchestration/tools.js +119 -4
  43. package/dist/prompt/systemPrompt.js +5 -4
  44. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  45. package/dist/runtime/anthropicAdapter.js +293 -0
  46. package/dist/runtime/cronParser.d.ts +23 -0
  47. package/dist/runtime/cronParser.js +122 -0
  48. package/dist/runtime/mcpClient.js +1 -1
  49. package/dist/runtime/mcpPool.d.ts +8 -0
  50. package/dist/runtime/mcpPool.js +19 -0
  51. package/dist/runtime/mcpUtils.d.ts +14 -0
  52. package/dist/runtime/mcpUtils.js +23 -0
  53. package/dist/runtime/scheduleTicker.d.ts +33 -0
  54. package/dist/runtime/scheduleTicker.js +99 -0
  55. package/dist/runtime/vendorSnippets.d.ts +45 -0
  56. package/dist/runtime/vendorSnippets.js +153 -0
  57. package/dist/state/scheduleStore.d.ts +37 -0
  58. package/dist/state/scheduleStore.js +64 -0
  59. package/package.json +7 -4
@@ -0,0 +1,130 @@
1
+ // Strict tool-call recovery helpers (0.3.8-I4 / roadmap §8).
2
+ //
3
+ // Adapted from deer-flow/backend/packages/harness/deerflow/agents/middlewares/
4
+ // dangling_tool_call_middleware.py — same pattern: detect tool_calls that
5
+ // never received a paired tool_result and inject synthetic placeholders so
6
+ // strict OpenAI-compatible validators don't reject the next request.
7
+ //
8
+ // These helpers are intentionally pure (no agent.ts imports) so they can be
9
+ // unit-tested in isolation and reused if another runtime grows similar needs.
10
+ /**
11
+ * Drop duplicate tool_call ids inside a single assistant response. Keeps the
12
+ * LAST occurrence (closest to the model's final intent). Calls without a
13
+ * string id are passed through unchanged — the orphan safety net will catch
14
+ * them later.
15
+ *
16
+ * `onDuplicate` is invoked once per dropped duplicate so callers can log a
17
+ * warning without coupling this module to a logger.
18
+ */
19
+ export function dedupeToolCalls(calls, onDuplicate) {
20
+ if (!Array.isArray(calls) || calls.length === 0)
21
+ return [];
22
+ const seen = new Set();
23
+ const out = [];
24
+ for (let i = calls.length - 1; i >= 0; i--) {
25
+ const c = calls[i];
26
+ const id = c?.id;
27
+ if (typeof id !== 'string' || id === '') {
28
+ out.push(c);
29
+ continue;
30
+ }
31
+ if (seen.has(id)) {
32
+ onDuplicate?.(id, i);
33
+ continue;
34
+ }
35
+ seen.add(id);
36
+ out.push(c);
37
+ }
38
+ return out.reverse();
39
+ }
40
+ /**
41
+ * Try-parse `tool_call.function.arguments`. On parse failure return a
42
+ * structured error string instead of throwing, so the caller can attach a
43
+ * synthetic tool_result that the next model turn can read.
44
+ */
45
+ export function parseArgumentsOrError(call) {
46
+ const raw = call?.function?.arguments;
47
+ if (raw == null)
48
+ return { args: {}, rawArguments: '' };
49
+ if (typeof raw !== 'string') {
50
+ // Provider already parsed it for us.
51
+ return {
52
+ args: (raw && typeof raw === 'object') ? raw : {},
53
+ rawArguments: (() => { try {
54
+ return JSON.stringify(raw);
55
+ }
56
+ catch {
57
+ return String(raw);
58
+ } })(),
59
+ };
60
+ }
61
+ if (raw.trim() === '')
62
+ return { args: {}, rawArguments: raw };
63
+ try {
64
+ const parsed = JSON.parse(raw);
65
+ return {
66
+ args: (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {},
67
+ rawArguments: raw,
68
+ };
69
+ }
70
+ catch (e) {
71
+ const msg = e?.message ?? String(e);
72
+ // Keep the raw arguments visible — the model often needs to see exactly
73
+ // what it produced to self-correct (e.g. trailing comma, missing quote).
74
+ const previewedRaw = raw.length > 400 ? `${raw.slice(0, 400)}…[truncated ${raw.length - 400} chars]` : raw;
75
+ return {
76
+ args: {},
77
+ error: `Tool argument JSON was malformed: ${msg}. Raw arguments emitted by the model: ${previewedRaw}. Re-issue the tool call with valid JSON arguments.`,
78
+ rawArguments: raw,
79
+ };
80
+ }
81
+ }
82
+ /**
83
+ * For every tool_call in `calls` that has no matching tool_result in
84
+ * `results`, build a synthetic tool message so the next LLM request stays
85
+ * well-formed (OpenAI strictly requires tool_call ↔ tool_result pairing).
86
+ *
87
+ * IMPORTANT: the synthetic `content` MUST start with `ERROR:` and be a plain
88
+ * string. The agent runtime's R1 child-drain guardrail tracks spawned
89
+ * children by `parseJsonObject(resultText)` on tool results — if the
90
+ * synthetic envelope parses as JSON with an `id` field, the guardrail would
91
+ * incorrectly think a child agent was spawned and try to wait on it.
92
+ */
93
+ export function synthesizeOrphanResults(calls, results) {
94
+ if (!Array.isArray(calls) || calls.length === 0)
95
+ return [];
96
+ const have = new Set(results.map((r) => r.tool_call_id));
97
+ const synthetic = [];
98
+ for (const c of calls) {
99
+ const id = c?.id;
100
+ if (typeof id !== 'string' || id === '')
101
+ continue;
102
+ if (have.has(id))
103
+ continue;
104
+ synthetic.push({
105
+ role: 'tool',
106
+ tool_call_id: id,
107
+ name: c?.function?.name ?? 'unknown',
108
+ content: 'ERROR: tool call orphaned by model; no execution recorded. Re-issue the tool call if you still need this work done.',
109
+ isError: true,
110
+ });
111
+ }
112
+ return synthetic;
113
+ }
114
+ /**
115
+ * Use the caller's existing `normalizeToolName` to surface a "did you mean"
116
+ * suggestion when the LLM emits a tool name that doesn't exist as-is but
117
+ * normalizes to a real registered tool. Tolerates the single-underscore
118
+ * `mcp_<server>_<tool>` prefix (R5 convention) since `normalizeToolName`
119
+ * matches by flattened form.
120
+ */
121
+ export function suggestSimilarToolName(raw, candidates, normalize) {
122
+ const trimmed = (raw ?? '').trim();
123
+ if (!trimmed || candidates.includes(trimmed))
124
+ return undefined;
125
+ const suggestion = normalize(trimmed, candidates);
126
+ if (suggestion && suggestion !== trimmed && candidates.includes(suggestion)) {
127
+ return suggestion;
128
+ }
129
+ return undefined;
130
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * True iff `toolName` is on the conservative parallel-safe whitelist.
3
+ * Accepts both the bare local tool name (`read_file`) and the MCP-prefixed
4
+ * form (`mcp_brainrouter_memory_recall`). Anything else — including any
5
+ * unknown tool name — returns false so the caller falls back to safe
6
+ * serial execution.
7
+ */
8
+ export declare function isParallelSafe(toolName: string): boolean;
9
+ /** Companion to `isParallelSafe` — true iff the name resolves to a known MCP read tool. */
10
+ export declare function isMcpReadTool(toolName: string): boolean;
11
+ /**
12
+ * Kill switch: `BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS=false` (or `0`/`off`/`no`)
13
+ * forces every batch back to strict serial execution — the pre-R4 shape.
14
+ * Useful when debugging an issue and you want to rule out concurrency, or
15
+ * when running against an LLM provider that rate-limits tool dispatch.
16
+ */
17
+ export declare function parallelExecutionEnabled(): boolean;
@@ -0,0 +1,102 @@
1
+ // 0.3.8-R4 — Single source of truth for which tool calls are safe to
2
+ // dispatch concurrently within one LLM response.
3
+ //
4
+ // Pre-R4 the runtime executed every tool call from one assistant message
5
+ // strictly serially. That's safe but it's pure latency loss for the common
6
+ // case of "read 5 files in one turn" — none of those reads share state or
7
+ // depend on each other's results. Writes and shell commands still need to
8
+ // serialize to preserve causality.
9
+ //
10
+ // `isParallelSafe(toolName)` is the conservative whitelist. Anything not on
11
+ // the list is treated as serial — the failure mode is "we ran something
12
+ // sequentially that could have been concurrent," which is the same
13
+ // performance the pre-R4 code shipped with. Adding tools here is the only
14
+ // way to opt them in.
15
+ /**
16
+ * Local read-only tools whose execution is independent and has no
17
+ * observable side effect on the workspace, on child-session state, or on
18
+ * the agent's own bookkeeping. These can run concurrently within a single
19
+ * LLM response.
20
+ *
21
+ * Explicitly EXCLUDED (must stay serial):
22
+ * - write_file / edit_file / apply_patch / run_command — workspace mutation.
23
+ * - spawn_agent / spawn_agents / task_agent / delegate_agent
24
+ * / wait_agent / wait_agents / close_agent / route_agent
25
+ * / read_agent_transcript — orchestration / child-session mutation.
26
+ * (R1's child-drain guardrail tracks every spawn/wait one-by-one;
27
+ * running them in parallel would let bookkeeping diverge.)
28
+ * - update_plan / goal_complete / goal_blocked — session state mutation.
29
+ * - ask_user_choice — interactive picker; must not interleave with other UI.
30
+ * - list_agents — reads orchestration state but classified serial out of
31
+ * caution; cheap and rarely batched.
32
+ */
33
+ const PARALLEL_SAFE_LOCAL_TOOLS = new Set([
34
+ 'read_file',
35
+ 'list_dir',
36
+ 'grep_search',
37
+ 'glob_files',
38
+ 'fetch_url',
39
+ 'web_search',
40
+ ]);
41
+ /**
42
+ * MCP read tools — bare tool names (without the `mcp_<server>_` prefix)
43
+ * that BrainRouter knows to be read-only. The pool normalises any legacy
44
+ * double-underscore emissions to the canonical single-underscore
45
+ * `mcp_<server>_<tool>` form at its boundary (0.3.8-R5), so the matcher
46
+ * here only deals with that one shape.
47
+ */
48
+ const PARALLEL_SAFE_MCP_READ_TOOLS = new Set([
49
+ 'memory_recall',
50
+ 'memory_search',
51
+ 'memory_file_history',
52
+ 'memory_task_state',
53
+ 'memory_contradictions',
54
+ 'memory_inspect',
55
+ 'memory_list_records',
56
+ ]);
57
+ /**
58
+ * True iff `toolName` is on the conservative parallel-safe whitelist.
59
+ * Accepts both the bare local tool name (`read_file`) and the MCP-prefixed
60
+ * form (`mcp_brainrouter_memory_recall`). Anything else — including any
61
+ * unknown tool name — returns false so the caller falls back to safe
62
+ * serial execution.
63
+ */
64
+ export function isParallelSafe(toolName) {
65
+ if (!toolName)
66
+ return false;
67
+ if (PARALLEL_SAFE_LOCAL_TOOLS.has(toolName))
68
+ return true;
69
+ const bare = stripMcpPrefix(toolName);
70
+ if (bare && PARALLEL_SAFE_MCP_READ_TOOLS.has(bare))
71
+ return true;
72
+ return false;
73
+ }
74
+ /** Companion to `isParallelSafe` — true iff the name resolves to a known MCP read tool. */
75
+ export function isMcpReadTool(toolName) {
76
+ const bare = stripMcpPrefix(toolName);
77
+ return !!bare && PARALLEL_SAFE_MCP_READ_TOOLS.has(bare);
78
+ }
79
+ function stripMcpPrefix(name) {
80
+ // Canonical single-underscore shape: mcp_<server>_<tool>. Server names
81
+ // may contain underscores so we suffix-match against known bare tools
82
+ // instead of guessing where the server segment ends.
83
+ if (name.startsWith('mcp_')) {
84
+ for (const known of PARALLEL_SAFE_MCP_READ_TOOLS) {
85
+ if (name.endsWith('_' + known))
86
+ return known;
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+ /**
92
+ * Kill switch: `BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS=false` (or `0`/`off`/`no`)
93
+ * forces every batch back to strict serial execution — the pre-R4 shape.
94
+ * Useful when debugging an issue and you want to rule out concurrency, or
95
+ * when running against an LLM provider that rate-limits tool dispatch.
96
+ */
97
+ export function parallelExecutionEnabled() {
98
+ const raw = (process.env.BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS ?? '').trim().toLowerCase();
99
+ if (raw === 'false' || raw === '0' || raw === 'off' || raw === 'no')
100
+ return false;
101
+ return true;
102
+ }
@@ -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.7 ──────────────────────────────╮
12
+ * ╭─ 🧠 BrainRouter CLI 0.3.8 ──────────────────────────────╮
13
13
  * │ workspace BrainRouter · c5b8c12d │
14
14
  * │ mcp local-http · http · online │
15
15
  * │ workflow cli-shell-redesign (in-progress) │
@@ -26,7 +26,7 @@ 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.7';
29
+ const VERSION = '0.3.8';
30
30
  const TITLE = '🧠 BrainRouter CLI';
31
31
  // Width floor for the BOXED banner. Below this we fall through to the
32
32
  // `renderPlainBanner` plaintext format. Was 56 — that caused the box to
@@ -1,4 +1,5 @@
1
1
  import readline from 'node:readline';
2
+ import { getAmbientChat } from './ink/ambientChat.js';
2
3
  /**
3
4
  * Shared bridge between the REPL's readline interface and modules outside
4
5
  * repl.ts that need to (a) write above the prompt without scrambling input,
@@ -33,6 +34,9 @@ export function isPickerActive() { return pickerActive; }
33
34
  * (e.g. piped non-interactive runs).
34
35
  */
35
36
  export function askYesNo(question, defaultValue = false) {
37
+ if (getAmbientChat() && process.stdin.isTTY) {
38
+ return runInkYesNo(question, defaultValue);
39
+ }
36
40
  if (!activeReadline || !process.stdin.isTTY) {
37
41
  return Promise.resolve(defaultValue);
38
42
  }
@@ -235,8 +239,69 @@ export function askChoice(question, options, opts = {}) {
235
239
  return Promise.reject(new NoTTYError('ask_user_choice requires an interactive TTY (no readline interface is active or stdin is not a TTY). ' +
236
240
  'Fall back to deciding yourself based on the available context, and state which option you picked and why in your reply.'));
237
241
  }
242
+ if (getAmbientChat()) {
243
+ return runInkChoice(question, options, opts);
244
+ }
238
245
  return runPicker(question, options, opts);
239
246
  }
247
+ async function runInkYesNo(question, defaultValue) {
248
+ const { runPicker } = await import('./ink/runPicker.js');
249
+ const result = await runPicker({
250
+ title: question,
251
+ badge: 'Confirm',
252
+ rows: [
253
+ { id: 'yes', label: 'Yes', description: 'Allow this action' },
254
+ { id: 'no', label: 'No', description: 'Do not allow this action' },
255
+ ],
256
+ initialCursor: defaultValue ? 0 : 1,
257
+ allowOther: false,
258
+ });
259
+ if (result.kind !== 'pick')
260
+ return defaultValue;
261
+ return result.id === 'yes';
262
+ }
263
+ async function runInkChoice(question, options, opts) {
264
+ const { runPicker } = await import('./ink/runPicker.js');
265
+ const rows = options.map((option, i) => ({
266
+ id: `choice:${i}`,
267
+ label: option.label,
268
+ description: option.description,
269
+ }));
270
+ const result = await runPicker({
271
+ title: question,
272
+ badge: opts.header,
273
+ rows,
274
+ initialCursor: opts.initialCursor,
275
+ allowOther: true,
276
+ otherLabel: OTHER_LABEL,
277
+ otherDescription: OTHER_DESCRIPTION,
278
+ prefilledOther: opts.prefilledOther,
279
+ multiSelect: !!opts.multiSelect,
280
+ onCursorChange: opts.onCursorChange
281
+ ? (_id, index) => {
282
+ opts.onCursorChange?.(index);
283
+ return undefined;
284
+ }
285
+ : undefined,
286
+ });
287
+ if (result.kind === 'cancelled') {
288
+ throw new CancelledChoiceError();
289
+ }
290
+ if (result.kind === 'other') {
291
+ return result.text;
292
+ }
293
+ if (result.kind === 'multi') {
294
+ const answers = result.ids.map((id) => {
295
+ const idx = Number(id.slice('choice:'.length));
296
+ return options[idx]?.label;
297
+ }).filter((label) => !!label);
298
+ if (result.otherText)
299
+ answers.push(result.otherText);
300
+ return answers;
301
+ }
302
+ const idx = Number(result.id.slice('choice:'.length));
303
+ return options[idx]?.label ?? options[0].label;
304
+ }
240
305
  function runPicker(question, options, opts) {
241
306
  return new Promise((resolve, reject) => {
242
307
  const rl = activeReadline;
@@ -313,7 +313,7 @@ async function addMcpProfile(ctx, theme) {
313
313
  const nameRes = await promptText({
314
314
  theme,
315
315
  title: 'New MCP server — name',
316
- subtitle: 'Short identifier. Used in tool prefixes: mcp__<name>__<tool>.',
316
+ subtitle: 'Short identifier. Used in tool prefixes: mcp_<name>_<tool>.',
317
317
  badge: 'MCP',
318
318
  placeholder: 'github, filesystem, my-brain, …',
319
319
  validate: (raw) => {
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * /mcp — alias for /mcp list
7
7
  * /mcp list — every configured profile + per-server status
8
- * /mcp tools [server] — MCP tools grouped by `mcp__<server>__*`
8
+ * /mcp tools [server] — MCP tools grouped by `mcp_<server>_*`
9
9
  * namespace; pass a server id to scope
10
10
  * /mcp connect <name> — connect a configured server that's idle/offline
11
11
  * /mcp disconnect <name> — close one server's transport (config preserved)
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * /mcp — alias for /mcp list
7
7
  * /mcp list — every configured profile + per-server status
8
- * /mcp tools [server] — MCP tools grouped by `mcp__<server>__*`
8
+ * /mcp tools [server] — MCP tools grouped by `mcp_<server>_*`
9
9
  * namespace; pass a server id to scope
10
10
  * /mcp connect <name> — connect a configured server that's idle/offline
11
11
  * /mcp disconnect <name> — close one server's transport (config preserved)
@@ -22,6 +22,7 @@ import { resolveIdentityFromConfig } from '../../runtime/mcpClient.js';
22
22
  import { selectMcpServerIds } from '../../runtime/mcpPool.js';
23
23
  import { buildBannerInputs, renderBanner } from '../banner.js';
24
24
  import { resolveTheme } from '../theme.js';
25
+ import { runMcpInstall } from './mcpInstall.js';
25
26
  export async function tryHandleMcpCommand(ctx) {
26
27
  const { command, args, mcpClient, config } = ctx;
27
28
  if (command !== '/mcp')
@@ -42,12 +43,28 @@ export async function tryHandleMcpCommand(ctx) {
42
43
  const res = await mcpClient.listTools();
43
44
  const allTools = res.tools || [];
44
45
  spinner.succeed(chalk.green(`${allTools.length} tools across ${connected.length} server${connected.length === 1 ? '' : 's'}`));
45
- // Pool tools are exposed as `mcp__<serverId>__<rawTool>`. Group by serverId.
46
+ // Pool tools are exposed as `mcp_<serverId>_<rawTool>`. Group by serverId.
47
+ const knownIds = new Set(connected.map((s) => s.serverId));
46
48
  const byServer = {};
47
49
  for (const t of allTools) {
48
- const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(t.name);
49
- const serverId = m?.[1] ?? '__unknown__';
50
- const raw = m?.[2] ?? t.name;
50
+ let serverId = '__unknown__';
51
+ let raw = t.name;
52
+ if (t.name.startsWith('mcp_')) {
53
+ const rest = t.name.slice('mcp_'.length);
54
+ // Server ids may contain underscores; match the longest known id.
55
+ const id = [...knownIds].sort((a, b) => b.length - a.length).find((k) => rest.startsWith(`${k}_`));
56
+ if (id) {
57
+ serverId = id;
58
+ raw = rest.slice(id.length + 1);
59
+ }
60
+ else {
61
+ const idx = rest.indexOf('_');
62
+ if (idx > 0) {
63
+ serverId = rest.slice(0, idx);
64
+ raw = rest.slice(idx + 1);
65
+ }
66
+ }
67
+ }
51
68
  if (onlyServer && serverId !== onlyServer)
52
69
  continue;
53
70
  (byServer[serverId] ||= []).push(raw);
@@ -64,7 +81,7 @@ export async function tryHandleMcpCommand(ctx) {
64
81
  const identTag = formatIdentityTag(ident);
65
82
  console.log(` ${chalk.bold.green(id)} ${identTag} (${byServer[id].length})`);
66
83
  for (const name of byServer[id].sort()) {
67
- console.log(` ${chalk.gray('•')} ${name} ${chalk.gray(`mcp__${id}__${name}`)}`);
84
+ console.log(` ${chalk.gray('•')} ${name} ${chalk.gray(`mcp_${id}_${name}`)}`);
68
85
  }
69
86
  }
70
87
  }
@@ -200,6 +217,11 @@ export async function tryHandleMcpCommand(ctx) {
200
217
  }
201
218
  return true;
202
219
  }
220
+ if (sub === 'install') {
221
+ const result = runMcpInstall(args.slice(1), config);
222
+ console.log(result.output);
223
+ return true;
224
+ }
203
225
  if (sub === 'disconnect') {
204
226
  if (!targetName) {
205
227
  console.log(chalk.red('\nUsage: /mcp disconnect <name>\n'));
@@ -218,7 +240,7 @@ export async function tryHandleMcpCommand(ctx) {
218
240
  }
219
241
  return true;
220
242
  }
221
- console.log(chalk.red(`\nUnknown /mcp subcommand "${sub}". Usage: /mcp list | /mcp tools [server] | /mcp connect <name> | /mcp disconnect <name> | /mcp reconnect [name]\n`));
243
+ console.log(chalk.red(`\nUnknown /mcp subcommand "${sub}". Usage: /mcp list | /mcp tools [server] | /mcp connect <name> | /mcp disconnect <name> | /mcp reconnect [name] | /mcp install <vendor>|list\n`));
222
244
  return true;
223
245
  }
224
246
  function formatIdentityTag(identity) {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `/mcp install` — generates per-vendor MCP config snippets for non-CLI
3
+ * hosts (Claude Desktop, Cursor, Windsurf, VS Code Continue, Zed, Cline).
4
+ *
5
+ * Pattern: print-only. We do NOT write to vendor config files — the user
6
+ * pastes the block themselves. Direct-write is a future enhancement
7
+ * (roadmap: tracked under post-0.4.0 polish; intentionally not a follow-up).
8
+ *
9
+ * Adapted from semble's per-agent install docs pattern
10
+ * (openSrc/semble/src/semble/agents/) — one focused entry per vendor.
11
+ */
12
+ import type { Config } from '../../config/config.js';
13
+ export interface RenderResult {
14
+ ok: boolean;
15
+ output: string;
16
+ }
17
+ export interface RunOpts {
18
+ platform?: NodeJS.Platform;
19
+ }
20
+ export declare function runMcpInstall(args: string[], config: Config, opts?: RunOpts): RenderResult;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `/mcp install` — generates per-vendor MCP config snippets for non-CLI
3
+ * hosts (Claude Desktop, Cursor, Windsurf, VS Code Continue, Zed, Cline).
4
+ *
5
+ * Pattern: print-only. We do NOT write to vendor config files — the user
6
+ * pastes the block themselves. Direct-write is a future enhancement
7
+ * (roadmap: tracked under post-0.4.0 polish; intentionally not a follow-up).
8
+ *
9
+ * Adapted from semble's per-agent install docs pattern
10
+ * (openSrc/semble/src/semble/agents/) — one focused entry per vendor.
11
+ */
12
+ import chalk from 'chalk';
13
+ import { displayPath, getVendor, listVendors, renderSnippet, VENDORS } from '../../runtime/vendorSnippets.js';
14
+ /**
15
+ * Resolve the active BrainRouter profile from config. Returns null when
16
+ * the user hasn't logged in yet — caller prints a `/login` hint.
17
+ */
18
+ function resolveActiveBrainrouter(config) {
19
+ const name = config.activeServer;
20
+ if (!name)
21
+ return null;
22
+ const profile = config.servers?.[name];
23
+ if (!profile)
24
+ return null;
25
+ if (profile.type !== 'http')
26
+ return null;
27
+ const url = profile.url?.trim();
28
+ const apiKey = profile.apiKey?.trim();
29
+ if (!url || !apiKey)
30
+ return null;
31
+ return { url, apiKey };
32
+ }
33
+ export function runMcpInstall(args, config, opts = {}) {
34
+ const platform = opts.platform ?? process.platform;
35
+ const sub = (args[0] ?? '').toLowerCase();
36
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
37
+ return {
38
+ ok: true,
39
+ output: `${chalk.bold('Usage')}\n` +
40
+ ` /mcp install list — list supported vendors\n` +
41
+ ` /mcp install <vendor> — print paste-ready snippet for one vendor\n\n` +
42
+ `Vendors: ${listVendors().map((v) => v.id).join(', ')}\n`,
43
+ };
44
+ }
45
+ if (sub === 'list') {
46
+ const lines = [chalk.bold('\nSupported MCP hosts')];
47
+ for (const v of listVendors()) {
48
+ const p = displayPath(v.configPath(platform), platform);
49
+ lines.push(` ${chalk.bold.cyan(v.id.padEnd(18))} ${chalk.gray(v.label.padEnd(28))} ${chalk.gray(p)}`);
50
+ }
51
+ lines.push('', chalk.gray(`Run "/mcp install <id>" for a paste-ready snippet.`), '');
52
+ return { ok: true, output: lines.join('\n') };
53
+ }
54
+ const entry = getVendor(sub);
55
+ if (!entry) {
56
+ return {
57
+ ok: false,
58
+ output: chalk.red(`\nUnknown vendor "${sub}".\n`) +
59
+ chalk.gray(`Known: ${Object.keys(VENDORS).join(', ')}\n`),
60
+ };
61
+ }
62
+ const active = resolveActiveBrainrouter(config);
63
+ if (!active) {
64
+ return {
65
+ ok: false,
66
+ output: chalk.red('\nNo active BrainRouter profile with URL + API key.\n') +
67
+ chalk.gray('Run `/login` to configure one, then re-run this command.\n'),
68
+ };
69
+ }
70
+ const snippet = renderSnippet(entry, { url: active.url, apiKey: active.apiKey });
71
+ const configPath = displayPath(entry.configPath(platform), platform);
72
+ const out = [];
73
+ out.push('');
74
+ out.push(chalk.bold.cyan(`${entry.label} (${entry.id})`));
75
+ out.push(chalk.gray(`Config file: ${configPath}`));
76
+ if (entry.note)
77
+ out.push(chalk.gray(`Note: ${entry.note}`));
78
+ out.push('');
79
+ out.push(chalk.yellow('⚠ This block contains your live API key — paste into your vendor config and do not commit.'));
80
+ out.push('');
81
+ out.push(snippet);
82
+ out.push('');
83
+ out.push(chalk.gray(`Restart: ${entry.restart}`));
84
+ out.push(chalk.gray('Web reference: brainrouter-docs/mcp-install.md'));
85
+ out.push('');
86
+ return { ok: true, output: out.join('\n') };
87
+ }
@@ -44,6 +44,39 @@ export async function tryHandleOrchestrationCommand(ctx) {
44
44
  console.log();
45
45
  return true;
46
46
  }
47
+ // `--watch`: poll the same data shape every second and re-render the
48
+ // running-children list inline. Same shape as `/agents` and the Ink
49
+ // status row so the user gets a single mental model (roadmap §3).
50
+ if (args.includes('--watch')) {
51
+ const intervalMs = 1000;
52
+ const maxTicks = 600; // ~10 min safety cap; Ctrl-C exits early.
53
+ let ticks = 0;
54
+ console.log(chalk.bold('\nWatching child agents (Ctrl-C to stop)…'));
55
+ await new Promise((resolve) => {
56
+ const handle = setInterval(() => {
57
+ reconcileStale(agent.workspaceRoot);
58
+ const running = listSessions(agent.workspaceRoot)
59
+ .filter((s) => s.status === 'pending' || s.status === 'running');
60
+ const stamp = new Date().toISOString().slice(11, 19);
61
+ if (running.length === 0) {
62
+ process.stdout.write(`\r[${stamp}] no running children${' '.repeat(40)}`);
63
+ }
64
+ else {
65
+ const parts = running.map((s) => `${s.id.slice(0, 14)} (${s.role})`).join(', ');
66
+ process.stdout.write(`\r[${stamp}] running: ${parts}${' '.repeat(10)}`);
67
+ }
68
+ if (++ticks >= maxTicks) {
69
+ clearInterval(handle);
70
+ process.stdout.write('\n');
71
+ resolve();
72
+ }
73
+ }, intervalMs);
74
+ const onSig = () => { clearInterval(handle); process.stdout.write('\n'); process.off('SIGINT', onSig); resolve(); };
75
+ process.once('SIGINT', onSig);
76
+ });
77
+ console.log();
78
+ return true;
79
+ }
47
80
  reconcileStale(agent.workspaceRoot);
48
81
  const sessions = listSessions(agent.workspaceRoot);
49
82
  // `--json` for scripting. Emits a single JSON line on stdout so
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `/release-notes` slash command — show the changelog for the running CLI version.
3
+ *
4
+ * /release-notes → current version's notes
5
+ * /release-notes <version> → specific version
6
+ * /release-notes list → every shipped version, sorted descending
7
+ *
8
+ * Changelog files ship inside the published package at `changelog/<version>.md`.
9
+ * The repo-root `brainrouter-changelog/` is copied into `brainrouter-cli/changelog/`
10
+ * by `prepublishOnly` so users who install via npm see them.
11
+ */
12
+ import type { CommandContext } from './_context.js';
13
+ export interface ReleaseNotesDeps {
14
+ /** Override the changelog directory (tests). Defaults to bundled `changelog/`. */
15
+ changelogDir?: string;
16
+ /** Override the current version (tests). Defaults to package.json#version. */
17
+ currentVersion?: string;
18
+ }
19
+ export declare function tryHandleReleaseNotesCommand(ctx: CommandContext, deps?: ReleaseNotesDeps): Promise<boolean>;
20
+ /**
21
+ * Pure handler — returns the rendered string. Split from `tryHandle*` so unit
22
+ * tests can assert on the output without capturing stdout.
23
+ */
24
+ export declare function runReleaseNotes(args: string[], deps?: ReleaseNotesDeps): string;