@kinqs/brainrouter-cli 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/bin/cli.cjs +71 -0
  8. package/dist/agent/agent.d.ts +224 -3
  9. package/dist/agent/agent.js +561 -55
  10. package/dist/cli/banner.d.ts +80 -0
  11. package/dist/cli/banner.js +232 -0
  12. package/dist/cli/cliPrompt.d.ts +106 -0
  13. package/dist/cli/cliPrompt.js +314 -0
  14. package/dist/cli/commands/_context.d.ts +3 -1
  15. package/dist/cli/commands/_helpers.d.ts +1 -1
  16. package/dist/cli/commands/_helpers.js +6 -6
  17. package/dist/cli/commands/config.d.ts +46 -0
  18. package/dist/cli/commands/config.js +1042 -0
  19. package/dist/cli/commands/guard.js +75 -10
  20. package/dist/cli/commands/init.d.ts +20 -0
  21. package/dist/cli/commands/init.js +64 -0
  22. package/dist/cli/commands/login.d.ts +13 -0
  23. package/dist/cli/commands/login.js +179 -0
  24. package/dist/cli/commands/mcp.d.ts +19 -0
  25. package/dist/cli/commands/mcp.js +286 -0
  26. package/dist/cli/commands/memory.js +2 -2
  27. package/dist/cli/commands/obs.js +22 -22
  28. package/dist/cli/commands/orchestration.js +18 -0
  29. package/dist/cli/commands/session.js +13 -5
  30. package/dist/cli/commands/ui.js +202 -91
  31. package/dist/cli/commands/workflow.d.ts +20 -0
  32. package/dist/cli/commands/workflow.js +368 -51
  33. package/dist/cli/ink/ChatApp.d.ts +206 -0
  34. package/dist/cli/ink/ChatApp.js +493 -0
  35. package/dist/cli/ink/Frame.d.ts +26 -0
  36. package/dist/cli/ink/Frame.js +5 -0
  37. package/dist/cli/ink/Picker.d.ts +65 -0
  38. package/dist/cli/ink/Picker.js +133 -0
  39. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  40. package/dist/cli/ink/SlashPalette.js +136 -0
  41. package/dist/cli/ink/TextField.d.ts +34 -0
  42. package/dist/cli/ink/TextField.js +47 -0
  43. package/dist/cli/ink/WizardApp.d.ts +7 -0
  44. package/dist/cli/ink/WizardApp.js +422 -0
  45. package/dist/cli/ink/ambientChat.d.ts +34 -0
  46. package/dist/cli/ink/ambientChat.js +7 -0
  47. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  48. package/dist/cli/ink/consoleCapture.js +33 -0
  49. package/dist/cli/ink/markdownRender.d.ts +41 -0
  50. package/dist/cli/ink/markdownRender.js +278 -0
  51. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  52. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  53. package/dist/cli/ink/runChat.d.ts +34 -0
  54. package/dist/cli/ink/runChat.js +571 -0
  55. package/dist/cli/ink/runPicker.d.ts +31 -0
  56. package/dist/cli/ink/runPicker.js +139 -0
  57. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  58. package/dist/cli/ink/runSlashPalette.js +33 -0
  59. package/dist/cli/ink/runWizard.d.ts +22 -0
  60. package/dist/cli/ink/runWizard.js +133 -0
  61. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  62. package/dist/cli/ink/stdinHandoff.js +78 -0
  63. package/dist/cli/ink/toolFormat.d.ts +73 -0
  64. package/dist/cli/ink/toolFormat.js +180 -0
  65. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  66. package/dist/cli/ink/useTerminalSize.js +26 -0
  67. package/dist/cli/repl.d.ts +25 -3
  68. package/dist/cli/repl.js +64 -646
  69. package/dist/cli/slashSuggest.d.ts +32 -0
  70. package/dist/cli/slashSuggest.js +146 -0
  71. package/dist/cli/spinner.d.ts +34 -0
  72. package/dist/cli/spinner.js +36 -0
  73. package/dist/cli/statusline.d.ts +67 -0
  74. package/dist/cli/statusline.js +204 -0
  75. package/dist/cli/theme.d.ts +79 -0
  76. package/dist/cli/theme.js +106 -0
  77. package/dist/cli/whereView.d.ts +81 -0
  78. package/dist/cli/whereView.js +245 -0
  79. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  80. package/dist/cli/wizard/modelsApi.js +166 -0
  81. package/dist/cli/wizard/picker.d.ts +202 -0
  82. package/dist/cli/wizard/picker.js +547 -0
  83. package/dist/cli/wizard/providers.d.ts +86 -0
  84. package/dist/cli/wizard/providers.js +190 -0
  85. package/dist/cli/wizard/runner.d.ts +13 -0
  86. package/dist/cli/wizard/runner.js +488 -0
  87. package/dist/cli/wizard/types.d.ts +122 -0
  88. package/dist/cli/wizard/types.js +109 -0
  89. package/dist/config/config.d.ts +52 -0
  90. package/dist/config/config.js +89 -75
  91. package/dist/index.js +215 -206
  92. package/dist/memory/briefing.d.ts +11 -1
  93. package/dist/memory/briefing.js +69 -1
  94. package/dist/memory/consolidation.d.ts +1 -1
  95. package/dist/orchestration/agentRegistry.d.ts +36 -0
  96. package/dist/orchestration/agentRegistry.js +64 -0
  97. package/dist/orchestration/orchestrator.d.ts +7 -0
  98. package/dist/orchestration/orchestrator.js +2 -0
  99. package/dist/orchestration/tools.d.ts +10 -1
  100. package/dist/orchestration/tools.js +48 -4
  101. package/dist/prompt/breadthHint.d.ts +5 -0
  102. package/dist/prompt/breadthHint.js +44 -0
  103. package/dist/prompt/skillCatalog.d.ts +11 -0
  104. package/dist/prompt/skillCatalog.js +134 -0
  105. package/dist/prompt/skillRunner.d.ts +2 -2
  106. package/dist/prompt/skillRunner.js +2 -31
  107. package/dist/prompt/systemPrompt.d.ts +34 -0
  108. package/dist/prompt/systemPrompt.js +128 -108
  109. package/dist/runtime/dangerousCommand.d.ts +53 -0
  110. package/dist/runtime/dangerousCommand.js +105 -0
  111. package/dist/runtime/mcpClient.d.ts +38 -1
  112. package/dist/runtime/mcpClient.js +104 -13
  113. package/dist/runtime/mcpPool.d.ts +162 -0
  114. package/dist/runtime/mcpPool.js +423 -0
  115. package/dist/runtime/mcpUtils.d.ts +3 -1
  116. package/dist/state/goalStore.d.ts +98 -17
  117. package/dist/state/goalStore.js +132 -42
  118. package/dist/state/preferencesStore.d.ts +67 -3
  119. package/dist/state/preferencesStore.js +84 -1
  120. package/dist/state/workflowArtifacts.d.ts +63 -2
  121. package/dist/state/workflowArtifacts.js +120 -8
  122. package/dist/tests/_helpers.d.ts +31 -0
  123. package/dist/tests/_helpers.js +91 -0
  124. package/package.json +12 -5
  125. package/.env.example +0 -109
