@kinqs/brainrouter-cli 0.3.6 → 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.
- package/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/changelog/0.2.0.md +15 -0
- package/changelog/0.3.0.md +20 -0
- package/changelog/0.3.1.md +22 -0
- package/changelog/0.3.2.md +15 -0
- package/changelog/0.3.3.md +19 -0
- package/changelog/0.3.4.md +20 -0
- package/changelog/0.3.5.md +9 -0
- package/changelog/0.3.6.md +9 -0
- package/changelog/0.3.7.md +20 -0
- package/changelog/0.3.8.md +30 -0
- package/changelog/README.md +41 -0
- package/dist/agent/agent.d.ts +34 -1
- package/dist/agent/agent.js +372 -79
- package/dist/agent/toolCallRecovery.d.ts +57 -0
- package/dist/agent/toolCallRecovery.js +130 -0
- package/dist/agent/toolSafety.d.ts +17 -0
- package/dist/agent/toolSafety.js +102 -0
- package/dist/cli/banner.d.ts +20 -0
- package/dist/cli/banner.js +47 -14
- package/dist/cli/cliPrompt.d.ts +40 -3
- package/dist/cli/cliPrompt.js +117 -25
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +13 -11
- package/dist/cli/commands/mcp.js +261 -74
- package/dist/cli/commands/mcpInstall.d.ts +20 -0
- package/dist/cli/commands/mcpInstall.js +87 -0
- package/dist/cli/commands/orchestration.js +51 -0
- package/dist/cli/commands/releaseNotes.d.ts +24 -0
- package/dist/cli/commands/releaseNotes.js +109 -0
- package/dist/cli/commands/schedule.d.ts +18 -0
- package/dist/cli/commands/schedule.js +189 -0
- package/dist/cli/commands/ui.js +119 -60
- package/dist/cli/commands/workflow.d.ts +2 -0
- package/dist/cli/commands/workflow.js +54 -8
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +71 -0
- package/dist/cli/ink/Picker.js +168 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +682 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +75 -0
- package/dist/cli/ink/toolFormat.js +206 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +52 -714
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +13 -1
- package/dist/config/config.js +45 -3
- package/dist/index.js +157 -206
- package/dist/memory/briefing.d.ts +1 -1
- package/dist/memory/briefing.js +4 -4
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +105 -3
- package/dist/orchestration/tools.js +167 -8
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.js +7 -2
- package/dist/runtime/anthropicAdapter.d.ts +100 -0
- package/dist/runtime/anthropicAdapter.js +293 -0
- package/dist/runtime/cronParser.d.ts +23 -0
- package/dist/runtime/cronParser.js +122 -0
- package/dist/runtime/mcpClient.js +14 -11
- package/dist/runtime/mcpPool.d.ts +170 -0
- package/dist/runtime/mcpPool.js +442 -0
- package/dist/runtime/mcpUtils.d.ts +17 -1
- package/dist/runtime/mcpUtils.js +23 -0
- package/dist/runtime/scheduleTicker.d.ts +33 -0
- package/dist/runtime/scheduleTicker.js +99 -0
- package/dist/runtime/vendorSnippets.d.ts +45 -0
- package/dist/runtime/vendorSnippets.js +153 -0
- package/dist/state/scheduleStore.d.ts +37 -0
- package/dist/state/scheduleStore.js +64 -0
- package/package.json +14 -5
- package/.env.example +0 -116
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface ToolCallLike {
|
|
2
|
+
id: string;
|
|
3
|
+
type?: string;
|
|
4
|
+
function: {
|
|
5
|
+
name: string;
|
|
6
|
+
arguments: string | object;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export interface ToolResultMessage {
|
|
10
|
+
role: 'tool';
|
|
11
|
+
tool_call_id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
content: string;
|
|
14
|
+
isError?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Drop duplicate tool_call ids inside a single assistant response. Keeps the
|
|
18
|
+
* LAST occurrence (closest to the model's final intent). Calls without a
|
|
19
|
+
* string id are passed through unchanged — the orphan safety net will catch
|
|
20
|
+
* them later.
|
|
21
|
+
*
|
|
22
|
+
* `onDuplicate` is invoked once per dropped duplicate so callers can log a
|
|
23
|
+
* warning without coupling this module to a logger.
|
|
24
|
+
*/
|
|
25
|
+
export declare function dedupeToolCalls<T extends ToolCallLike>(calls: T[] | undefined | null, onDuplicate?: (id: string, droppedIndex: number) => void): T[];
|
|
26
|
+
export interface ParsedArguments {
|
|
27
|
+
args: Record<string, any>;
|
|
28
|
+
/** Defined iff the LLM emitted malformed JSON; ready-to-use error string for a tool_result envelope. */
|
|
29
|
+
error?: string;
|
|
30
|
+
rawArguments: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Try-parse `tool_call.function.arguments`. On parse failure return a
|
|
34
|
+
* structured error string instead of throwing, so the caller can attach a
|
|
35
|
+
* synthetic tool_result that the next model turn can read.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseArgumentsOrError(call: ToolCallLike): ParsedArguments;
|
|
38
|
+
/**
|
|
39
|
+
* For every tool_call in `calls` that has no matching tool_result in
|
|
40
|
+
* `results`, build a synthetic tool message so the next LLM request stays
|
|
41
|
+
* well-formed (OpenAI strictly requires tool_call ↔ tool_result pairing).
|
|
42
|
+
*
|
|
43
|
+
* IMPORTANT: the synthetic `content` MUST start with `ERROR:` and be a plain
|
|
44
|
+
* string. The agent runtime's R1 child-drain guardrail tracks spawned
|
|
45
|
+
* children by `parseJsonObject(resultText)` on tool results — if the
|
|
46
|
+
* synthetic envelope parses as JSON with an `id` field, the guardrail would
|
|
47
|
+
* incorrectly think a child agent was spawned and try to wait on it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function synthesizeOrphanResults<T extends ToolCallLike>(calls: T[] | undefined | null, results: ToolResultMessage[]): ToolResultMessage[];
|
|
50
|
+
/**
|
|
51
|
+
* Use the caller's existing `normalizeToolName` to surface a "did you mean"
|
|
52
|
+
* suggestion when the LLM emits a tool name that doesn't exist as-is but
|
|
53
|
+
* normalizes to a real registered tool. Tolerates the single-underscore
|
|
54
|
+
* `mcp_<server>_<tool>` prefix (R5 convention) since `normalizeToolName`
|
|
55
|
+
* matches by flattened form.
|
|
56
|
+
*/
|
|
57
|
+
export declare function suggestSimilarToolName(raw: string, candidates: string[], normalize: (raw: string, candidates: string[]) => string): string | undefined;
|
|
@@ -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
|
+
}
|
package/dist/cli/banner.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/banner.js
CHANGED
|
@@ -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.
|
|
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,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.
|
|
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
|
+
// `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(
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/dist/cli/cliPrompt.d.ts
CHANGED
|
@@ -55,7 +55,22 @@ export interface PickerKey {
|
|
|
55
55
|
/** A single printable character for free-text capture (Other phase). */
|
|
56
56
|
char?: string;
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `prefilledOther` drops the picker straight into the free-text "Other"
|
|
60
|
+
* phase with the supplied string already in the buffer. Used by the
|
|
61
|
+
* 0.3.7 wizard / `/config` panel when a value can be derived from an
|
|
62
|
+
* env var — the user sees the env value and presses ENTER to accept or
|
|
63
|
+
* edits to override. Pass an empty string to keep today's behaviour.
|
|
64
|
+
*
|
|
65
|
+
* `initialCursor` lets a picker open with a non-zero highlight so the
|
|
66
|
+
* settings home panel can re-open on the row the user just edited
|
|
67
|
+
* without re-scrolling them to the top.
|
|
68
|
+
*/
|
|
69
|
+
export interface InitPickerStateOptions {
|
|
70
|
+
prefilledOther?: string;
|
|
71
|
+
initialCursor?: number;
|
|
72
|
+
}
|
|
73
|
+
export declare function initPickerState(options: ChoiceOption[], multiSelect: boolean, init?: InitPickerStateOptions): PickerState;
|
|
59
74
|
export declare function reducePicker(state: PickerState, key: PickerKey): PickerState;
|
|
60
75
|
export declare function renderPicker(state: PickerState, question: string, header?: string): string;
|
|
61
76
|
/**
|
|
@@ -72,10 +87,32 @@ export declare function renderPicker(state: PickerState, question: string, heade
|
|
|
72
87
|
* User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
|
|
73
88
|
* tool wrapper can surface "user declined to commit" as a tool-call error.
|
|
74
89
|
*/
|
|
75
|
-
|
|
90
|
+
/**
|
|
91
|
+
* 0.3.7 picker opts.
|
|
92
|
+
*
|
|
93
|
+
* `onCursorChange(index)` fires after every arrow-key move that actually
|
|
94
|
+
* moves the cursor (no-op keys, ENTER, SPACE don't fire it). The 0.3.7
|
|
95
|
+
* theme picker uses this to live-preview the selected theme by redrawing
|
|
96
|
+
* the banner accent before the user confirms — pattern lifted from
|
|
97
|
+
* `openSrc/codex/codex-rs/tui/src/bottom_pane/list_selection_view.rs` and
|
|
98
|
+
* `openSrc/codex/codex-rs/tui/src/theme_picker.rs`.
|
|
99
|
+
*
|
|
100
|
+
* `prefilledOther` opens the picker with the synthetic "Other" row
|
|
101
|
+
* already selected AND the free-text input pre-filled. Used when a value
|
|
102
|
+
* is derived from an env var so the user can press ENTER to accept or
|
|
103
|
+
* edit in-place. Pre-fill flips `awaitingOther` true on init.
|
|
104
|
+
*
|
|
105
|
+
* `initialCursor` lets the settings home panel re-open on the row the
|
|
106
|
+
* user just left, avoiding a snap-to-row-0 after every sub-picker.
|
|
107
|
+
*/
|
|
108
|
+
export interface AskChoiceOptions {
|
|
76
109
|
multiSelect?: boolean;
|
|
77
110
|
header?: string;
|
|
78
|
-
|
|
111
|
+
onCursorChange?: (cursor: number) => void;
|
|
112
|
+
prefilledOther?: string;
|
|
113
|
+
initialCursor?: number;
|
|
114
|
+
}
|
|
115
|
+
export declare function askChoice(question: string, options: ChoiceOption[], opts?: AskChoiceOptions): Promise<string | string[]>;
|
|
79
116
|
/**
|
|
80
117
|
* Print a line of output while the prompt is showing, then redraw the prompt
|
|
81
118
|
* with whatever the user was mid-typing. Used by callbacks that fire while the
|