@@ -0,0 +1,423 @@
1
+ import { McpClientWrapper, resolveIdentityFromConfig } from './mcpClient.js';
2
+ /**
3
+ * Choose which configured MCP profiles should connect for a normal CLI run.
4
+ *
5
+ * BrainRouter profiles are special: users may store several BrainRouter MCPs
6
+ * (local, staging, remote, self-hosted), but only one should be active at a
7
+ * time because the BrainRouter MCP is the memory/brain plane. Third-party MCPs
8
+ * are additive tools, so they all connect concurrently.
9
+ *
10
+ * `requestedProfile` is the explicit escape hatch (`--profile <name>`): it
11
+ * scopes the run to exactly that profile, matching the existing single-server
12
+ * mode.
13
+ */
14
+ export function selectMcpServerIds(servers, activeServer, requestedProfile) {
15
+ const ids = Object.keys(servers);
16
+ if (requestedProfile)
17
+ return servers[requestedProfile] ? [requestedProfile] : [];
18
+ const brainrouterIds = ids.filter((id) => resolveIdentityFromConfig(servers[id], id) === 'brainrouter');
19
+ const activeBrainrouter = activeServer && brainrouterIds.includes(activeServer)
20
+ ? activeServer
21
+ : brainrouterIds[0];
22
+ return ids.filter((id) => {
23
+ const identity = resolveIdentityFromConfig(servers[id], id);
24
+ return identity !== 'brainrouter' || id === activeBrainrouter;
25
+ });
26
+ }
27
+ function isBrainrouterOwnedTool(name) {
28
+ return name.startsWith('memory_') ||
29
+ [
30
+ 'list_skills',
31
+ 'get_skill',
32
+ 'search_skills',
33
+ 'create_skill',
34
+ 'update_skill',
35
+ 'get_persona',
36
+ 'get_reference',
37
+ 'list_template_docs',
38
+ 'get_template_doc',
39
+ ].includes(name);
40
+ }
41
+ export class McpClientPool {
42
+ /** serverId → connected wrapper. */
43
+ clients = new Map();
44
+ /** serverId → status entry (kept even for failed/offline servers so /mcp can render them). */
45
+ statuses = new Map();
46
+ /**
47
+ * Unprefixed tool name → owning serverId. Sentinel `__COLLISION__`
48
+ * marks tool names exposed by multiple servers (must be addressed
49
+ * via the prefixed form).
50
+ */
51
+ toolToServer = new Map();
52
+ /** Prefixed form (`mcp_<serverId>_<tool>`) → `{serverId, tool}` for fast dispatch. */
53
+ prefixedToServer = new Map();
54
+ /** LLM config from the last connectAll — needed for reconnect calls. */
55
+ currentLlmConfig;
56
+ /** Raw server configs from the last connectAll — needed for /mcp reconnect <id>. */
57
+ serverConfigs = new Map();
58
+ /** serverId → prefix used in tool names. Brainrouter servers get "brainrouter"; others keep their config key. */
59
+ prefixIds = new Map();
60
+ /** Reverse: prefixId → serverId (needed to dispatch `mcp_brainrouter_X` back to the real key). */
61
+ prefixToServerId = new Map();
62
+ getPrefixId(serverId) {
63
+ return this.prefixIds.get(serverId) ?? serverId;
64
+ }
65
+ assignPrefixId(serverId, wrapper) {
66
+ const id = wrapper.getIdentity() === 'brainrouter' ? 'brainrouter' : serverId;
67
+ this.prefixIds.set(serverId, id);
68
+ this.prefixToServerId.set(id, serverId);
69
+ }
70
+ /**
71
+ * Connect to every entry in `servers` concurrently. Each connect
72
+ * gets its own timeout; offline servers don't block the others.
73
+ * Returns the status array after all connects settle.
74
+ */
75
+ async connectAll(servers, llmConfig, options) {
76
+ this.currentLlmConfig = llmConfig;
77
+ const entries = Object.entries(servers);
78
+ // Stash configs first so `/mcp reconnect <id>` can find them later.
79
+ for (const [serverId, cfg] of entries)
80
+ this.serverConfigs.set(serverId, cfg);
81
+ const tasks = entries.map(([serverId, cfg]) => this.connectOne(serverId, cfg, llmConfig, options?.timeoutMs).then(() => {
82
+ const s = this.statuses.get(serverId);
83
+ if (s && options?.onStatusChange)
84
+ options.onStatusChange(s);
85
+ }));
86
+ await Promise.allSettled(tasks);
87
+ await this.refreshToolIndex();
88
+ return this.getStatuses();
89
+ }
90
+ /**
91
+ * Connect a single server. Used both by `connectAll` and by
92
+ * `/mcp connect <id>` for late-joining servers. Idempotent — if
93
+ * the server is already connected, closes the previous wrapper first.
94
+ */
95
+ async connectOne(serverId, config, llmConfig, timeoutMs = 5_000) {
96
+ if (this.clients.has(serverId)) {
97
+ try {
98
+ await this.clients.get(serverId).close();
99
+ }
100
+ catch { /* ignore */ }
101
+ this.clients.delete(serverId);
102
+ }
103
+ this.serverConfigs.set(serverId, config);
104
+ this.statuses.set(serverId, { serverId, identity: 'unknown', status: 'connecting' });
105
+ const wrapper = new McpClientWrapper();
106
+ try {
107
+ await Promise.race([
108
+ wrapper.connect(config, llmConfig ?? this.currentLlmConfig, serverId),
109
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)),
110
+ ]);
111
+ this.clients.set(serverId, wrapper);
112
+ this.assignPrefixId(serverId, wrapper);
113
+ this.statuses.set(serverId, {
114
+ serverId,
115
+ identity: wrapper.getIdentity(),
116
+ status: 'connected',
117
+ });
118
+ }
119
+ catch (err) {
120
+ this.statuses.set(serverId, {
121
+ serverId,
122
+ identity: 'unknown',
123
+ status: 'failed',
124
+ error: err?.message ?? String(err),
125
+ });
126
+ try {
127
+ await wrapper.close();
128
+ }
129
+ catch { /* ignore */ }
130
+ }
131
+ await this.refreshToolIndex();
132
+ }
133
+ /** Tear down a single server. Removes it from the pool and rebuilds the tool index. */
134
+ async disconnectOne(serverId) {
135
+ const wrapper = this.clients.get(serverId);
136
+ if (wrapper) {
137
+ try {
138
+ await wrapper.close();
139
+ }
140
+ catch { /* ignore */ }
141
+ }
142
+ this.clients.delete(serverId);
143
+ const oldPid = this.prefixIds.get(serverId);
144
+ if (oldPid)
145
+ this.prefixToServerId.delete(oldPid);
146
+ this.prefixIds.delete(serverId);
147
+ const prev = this.statuses.get(serverId);
148
+ this.statuses.set(serverId, {
149
+ serverId,
150
+ identity: prev?.identity ?? 'unknown',
151
+ status: 'offline',
152
+ });
153
+ await this.refreshToolIndex();
154
+ }
155
+ /** Reconnect: close + connect again using the stashed config. */
156
+ async reconnectOne(serverId) {
157
+ const config = this.serverConfigs.get(serverId);
158
+ if (!config) {
159
+ throw new Error(`No stored config for serverId "${serverId}". Add it to ~/.config/brainrouter/config.json first.`);
160
+ }
161
+ await this.disconnectOne(serverId);
162
+ await this.connectOne(serverId, config, this.currentLlmConfig);
163
+ }
164
+ /**
165
+ * Walk every connected client and rebuild the tool→server indices.
166
+ * Called after every connect / disconnect / reconnect so the
167
+ * dispatch path stays correct without re-fetching tools on every
168
+ * `callTool`.
169
+ */
170
+ async refreshToolIndex() {
171
+ this.toolToServer.clear();
172
+ this.prefixedToServer.clear();
173
+ for (const [serverId, wrapper] of this.clients) {
174
+ if (!wrapper.isConnected())
175
+ continue;
176
+ try {
177
+ const res = await wrapper.listTools();
178
+ const tools = res.tools ?? [];
179
+ const status = this.statuses.get(serverId);
180
+ if (status) {
181
+ status.toolCount = tools.length;
182
+ status.identity = wrapper.getIdentity();
183
+ }
184
+ for (const tool of tools) {
185
+ const rawName = tool.name;
186
+ const pid = this.getPrefixId(serverId);
187
+ const prefixed = `mcp_${pid}_${rawName}`;
188
+ this.prefixedToServer.set(prefixed, { serverId, tool: rawName });
189
+ const existing = this.toolToServer.get(rawName);
190
+ if (existing && existing !== serverId) {
191
+ // Two servers expose the same unprefixed tool name. Mark
192
+ // collision so the raw-name resolver knows to require the
193
+ // prefix.
194
+ this.toolToServer.set(rawName, '__COLLISION__');
195
+ }
196
+ else if (!existing) {
197
+ this.toolToServer.set(rawName, serverId);
198
+ }
199
+ }
200
+ }
201
+ catch {
202
+ // Server is connected but listTools failed — its tools won't
203
+ // appear this turn. Will retry on the next refresh.
204
+ }
205
+ }
206
+ }
207
+ /**
208
+ * Concatenated tool list across every connected server, with names
209
+ * prefixed `mcp_<serverId>_<toolName>` (Claude Code style). The
210
+ * agent calls this once per turn and hands it to the LLM.
211
+ */
212
+ async listTools() {
213
+ const all = [];
214
+ for (const [serverId, wrapper] of this.clients) {
215
+ if (!wrapper.isConnected())
216
+ continue;
217
+ try {
218
+ const res = await wrapper.listTools();
219
+ const tools = res.tools ?? [];
220
+ const status = this.statuses.get(serverId);
221
+ if (status) {
222
+ status.identity = wrapper.getIdentity();
223
+ }
224
+ const pid = this.getPrefixId(serverId);
225
+ for (const tool of tools) {
226
+ all.push({
227
+ ...tool,
228
+ name: `mcp_${pid}_${tool.name}`,
229
+ __serverId: serverId,
230
+ __rawName: tool.name,
231
+ });
232
+ }
233
+ }
234
+ catch {
235
+ // listTools failed for this server — drop its tools this turn.
236
+ }
237
+ }
238
+ return { tools: all };
239
+ }
240
+ /**
241
+ * Route a tool call to the right server. Accepts both name forms:
242
+ *
243
+ * - `mcp_<serverId>_<tool>` — the canonical form the LLM sees
244
+ * in the inventory. Stripped + dispatched directly.
245
+ * - `<tool>` raw form — back-compat for prompts/skills that
246
+ * hardcode `memory_recall`-style names. Routed to the unique
247
+ * server providing that tool. Returns a helpful error if two
248
+ * servers expose the same name (caller must use the prefix).
249
+ */
250
+ async callTool(name, args) {
251
+ const resolved = this.resolveToolCall(name);
252
+ if (!resolved) {
253
+ // Distinguish the two failure modes — gives the LLM (and humans
254
+ // tailing logs) actionable feedback.
255
+ if (this.toolToServer.get(name) === '__COLLISION__') {
256
+ const prefixes = [...this.clients.keys()]
257
+ .filter((id) => {
258
+ const w = this.clients.get(id);
259
+ return w && w.isConnected() && this.prefixedToServer.has(`mcp_${this.getPrefixId(id)}_${name}`);
260
+ })
261
+ .map((id) => this.getPrefixId(id));
262
+ return {
263
+ isError: true,
264
+ content: [{
265
+ type: 'text',
266
+ text: `Ambiguous tool name "${name}" — exposed by ${prefixes.length} MCP servers: ${prefixes.join(', ')}. Use the prefixed form, e.g. mcp_${prefixes[0]}_${name}.`,
267
+ }],
268
+ };
269
+ }
270
+ return {
271
+ isError: true,
272
+ content: [{ type: 'text', text: `Tool "${name}" not found on any connected MCP server.` }],
273
+ };
274
+ }
275
+ const wrapper = this.clients.get(resolved.serverId);
276
+ if (!wrapper || !wrapper.isConnected()) {
277
+ return {
278
+ isError: true,
279
+ content: [{
280
+ type: 'text',
281
+ text: `MCP server "${resolved.serverId}" is offline; tool "${resolved.tool}" cannot be reached. Try /mcp reconnect ${resolved.serverId}.`,
282
+ }],
283
+ };
284
+ }
285
+ return wrapper.callTool(resolved.tool, args);
286
+ }
287
+ /** Internal — map a name (prefixed OR raw) to a concrete server + tool. */
288
+ resolveToolCall(name) {
289
+ // Fast path: exact prefixed form match in the index.
290
+ if (name.startsWith('mcp_')) {
291
+ const direct = this.prefixedToServer.get(name);
292
+ if (direct)
293
+ return direct;
294
+ // Lenient parse: walk prefix IDs first (covers "brainrouter"
295
+ // alias), then raw server IDs as fallback.
296
+ const rest = name.slice('mcp_'.length);
297
+ for (const [pid, realId] of this.prefixToServerId) {
298
+ const prefix = `${pid}_`;
299
+ if (rest.startsWith(prefix)) {
300
+ return { serverId: realId, tool: rest.slice(prefix.length) };
301
+ }
302
+ }
303
+ for (const serverId of this.clients.keys()) {
304
+ const prefix = `${serverId}_`;
305
+ if (rest.startsWith(prefix)) {
306
+ return { serverId, tool: rest.slice(prefix.length) };
307
+ }
308
+ }
309
+ return undefined;
310
+ }
311
+ if (isBrainrouterOwnedTool(name)) {
312
+ for (const [serverId, wrapper] of this.clients) {
313
+ if (wrapper.isConnected() &&
314
+ wrapper.getIdentity() === 'brainrouter' &&
315
+ this.prefixedToServer.has(`mcp_${serverId}_${name}`)) {
316
+ return { serverId, tool: name };
317
+ }
318
+ }
319
+ }
320
+ // Raw-name fallback.
321
+ const owner = this.toolToServer.get(name);
322
+ if (!owner || owner === '__COLLISION__')
323
+ return undefined;
324
+ return { serverId: owner, tool: name };
325
+ }
326
+ // ----- Facade methods that match McpClientWrapper's public surface -----
327
+ /** True iff at least one server is connected. */
328
+ isConnected() {
329
+ for (const w of this.clients.values()) {
330
+ if (w.isConnected())
331
+ return true;
332
+ }
333
+ return false;
334
+ }
335
+ /**
336
+ * Identity precedence: any connected `brainrouter` > any connected
337
+ * `third-party` > `unknown`. The CLI banner + offline prompt swap
338
+ * branch on this — "BrainRouter is offline" makes sense only when
339
+ * we expected one and didn't get one.
340
+ */
341
+ getIdentity() {
342
+ for (const w of this.clients.values()) {
343
+ if (w.isConnected() && w.getIdentity() === 'brainrouter')
344
+ return 'brainrouter';
345
+ }
346
+ for (const w of this.clients.values()) {
347
+ if (w.isConnected() && w.getIdentity() === 'third-party')
348
+ return 'third-party';
349
+ }
350
+ return 'unknown';
351
+ }
352
+ /**
353
+ * Human-readable summary for the banner/statusline. Single-server
354
+ * pools render just the server name; multi-server pools render
355
+ * a count + the first few names.
356
+ */
357
+ getServerName() {
358
+ const connected = [...this.clients.entries()]
359
+ .filter(([_, w]) => w.isConnected())
360
+ .map(([id]) => id);
361
+ if (connected.length === 0)
362
+ return undefined;
363
+ if (connected.length === 1)
364
+ return connected[0];
365
+ const head = connected.slice(0, 3).join(', ');
366
+ return connected.length > 3 ? `${connected.length} servers (${head}, …)` : `${connected.length} servers (${head})`;
367
+ }
368
+ /**
369
+ * Look up a wrapper by serverId. Used by `/mcp tools <server>` and
370
+ * similar commands that want to talk to one specific server.
371
+ */
372
+ getClient(serverId) {
373
+ return this.clients.get(serverId);
374
+ }
375
+ /**
376
+ * Find the connected wrapper whose identity is 'brainrouter'. Some
377
+ * code paths (memory capture, working-memory offload) specifically
378
+ * need the canonical brain regardless of how many third-party MCPs
379
+ * the user added.
380
+ */
381
+ getBrainrouterClient() {
382
+ for (const w of this.clients.values()) {
383
+ if (w.isConnected() && w.getIdentity() === 'brainrouter')
384
+ return w;
385
+ }
386
+ return undefined;
387
+ }
388
+ /** Server id for the currently connected BrainRouter MCP, if one is active. */
389
+ getActiveBrainrouterServerId() {
390
+ for (const [serverId, wrapper] of this.clients) {
391
+ if (wrapper.isConnected() && wrapper.getIdentity() === 'brainrouter')
392
+ return serverId;
393
+ }
394
+ return undefined;
395
+ }
396
+ /** Status snapshot for every server the pool has tried to connect to. */
397
+ getStatuses() {
398
+ return [...this.statuses.values()];
399
+ }
400
+ /** Status for one server (returns undefined if the pool has never seen it). */
401
+ getStatus(serverId) {
402
+ return this.statuses.get(serverId);
403
+ }
404
+ /** List of serverIds currently held by the pool (connected or not). */
405
+ getServerIds() {
406
+ return [...this.statuses.keys()];
407
+ }
408
+ /** Close every wrapper. Used on CLI exit. */
409
+ async close() {
410
+ for (const wrapper of this.clients.values()) {
411
+ try {
412
+ await wrapper.close();
413
+ }
414
+ catch { /* ignore */ }
415
+ }
416
+ this.clients.clear();
417
+ this.toolToServer.clear();
418
+ this.prefixedToServer.clear();
419
+ this.prefixIds.clear();
420
+ this.prefixToServerId.clear();
421
+ // Keep `statuses` so a `getStatuses()` after close still shows what was there.
422
+ }
423
+ }
@@ -1,4 +1,6 @@
1
1
  import type { McpClientWrapper } from './mcpClient.js';
2
+ import type { McpClientPool } from './mcpPool.js';
3
+ export type McpClient = McpClientWrapper | McpClientPool;
2
4
  /**
3
5
  * Centralized helpers for talking to the BrainRouter MCP server.
4
6
  *
@@ -27,7 +29,7 @@ export interface McpCallResult<T = any> {
27
29
  * Network and protocol errors are converted to `{ isError: true, text: errorMessage }`
28
30
  * so callers can branch on a single shape instead of mixing try/catch with isError checks.
29
31
  */
30
- export declare function callMcpTool<T = any>(client: McpClientWrapper, name: string, args: Record<string, unknown>): Promise<McpCallResult<T>>;
32
+ export declare function callMcpTool<T = any>(client: McpClient, name: string, args: Record<string, unknown>): Promise<McpCallResult<T>>;
31
33
  /**
32
34
  * Canonical convention for naming a child agent's session key relative to its
33
35
  * parent: `<parent>:child:<id>`. Centralized so a future change (e.g. switching
@@ -24,12 +24,20 @@
24
24
  * "you ran out of room" from "user paused" from
25
25
  * "agent gave up."
26
26
  *
27
- * Storage (per-session bucket):
28
- * ~/.brainrouter/workspaces/<encoded>/cli/sessions/<encodedKey>/goal.json
27
+ * Storage (priority chain — see `resolveGoalScope`):
28
+ * 1. Workflow bound: `<workspace>/.brainrouter/workflows/<slug>/goal.json`
29
+ * (lives in the committable workflow folder so the goal travels with
30
+ * the spec / tasks / walkthrough).
31
+ * 2. No workflow, session-scoped:
32
+ * `~/.brainrouter/workspaces/<encoded>/cli/sessions/<encodedKey>/goal.json`
33
+ * 3. Back-compat (no workflow, no sessionKey):
34
+ * `~/.brainrouter/workspaces/<encoded>/cli/goal.json`
29
35
  *
30
- * Legacy fallback paths exist for sessions created before per-session
31
- * goal isolation was added. normalize() fills missing fields with defaults
32
- * so resumed sessions don't crash on first read.
36
+ * Session-scoped reads stay isolated (Item 1 invariant never fall back to
37
+ * a prior session's goal). Workflow-bound reads stay isolated by workflow
38
+ * (Item 3 invariant switching workflows swaps which goal you see).
39
+ * normalize() fills missing fields with defaults so resumed sessions don't
40
+ * crash on first read.
33
41
  */
34
42
  export type GoalStatus = 'active' | 'paused' | 'complete' | 'blocked' | 'usage_limited';
35
43
  /** A pausing status is one where continuation is halted but resumable. */
@@ -57,7 +65,25 @@ export interface Goal {
57
65
  completedAt?: string;
58
66
  blockedReason?: string;
59
67
  }
60
- export declare const DEFAULT_GOAL_BUDGET = 10;
68
+ /**
69
+ * Default iteration cap when the user doesn't pass one.
70
+ *
71
+ * Set to a very high number (effectively "unlimited" for any real task)
72
+ * rather than a tight 10. Rationale: the goal lifecycle has three
73
+ * independent safety nets that already prevent runaway loops —
74
+ * 1. Anti-spin — a turn that made zero tool calls doesn't continue
75
+ * 2. Repeat-loop — calling the same tool with identical args 3× errors
76
+ * 3. Manual stop — Ctrl-C, /goal pause, /goal clear
77
+ *
78
+ * A hard iteration cap on top of those is overly paternalistic for users
79
+ * running local models (no $ cost) and is easily lifted with /goal budget
80
+ * <n> when wanted. Display layers should treat any value >= UNLIMITED_THRESHOLD
81
+ * as "unlimited" for friendlier UX.
82
+ */
83
+ export declare const DEFAULT_GOAL_BUDGET = 1000000;
84
+ export declare const UNLIMITED_BUDGET_THRESHOLD = 100000;
85
+ /** Format helper — used by REPL display + status output. */
86
+ export declare function formatBudget(maxIterations: number): string;
61
87
  /**
62
88
  * Hard cap on the goal text length. A goal is supposed to be a 1–3 sentence
63
89
  * outcome statement; multi-thousand-character pastes (e.g. full chat logs)
@@ -82,6 +108,55 @@ export declare class GoalConflictError extends Error {
82
108
  readonly existing: Goal;
83
109
  constructor(existing: Goal);
84
110
  }
111
+ /**
112
+ * Where the agent's goal lives RIGHT NOW. The priority chain — adapted from
113
+ * openSrc/agentmemory's fallback-provider walk (guard clauses that early-
114
+ * return per layer rather than a single flat loop) — is:
115
+ *
116
+ * 1. workflow scope — a workflow is bound via `current-workflow.json`
117
+ * (the per-user CLI pointer). Goal lives at `<workflow>/goal.json`
118
+ * next to spec.md / tasks.md / meta.json. Switching workflows carries
119
+ * the goal with the folder.
120
+ * 2. session scope — no workflow bound but a sessionKey is supplied
121
+ * (the post-Item-1 default). Goal lives at
122
+ * `<cliStateDir>/sessions/<encodedKey>/goal.json` — strictly per
123
+ * session, never falls back to a different session's file.
124
+ * 3. legacy scope — no workflow, no sessionKey. Used by the very-old
125
+ * single-process call sites that haven't been migrated yet (and by
126
+ * back-compat reads of pre-0.3.5 workspace-level goal.json files).
127
+ *
128
+ * Every read/write entrypoint routes through this single resolver so the
129
+ * priority chain has exactly one decision point. Callers don't decide where
130
+ * to look; they get a path + scope tag and act on it.
131
+ */
132
+ export type GoalScope = {
133
+ scope: 'session';
134
+ sessionKey: string;
135
+ path: string;
136
+ } | {
137
+ scope: 'legacy';
138
+ path: string;
139
+ };
140
+ /**
141
+ * Resolve the on-disk location where the active goal for this CLI process
142
+ * lives.
143
+ *
144
+ * **Design (0.3.6 decouple-goal-from-workflow, supersedes Item 3):** goal
145
+ * is **always per-session**. Workflows are durable artifact folders
146
+ * (spec.md, tasks.md, walkthrough.md, meta.json) that have nothing to do
147
+ * with the agent's autonomy primitive. The earlier Item 3 design coupled
148
+ * the two by storing goal state inside `<workflow>/goal.json`, which
149
+ * meant any two CLI sessions in the same workspace that happened to land
150
+ * on the same workflow shared a goal — silently reintroducing the
151
+ * cross-session leak PR #26 had fixed. We removed that coupling
152
+ * entirely: workflows are storage, goals are runtime, no overlap.
153
+ *
154
+ * Priority chain:
155
+ * 1. Session-scoped — `<session>/goal.json`. The normal case.
156
+ * 2. Legacy — `<cli-state>/goal.json`. Only hit by callers without a
157
+ * sessionKey (rare; mostly tooling paths that pre-date Item 1).
158
+ */
159
+ export declare function resolveGoalScope(workspaceRoot: string, sessionKey?: string): GoalScope;
85
160
  export declare function readGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
86
161
  /**
87
162
  * Set a new active goal. Refuses to overwrite an in-progress goal (active,
@@ -158,17 +233,23 @@ export declare function goalHasBudgetLeft(goal: Goal): boolean;
158
233
  * decision. We detect that ahead of time by checking before the tick.
159
234
  */
160
235
  export declare function goalIsOnFinalBudgetTurn(goal: Goal): boolean;
236
+ export interface FormatGoalBlockOptions {
237
+ /**
238
+ * Override the auto-detected final-budget-turn state. Useful for tests
239
+ * and for callers that want to force-render the wrap-up directive. When
240
+ * omitted, `formatGoalBlock` calls `goalIsOnFinalBudgetTurn(goal)` itself.
241
+ */
242
+ finalBudgetTurn?: boolean;
243
+ }
244
+ export declare function formatGoalBlock(goal: Goal, options?: FormatGoalBlockOptions): string;
161
245
  /**
162
- * Wrap-up steering message injected on the final-budget turn. The agent
163
- * loop pushes this into the chat history as a system message so the model
164
- * pivots from "continue investigating" to "consolidate and report." Plain
165
- * directive, no role-play.
246
+ * Drift / ready check used by the goal-continuation prompt. Compressed
247
+ * from the prose-heavy 4-paragraph form into a 2-line checklist as part
248
+ * of 9d's prompt deduplication the goal text, status, and budget are
249
+ * now owned by the goal-anchor system message; the continuation prompt
250
+ * carries only the per-turn drift check + a pointer to the anchor.
166
251
  *
167
- * The message specifically reports WHICH cap is tight (iterations, tokens,
168
- * or both) so the model doesn't get told "one turn left" when it actually
169
- * has many iterations remaining but is near the token cap, or vice versa.
170
- * Earlier versions hardcoded the iteration framing even when only the
171
- * token heuristic tripped, which misled the model on token-budgeted runs.
252
+ * Distinct from `buildGoalKickoffPrompt` (in `commands/_helpers.ts`),
253
+ * which is the FIRST-turn prompt fired by `/goal <text>` and `/goal resume`.
172
254
  */
173
- export declare function buildBudgetSteeringMessage(goal: Goal): string;
174
- export declare function formatGoalBlock(goal: Goal): string;
255
+ export declare function buildGoalContinuationPrompt(goal: Goal, lastPrompt: string, lastAnswer: string): string;