@bike4mind/cli 0.18.4 → 0.20.0
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/LICENSE +17 -3
- package/README.md +204 -35
- package/bin/bike4mind-cli.mjs +137 -24
- package/bin/hearth-hook.mjs +292 -0
- package/dist/AgentHistoryStore-C8uUKjjC.mjs +35512 -0
- package/dist/ApiClient-B_CQrUiF.mjs +277 -0
- package/dist/{ConfigStore-Cq20962p.mjs → ConfigStore-DD3DcC3-.mjs} +6256 -3911
- package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
- package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
- package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
- package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
- package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
- package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
- package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
- package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
- package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
- package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
- package/dist/buildAgent-mVuXU_H4.mjs +824 -0
- package/dist/commands/acpCommand.mjs +798 -0
- package/dist/commands/apiCommand.mjs +14 -16
- package/dist/commands/doctorCommand.mjs +5 -5
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +272 -76
- package/dist/commands/mcpCommand.mjs +14 -1
- package/dist/commands/pluginCommand.mjs +232 -0
- package/dist/commands/updateCommand.mjs +10 -9
- package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
- package/dist/index.mjs +3281 -2322
- package/dist/{package-CBaK53NX.mjs → package-BqKSCbso.mjs} +1 -1
- package/dist/serve-CuF0I5en.mjs +772 -0
- package/dist/store-BG3e54c8.mjs +3 -0
- package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
- package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
- package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
- package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
- package/package.json +48 -43
- package/dist/BackgroundAgentManager-DOesheMD.mjs +0 -27171
- package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
- package/dist/store-DgzCTRkN.mjs +0 -3
- package/dist/utils-Cdktpk_k.mjs +0 -158
- package/dist/utils-DEizxshI.mjs +0 -3
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { F as loadContextFiles, J as setWebSocketToolExecutor, L as generateCliTools, M as ReActAgent, X as buildSystemPrompt, Y as getPlanModeFilePath, a as McpManager, i as AgentStore, l as OllamaBackend, n as BackgroundAgentManager, o as createSseBackend, r as SubagentOrchestrator, s as ServerLlmBackend, t as AgentHistoryStore } from "./AgentHistoryStore-C8uUKjjC.mjs";
|
|
3
|
+
import { n as logger } from "./ConfigStore-DD3DcC3-.mjs";
|
|
4
|
+
import { z as z$1 } from "zod";
|
|
5
|
+
import WebSocket from "ws";
|
|
6
|
+
//#region src/llm/NotifyingLlmBackend.ts
|
|
7
|
+
/**
|
|
8
|
+
* LLM backend wrapper that injects background agent notifications
|
|
9
|
+
* into the message array before each completion call.
|
|
10
|
+
*
|
|
11
|
+
* When a background agent completes (or fails), the notification
|
|
12
|
+
* appears as a system message so the main agent naturally sees it
|
|
13
|
+
* in context - no polling required.
|
|
14
|
+
*/
|
|
15
|
+
var NotifyingLlmBackend = class {
|
|
16
|
+
constructor(inner, backgroundManager) {
|
|
17
|
+
this.inner = inner;
|
|
18
|
+
this.backgroundManager = backgroundManager;
|
|
19
|
+
}
|
|
20
|
+
get currentModel() {
|
|
21
|
+
return this.inner.currentModel;
|
|
22
|
+
}
|
|
23
|
+
set currentModel(model) {
|
|
24
|
+
this.inner.currentModel = model;
|
|
25
|
+
}
|
|
26
|
+
async complete(model, messages, options, callback) {
|
|
27
|
+
const notifications = this.backgroundManager.drainNotifications();
|
|
28
|
+
let effectiveMessages = messages;
|
|
29
|
+
if (notifications.length > 0) {
|
|
30
|
+
const notificationMessage = {
|
|
31
|
+
role: "user",
|
|
32
|
+
content: `[System Notification]\n\n${notifications.join("\n\n---\n\n")}\n\nPlease acknowledge these background agent results and incorporate them into your current work.`
|
|
33
|
+
};
|
|
34
|
+
effectiveMessages = [...messages, notificationMessage];
|
|
35
|
+
}
|
|
36
|
+
return this.inner.complete(model, effectiveMessages, options, callback);
|
|
37
|
+
}
|
|
38
|
+
pushToolMessages(messages, tool, result) {
|
|
39
|
+
return this.inner.pushToolMessages(messages, tool, result);
|
|
40
|
+
}
|
|
41
|
+
async getModelInfo() {
|
|
42
|
+
return this.inner.getModelInfo();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/tools/deferredToolRegistry.ts
|
|
47
|
+
/**
|
|
48
|
+
* Registry of tool schemas that are NOT loaded into the model's initial tool
|
|
49
|
+
* list. The model sees only the names (via the system prompt directory) and
|
|
50
|
+
* must call the `tool_search` meta-tool to load schemas on demand.
|
|
51
|
+
*
|
|
52
|
+
* This mirrors Claude Code's deferred-tool pattern. The win is large for
|
|
53
|
+
* heavy MCP integrations (e.g. 41 GitHub MCP tools at ~250-350 tokens of
|
|
54
|
+
* JSONSchema each = ~10-15k tokens per turn that's now ~1-1.5k of names).
|
|
55
|
+
*/
|
|
56
|
+
var DeferredToolRegistry = class {
|
|
57
|
+
constructor() {
|
|
58
|
+
this.byName = /* @__PURE__ */ new Map();
|
|
59
|
+
this.directoryNames = [];
|
|
60
|
+
}
|
|
61
|
+
/** Replace registry contents with the supplied tools. Idempotent. */
|
|
62
|
+
register(tools) {
|
|
63
|
+
this.byName.clear();
|
|
64
|
+
for (const tool of tools) this.byName.set(tool.toolSchema.name, tool);
|
|
65
|
+
this.directoryNames = Object.freeze([...this.byName.keys()].sort());
|
|
66
|
+
logger.debug(`[DeferredToolRegistry] Registered ${tools.length} deferred tool(s)`);
|
|
67
|
+
}
|
|
68
|
+
clear() {
|
|
69
|
+
this.byName.clear();
|
|
70
|
+
this.directoryNames = Object.freeze([]);
|
|
71
|
+
}
|
|
72
|
+
size() {
|
|
73
|
+
return this.byName.size;
|
|
74
|
+
}
|
|
75
|
+
has(name) {
|
|
76
|
+
return this.byName.has(name);
|
|
77
|
+
}
|
|
78
|
+
get(name) {
|
|
79
|
+
return this.byName.get(name);
|
|
80
|
+
}
|
|
81
|
+
getAll() {
|
|
82
|
+
return Array.from(this.byName.values());
|
|
83
|
+
}
|
|
84
|
+
/** Return tools whose names appear in the supplied list, in input order. */
|
|
85
|
+
getByNames(names) {
|
|
86
|
+
const found = [];
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const tool = this.byName.get(name);
|
|
89
|
+
if (tool) found.push(tool);
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Rank-search deferred tools by query terms. Name matches outrank
|
|
95
|
+
* description matches; exact substring on name wins ties.
|
|
96
|
+
*/
|
|
97
|
+
searchByKeywords(query, maxResults) {
|
|
98
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
99
|
+
if (terms.length === 0) return [];
|
|
100
|
+
const scored = [];
|
|
101
|
+
for (const tool of this.byName.values()) {
|
|
102
|
+
const name = tool.toolSchema.name.toLowerCase();
|
|
103
|
+
const desc = (tool.toolSchema.description || "").toLowerCase();
|
|
104
|
+
let score = 0;
|
|
105
|
+
for (const term of terms) {
|
|
106
|
+
if (name.includes(term)) score += 10;
|
|
107
|
+
if (desc.includes(term)) score += 1;
|
|
108
|
+
}
|
|
109
|
+
if (score > 0) scored.push({
|
|
110
|
+
tool,
|
|
111
|
+
score
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
scored.sort((a, b) => b.score - a.score);
|
|
115
|
+
return scored.slice(0, maxResults).map((s) => s.tool);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Directory entries rendered into the cache-stamped system-prompt reminder.
|
|
119
|
+
* Returns the frozen snapshot captured at `register()`, NOT live `byName`
|
|
120
|
+
* keys, so loading a tool mid-session can never change a byte of the cached
|
|
121
|
+
* system block (issue #213). A loaded tool remaining listed here is
|
|
122
|
+
* harmless: re-selecting it via `tool_search` is an idempotent no-op.
|
|
123
|
+
*/
|
|
124
|
+
getDirectoryNames() {
|
|
125
|
+
return [...this.directoryNames];
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const deferredToolRegistry = new DeferredToolRegistry();
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/tools/toolSearchTool.ts
|
|
131
|
+
/**
|
|
132
|
+
* Default number of tools returned for a keyword search. Matches Claude
|
|
133
|
+
* Code's ToolSearch convention. 5 keeps the response payload small while
|
|
134
|
+
* surfacing enough alternatives for the model to refine its query.
|
|
135
|
+
*/
|
|
136
|
+
const DEFAULT_MAX_RESULTS = 5;
|
|
137
|
+
/**
|
|
138
|
+
* Runtime validation for tool_search params. The LLM produces these
|
|
139
|
+
* values, so we validate at this boundary rather than trusting the
|
|
140
|
+
* shape. Coerces `max_results` from string->number for models that emit
|
|
141
|
+
* numeric-looking strings.
|
|
142
|
+
*/
|
|
143
|
+
const ToolSearchParamsSchema = z$1.object({
|
|
144
|
+
query: z$1.string().min(1, "query must be a non-empty string"),
|
|
145
|
+
max_results: z$1.coerce.number().int().min(1).max(20).optional()
|
|
146
|
+
});
|
|
147
|
+
/**
|
|
148
|
+
* Parse the query string. Two forms:
|
|
149
|
+
* - `select:name1,name2,...` - exact-name selection
|
|
150
|
+
* - free text - keyword search across name + description
|
|
151
|
+
*/
|
|
152
|
+
function parseQuery(query) {
|
|
153
|
+
const trimmed = query.trim();
|
|
154
|
+
const selectMatch = trimmed.match(/^select:(.+)$/i);
|
|
155
|
+
if (selectMatch) return {
|
|
156
|
+
mode: "select",
|
|
157
|
+
names: selectMatch[1].split(",").map((n) => n.trim()).filter((n) => n.length > 0)
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
mode: "search",
|
|
161
|
+
text: trimmed
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Format the loaded-tools response. Mirrors Claude Code's convention:
|
|
166
|
+
* one <function>{...}</function> line per matched tool. The model has
|
|
167
|
+
* already seen this format in its tool-registration system messages, so
|
|
168
|
+
* it parses without additional explanation.
|
|
169
|
+
*
|
|
170
|
+
* Note: the schemas are *also* injected into context.tools by the caller,
|
|
171
|
+
* so on the next iteration the model gets them as native tool definitions.
|
|
172
|
+
* The text response here is for in-turn awareness and audit trail.
|
|
173
|
+
*/
|
|
174
|
+
function renderToolsBlock(tools) {
|
|
175
|
+
if (tools.length === 0) return "";
|
|
176
|
+
return `<functions>\n${tools.map((tool) => {
|
|
177
|
+
const schema = {
|
|
178
|
+
description: tool.toolSchema.description,
|
|
179
|
+
name: tool.toolSchema.name,
|
|
180
|
+
parameters: tool.toolSchema.parameters
|
|
181
|
+
};
|
|
182
|
+
return `<function>${JSON.stringify(schema)}</function>`;
|
|
183
|
+
}).join("\n")}\n</functions>`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build the tool_search meta-tool. The returned tool has a closure over
|
|
187
|
+
* the supplied `toolListAccessor`, which it uses to push newly-resolved
|
|
188
|
+
* tool schemas into the live agent context.
|
|
189
|
+
*
|
|
190
|
+
* Idempotent: re-loading a tool that's already in the context is a no-op.
|
|
191
|
+
*/
|
|
192
|
+
function createToolSearchTool(toolListAccessor) {
|
|
193
|
+
return {
|
|
194
|
+
toolSchema: {
|
|
195
|
+
name: "tool_search",
|
|
196
|
+
description: "Fetches full schema definitions for deferred tools so they can be called. Deferred tools appear by name only in a system reminder; their parameter schemas are NOT loaded by default. Use this tool to load schemas on demand. Query forms: 'select:name1,name2' for exact selection, or free-text keywords to search by name and description. Once a tool's schema is returned, it becomes callable in subsequent turns.",
|
|
197
|
+
parameters: {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: {
|
|
200
|
+
query: {
|
|
201
|
+
type: "string",
|
|
202
|
+
description: "Either 'select:<comma-separated names>' to fetch specific tools, or free-text keywords (e.g. 'github pull request') to rank-search deferred tools."
|
|
203
|
+
},
|
|
204
|
+
max_results: {
|
|
205
|
+
type: "number",
|
|
206
|
+
description: `Maximum number of tools to return for keyword search. Defaults to ${DEFAULT_MAX_RESULTS}. Ignored for 'select:' queries.`
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
required: ["query"]
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
toolFn: async (params) => {
|
|
213
|
+
const parsedParams = ToolSearchParamsSchema.safeParse(params ?? {});
|
|
214
|
+
if (!parsedParams.success) {
|
|
215
|
+
const issue = parsedParams.error.issues[0];
|
|
216
|
+
return `tool_search: invalid parameters — ${issue.path.join(".") || "params"}: ${issue.message}`;
|
|
217
|
+
}
|
|
218
|
+
const { query, max_results } = parsedParams.data;
|
|
219
|
+
const parsed = parseQuery(query);
|
|
220
|
+
let matched;
|
|
221
|
+
let unmatched = [];
|
|
222
|
+
if (parsed.mode === "select") {
|
|
223
|
+
matched = deferredToolRegistry.getByNames(parsed.names);
|
|
224
|
+
const foundNames = new Set(matched.map((t) => t.toolSchema.name));
|
|
225
|
+
unmatched = parsed.names.filter((n) => !foundNames.has(n));
|
|
226
|
+
} else {
|
|
227
|
+
const max = max_results ?? DEFAULT_MAX_RESULTS;
|
|
228
|
+
matched = deferredToolRegistry.searchByKeywords(parsed.text, max);
|
|
229
|
+
}
|
|
230
|
+
if (matched.length === 0) return parsed.mode === "select" ? `tool_search: no deferred tools matched ${parsed.names.join(", ")}. Use a free-text query to search.` : `tool_search: no deferred tools matched query "${parsed.text}".`;
|
|
231
|
+
const liveTools = toolListAccessor();
|
|
232
|
+
const liveNames = new Set(liveTools.map((t) => t.toolSchema.name));
|
|
233
|
+
let added = 0;
|
|
234
|
+
for (const tool of matched) if (!liveNames.has(tool.toolSchema.name)) {
|
|
235
|
+
liveTools.push(tool);
|
|
236
|
+
added++;
|
|
237
|
+
}
|
|
238
|
+
logger.debug(`[tool_search] query="${query}" matched=${matched.length} added=${added} alreadyLoaded=${matched.length - added}`);
|
|
239
|
+
const block = renderToolsBlock(matched);
|
|
240
|
+
return `${`Loaded ${added} new tool schema(s)${added < matched.length ? ` (${matched.length - added} already loaded)` : ""}. These are now callable in your next message.${unmatched.length > 0 ? `\n\nNot found: ${unmatched.join(", ")}` : ""}`}\n\n${block}`;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/llm/MultiLlmBackend.ts
|
|
246
|
+
/**
|
|
247
|
+
* Routes completions between B4M server and a local Ollama instance
|
|
248
|
+
* based on the selected model's backend type.
|
|
249
|
+
*/
|
|
250
|
+
var MultiLlmBackend = class {
|
|
251
|
+
constructor(serverBackend, ollamaBackend, serverModels, ollamaModels, initialModel) {
|
|
252
|
+
this.serverBackend = serverBackend;
|
|
253
|
+
this.ollamaBackend = ollamaBackend;
|
|
254
|
+
this.serverModels = serverModels;
|
|
255
|
+
this.ollamaModels = ollamaModels;
|
|
256
|
+
this.currentModel = initialModel;
|
|
257
|
+
this.ollamaModelIds = new Set(ollamaModels.map((m) => m.id));
|
|
258
|
+
}
|
|
259
|
+
get activeBackend() {
|
|
260
|
+
return this.ollamaModelIds.has(this.currentModel) ? this.ollamaBackend : this.serverBackend;
|
|
261
|
+
}
|
|
262
|
+
async complete(model, messages, options, callback) {
|
|
263
|
+
return (this.ollamaModelIds.has(model) ? this.ollamaBackend : this.serverBackend).complete(model, messages, options, callback);
|
|
264
|
+
}
|
|
265
|
+
pushToolMessages(messages, tool, result, thinkingBlocks) {
|
|
266
|
+
this.activeBackend.pushToolMessages(messages, tool, result, thinkingBlocks);
|
|
267
|
+
}
|
|
268
|
+
async getModelInfo() {
|
|
269
|
+
return [...this.serverModels, ...this.ollamaModels];
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/ws/WebSocketConnectionManager.ts
|
|
274
|
+
const useWsPolyfill = typeof globalThis.WebSocket === "undefined";
|
|
275
|
+
const WS = useWsPolyfill ? WebSocket : globalThis.WebSocket;
|
|
276
|
+
/**
|
|
277
|
+
* Manages a persistent WebSocket connection for CLI <-> server communication.
|
|
278
|
+
* Handles heartbeat, reconnection, and message routing by requestId.
|
|
279
|
+
*
|
|
280
|
+
* Uses Node.js built-in WebSocket (Node 22+) with `ws` package fallback for Node 20.
|
|
281
|
+
*/
|
|
282
|
+
var WebSocketConnectionManager = class {
|
|
283
|
+
/**
|
|
284
|
+
* @param verifySession - Optional. Called when a connect ATTEMPT fails (the socket closed
|
|
285
|
+
* without ever opening) - exactly the signal an auth-rejected handshake produces. Omit to
|
|
286
|
+
* preserve the old always-retry-forever behavior.
|
|
287
|
+
*/
|
|
288
|
+
constructor(wsUrl, getToken, verifySession) {
|
|
289
|
+
this.ws = null;
|
|
290
|
+
this.heartbeatInterval = null;
|
|
291
|
+
this.reconnectAttempts = 0;
|
|
292
|
+
this.maxReconnectDelay = 3e4;
|
|
293
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
294
|
+
this.actionHandlers = /* @__PURE__ */ new Map();
|
|
295
|
+
this.disconnectHandlers = /* @__PURE__ */ new Set();
|
|
296
|
+
this.revokedHandlers = /* @__PURE__ */ new Set();
|
|
297
|
+
this.reconnectTimer = null;
|
|
298
|
+
this.connected = false;
|
|
299
|
+
this.connecting = false;
|
|
300
|
+
this.closed = false;
|
|
301
|
+
this.openedThisAttempt = false;
|
|
302
|
+
this.verifyingSession = false;
|
|
303
|
+
this.revoked = false;
|
|
304
|
+
this.wsUrl = wsUrl;
|
|
305
|
+
this.getToken = getToken;
|
|
306
|
+
this.verifySession = verifySession;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Connect to the WebSocket server.
|
|
310
|
+
* Resolves when connection is established, rejects on failure.
|
|
311
|
+
*/
|
|
312
|
+
async connect() {
|
|
313
|
+
if (this.connected || this.connecting) return;
|
|
314
|
+
this.connecting = true;
|
|
315
|
+
this.openedThisAttempt = false;
|
|
316
|
+
const token = await this.getToken();
|
|
317
|
+
if (!token) {
|
|
318
|
+
this.connecting = false;
|
|
319
|
+
throw new Error("No access token available for WebSocket connection");
|
|
320
|
+
}
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
logger.debug(`[WS] Connecting to ${this.wsUrl}...`);
|
|
323
|
+
if (useWsPolyfill) this.ws = new WebSocket(this.wsUrl, { headers: { "Sec-WebSocket-Protocol": `access_token.${token}` } });
|
|
324
|
+
else this.ws = new WS(this.wsUrl, [`access_token.${token}`]);
|
|
325
|
+
this.ws.onopen = () => {
|
|
326
|
+
logger.debug("[WS] Connected");
|
|
327
|
+
this.connected = true;
|
|
328
|
+
this.connecting = false;
|
|
329
|
+
this.openedThisAttempt = true;
|
|
330
|
+
this.reconnectAttempts = 0;
|
|
331
|
+
this.startHeartbeat();
|
|
332
|
+
resolve();
|
|
333
|
+
};
|
|
334
|
+
this.ws.onmessage = (event) => {
|
|
335
|
+
try {
|
|
336
|
+
const data = typeof event.data === "string" ? event.data : event.data.toString();
|
|
337
|
+
const message = JSON.parse(data);
|
|
338
|
+
const requestId = message.requestId;
|
|
339
|
+
if (requestId && this.handlers.has(requestId)) this.handlers.get(requestId)(message);
|
|
340
|
+
else {
|
|
341
|
+
const action = message.action;
|
|
342
|
+
if (action && this.actionHandlers.has(action)) this.actionHandlers.get(action)(message);
|
|
343
|
+
else logger.debug(`[WS] Unhandled message: ${action || "unknown"}`);
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
logger.debug(`[WS] Failed to parse message: ${err}`);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
this.ws.onclose = () => {
|
|
350
|
+
logger.debug("[WS] Connection closed");
|
|
351
|
+
const openedThisAttempt = this.openedThisAttempt;
|
|
352
|
+
this.cleanup();
|
|
353
|
+
this.notifyDisconnect();
|
|
354
|
+
if (this.closed || this.revoked) return;
|
|
355
|
+
if (openedThisAttempt || !this.verifySession) {
|
|
356
|
+
this.scheduleReconnect();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.verifyThenReconnect();
|
|
360
|
+
};
|
|
361
|
+
this.ws.onerror = (err) => {
|
|
362
|
+
const detail = err.error?.message || String(err);
|
|
363
|
+
logger.debug(`[WS] Error: ${detail}`);
|
|
364
|
+
if (this.connecting) {
|
|
365
|
+
this.connecting = false;
|
|
366
|
+
this.connected = false;
|
|
367
|
+
reject(/* @__PURE__ */ new Error(`WebSocket connection failed: ${detail}`));
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/** Whether the connection is currently established */
|
|
373
|
+
get isConnected() {
|
|
374
|
+
return this.connected;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Send a JSON message over the WebSocket connection.
|
|
378
|
+
*/
|
|
379
|
+
send(data) {
|
|
380
|
+
if (!this.ws || this.ws.readyState !== WS.OPEN) throw new Error("WebSocket is not connected");
|
|
381
|
+
const payload = JSON.stringify(data);
|
|
382
|
+
const sizeKB = (payload.length / 1024).toFixed(1);
|
|
383
|
+
logger.debug(`[WS] Sending ${sizeKB} KB (action: ${data.action})`);
|
|
384
|
+
if (payload.length > 32e3) logger.warn(`[WS] Payload ${sizeKB} KB exceeds API Gateway 32 KB frame limit — connection will be closed`);
|
|
385
|
+
this.ws.send(payload);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Register a handler for messages matching a specific requestId.
|
|
389
|
+
*/
|
|
390
|
+
onRequest(requestId, handler) {
|
|
391
|
+
this.handlers.set(requestId, handler);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Remove a handler for a specific requestId.
|
|
395
|
+
*/
|
|
396
|
+
offRequest(requestId) {
|
|
397
|
+
this.handlers.delete(requestId);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Register a handler for messages matching a specific action type.
|
|
401
|
+
* Used for server-pushed commands like keep_command.
|
|
402
|
+
*/
|
|
403
|
+
onAction(action, handler) {
|
|
404
|
+
this.actionHandlers.set(action, handler);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Remove a handler for a specific action type.
|
|
408
|
+
*/
|
|
409
|
+
offAction(action) {
|
|
410
|
+
this.actionHandlers.delete(action);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Register a handler that fires when the connection drops.
|
|
414
|
+
*/
|
|
415
|
+
onDisconnect(handler) {
|
|
416
|
+
this.disconnectHandlers.add(handler);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Remove a disconnect handler.
|
|
420
|
+
*/
|
|
421
|
+
offDisconnect(handler) {
|
|
422
|
+
this.disconnectHandlers.delete(handler);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Register a handler that fires once the session is confirmed revoked (verifySession
|
|
426
|
+
* returned false) and the reconnect loop has permanently stopped.
|
|
427
|
+
*/
|
|
428
|
+
onRevoked(handler) {
|
|
429
|
+
this.revokedHandlers.add(handler);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Remove a revoked handler.
|
|
433
|
+
*/
|
|
434
|
+
offRevoked(handler) {
|
|
435
|
+
this.revokedHandlers.delete(handler);
|
|
436
|
+
}
|
|
437
|
+
/** Whether the session has been confirmed revoked (reconnecting has permanently stopped). */
|
|
438
|
+
get isRevoked() {
|
|
439
|
+
return this.revoked;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Close the connection and stop all heartbeat/reconnect logic.
|
|
443
|
+
*/
|
|
444
|
+
disconnect() {
|
|
445
|
+
this.closed = true;
|
|
446
|
+
this.cleanup();
|
|
447
|
+
if (this.ws) {
|
|
448
|
+
this.ws.close();
|
|
449
|
+
this.ws = null;
|
|
450
|
+
}
|
|
451
|
+
this.handlers.clear();
|
|
452
|
+
this.actionHandlers.clear();
|
|
453
|
+
this.disconnectHandlers.clear();
|
|
454
|
+
this.revokedHandlers.clear();
|
|
455
|
+
}
|
|
456
|
+
startHeartbeat() {
|
|
457
|
+
this.stopHeartbeat();
|
|
458
|
+
this.heartbeatInterval = setInterval(() => {
|
|
459
|
+
if (this.ws && this.ws.readyState === WS.OPEN) {
|
|
460
|
+
this.ws.send(JSON.stringify({ action: "heartbeat" }));
|
|
461
|
+
logger.debug("[WS] Heartbeat sent");
|
|
462
|
+
}
|
|
463
|
+
}, 3e5);
|
|
464
|
+
}
|
|
465
|
+
stopHeartbeat() {
|
|
466
|
+
if (this.heartbeatInterval) {
|
|
467
|
+
clearInterval(this.heartbeatInterval);
|
|
468
|
+
this.heartbeatInterval = null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
cleanup() {
|
|
472
|
+
this.connected = false;
|
|
473
|
+
this.connecting = false;
|
|
474
|
+
this.stopHeartbeat();
|
|
475
|
+
if (this.reconnectTimer) {
|
|
476
|
+
clearTimeout(this.reconnectTimer);
|
|
477
|
+
this.reconnectTimer = null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
notifyDisconnect() {
|
|
481
|
+
for (const handler of this.disconnectHandlers) try {
|
|
482
|
+
handler();
|
|
483
|
+
} catch {}
|
|
484
|
+
}
|
|
485
|
+
notifyRevoked() {
|
|
486
|
+
for (const handler of this.revokedHandlers) try {
|
|
487
|
+
handler();
|
|
488
|
+
} catch {}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Called when a connect attempt fails to open at all - the signal a 401 handshake refusal
|
|
492
|
+
* produces. Verifies the session via the injected `verifySession` (single-flighted) before
|
|
493
|
+
* deciding whether to keep retrying. A verification that itself errors (network blip, 5xx)
|
|
494
|
+
* is treated as transient - only an explicit `false` result stops the loop.
|
|
495
|
+
*/
|
|
496
|
+
async verifyThenReconnect() {
|
|
497
|
+
if (this.verifyingSession) return;
|
|
498
|
+
this.verifyingSession = true;
|
|
499
|
+
try {
|
|
500
|
+
if (!await this.verifySession()) {
|
|
501
|
+
logger.debug("[WS] Session verification failed - session revoked, stopping reconnect");
|
|
502
|
+
this.revoked = true;
|
|
503
|
+
this.notifyRevoked();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logger.debug(`[WS] Session verification errored - treating as transient: ${err}`);
|
|
508
|
+
} finally {
|
|
509
|
+
this.verifyingSession = false;
|
|
510
|
+
}
|
|
511
|
+
this.scheduleReconnect();
|
|
512
|
+
}
|
|
513
|
+
scheduleReconnect() {
|
|
514
|
+
if (this.closed || this.revoked) return;
|
|
515
|
+
this.reconnectAttempts++;
|
|
516
|
+
const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
|
|
517
|
+
logger.debug(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
518
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
519
|
+
this.reconnectTimer = null;
|
|
520
|
+
if (this.closed) return;
|
|
521
|
+
try {
|
|
522
|
+
await this.connect();
|
|
523
|
+
} catch {
|
|
524
|
+
logger.debug("[WS] Reconnection failed");
|
|
525
|
+
}
|
|
526
|
+
}, delay);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/bootstrap/buildLlmBackend.ts
|
|
531
|
+
/** Production wiring: real transport classes + the ToolRouter singleton. */
|
|
532
|
+
const defaultLlmBackendDeps = {
|
|
533
|
+
connectWebSocket: async (wsUrl, tokenGetter, verifySession) => {
|
|
534
|
+
const ws = new WebSocketConnectionManager(wsUrl, tokenGetter, verifySession);
|
|
535
|
+
ws.onRevoked(() => {
|
|
536
|
+
logger.warn("Session revoked - run `b4m login` again. WebSocket reconnect stopped.");
|
|
537
|
+
});
|
|
538
|
+
try {
|
|
539
|
+
await ws.connect();
|
|
540
|
+
} catch (err) {
|
|
541
|
+
ws.disconnect();
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
return ws;
|
|
545
|
+
},
|
|
546
|
+
clearWebSocketToolExecutor: () => setWebSocketToolExecutor(null),
|
|
547
|
+
createServerBackend: (opts) => new ServerLlmBackend(opts),
|
|
548
|
+
createOllamaBackend: (host) => new OllamaBackend(host, {
|
|
549
|
+
debug: (...args) => logger.debug(args.map(String).join(" ")),
|
|
550
|
+
info: (...args) => logger.info(args.map(String).join(" ")),
|
|
551
|
+
warn: (...args) => logger.warn(args.map(String).join(" ")),
|
|
552
|
+
error: (...args) => logger.error(args.map(String).join(" "))
|
|
553
|
+
}),
|
|
554
|
+
createMultiBackend: (server, ollama, serverModels, ollamaModels, defaultModel) => new MultiLlmBackend(server, ollama, serverModels, ollamaModels, defaultModel)
|
|
555
|
+
};
|
|
556
|
+
/**
|
|
557
|
+
* True when some enabled feature module consumes realtime server events. Only
|
|
558
|
+
* Tavern does today (TavernModule.registerWsHandlers -> TavernActivityStream);
|
|
559
|
+
* keep this in sync with the module registration in index.tsx. Everything else
|
|
560
|
+
* runs socket-free, so the common path never opens a WebSocket.
|
|
561
|
+
*/
|
|
562
|
+
function needsFeatureEventSocket(config) {
|
|
563
|
+
return config.features?.tavern === true;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Connect the events-only socket that feature modules register handlers on.
|
|
567
|
+
* Returns null when it isn't needed, isn't advertised, or won't connect - a
|
|
568
|
+
* feature's live updates degrading is never a reason to fail startup, since
|
|
569
|
+
* completions no longer depend on this socket at all.
|
|
570
|
+
*/
|
|
571
|
+
async function connectFeatureEventSocket(config, websocketUrl, deps, auth) {
|
|
572
|
+
if (!needsFeatureEventSocket(config)) return null;
|
|
573
|
+
if (!websocketUrl) {
|
|
574
|
+
logger.debug("[WS] No websocketUrl in server config - feature live updates disabled");
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
return await deps.connectWebSocket(websocketUrl, auth.tokenGetter, auth.verifySession);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
logger.warn(`Realtime socket unavailable - live feature updates are disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Resolve the model to use from the available list: the requested default if
|
|
586
|
+
* present, otherwise the first available model. Pure - exported for testing.
|
|
587
|
+
*/
|
|
588
|
+
function resolveModelInfo(models, defaultModel) {
|
|
589
|
+
return models.find((m) => m.id === defaultModel) || models[0];
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Build the LLM backend: HTTP+SSE transport (ServerLlmBackend), optional Ollama
|
|
593
|
+
* multiplexing. Resolves the default model and pins it on the backend.
|
|
594
|
+
*
|
|
595
|
+
* The WebSocket COMPLETION transport was removed - completions always use SSE,
|
|
596
|
+
* because relays that emit the generic `streamed_chat_completion` action drop
|
|
597
|
+
* every CLI chunk. The socket itself is still connected, but only when a
|
|
598
|
+
* WS-consuming feature module is enabled, and only to carry that module's events
|
|
599
|
+
* (see `wsManager`); Keep relay and WS server-side tool execution stay off.
|
|
600
|
+
*
|
|
601
|
+
* Pure bootstrap seam: no React hooks, no Zustand state.
|
|
602
|
+
*/
|
|
603
|
+
async function buildLlmBackend(input, deps = defaultLlmBackendDeps) {
|
|
604
|
+
const { config, apiClient, startupLog, tokenGetter } = input;
|
|
605
|
+
const sse = await createSseBackend({
|
|
606
|
+
apiClient,
|
|
607
|
+
model: config.defaultModel
|
|
608
|
+
}, {
|
|
609
|
+
createServerBackend: deps.createServerBackend,
|
|
610
|
+
clearWebSocketToolExecutor: deps.clearWebSocketToolExecutor
|
|
611
|
+
});
|
|
612
|
+
let llm = sse.llm;
|
|
613
|
+
const wsManager = await connectFeatureEventSocket(config, sse.serverConfig.websocketUrl, deps, {
|
|
614
|
+
tokenGetter,
|
|
615
|
+
verifySession: () => apiClient.checkSessionValid()
|
|
616
|
+
});
|
|
617
|
+
const ollamaHost = input.ollamaHost ?? process.env.B4M_OLLAMA_HOST;
|
|
618
|
+
let models;
|
|
619
|
+
if (ollamaHost) {
|
|
620
|
+
const ollamaBackend = deps.createOllamaBackend(ollamaHost);
|
|
621
|
+
const [serverModels, ollamaModels] = await Promise.all([llm.getModelInfo(), ollamaBackend.getModelInfo()]);
|
|
622
|
+
if (serverModels.length === 0 && ollamaModels.length === 0) throw new Error(`No models available from server or Ollama at ${ollamaHost}.\nPull a model: ollama pull qwen3.5`);
|
|
623
|
+
if (ollamaModels.length === 0) startupLog.push(`⚠️ No models found in Ollama at ${ollamaHost}. Pull one with: ollama pull qwen3.5`);
|
|
624
|
+
const serverBackend = llm;
|
|
625
|
+
llm = deps.createMultiBackend(serverBackend, ollamaBackend, serverModels, ollamaModels, config.defaultModel);
|
|
626
|
+
models = await llm.getModelInfo();
|
|
627
|
+
startupLog.push(`🦙 Self-hosted Ollama: ${ollamaModels.length} model(s) added to picker`);
|
|
628
|
+
} else {
|
|
629
|
+
models = await llm.getModelInfo();
|
|
630
|
+
if (models.length === 0) throw new Error("No models available from server.");
|
|
631
|
+
}
|
|
632
|
+
logger.debug(`📋 Available models: ${models.map((m) => m.id).join(", ")}`);
|
|
633
|
+
const modelInfo = resolveModelInfo(models, config.defaultModel);
|
|
634
|
+
if (modelInfo.id !== config.defaultModel) {
|
|
635
|
+
logger.warn(`⚠️ Requested model '${config.defaultModel}' not available`);
|
|
636
|
+
logger.warn(`🤖 Using fallback model: ${modelInfo.id}`);
|
|
637
|
+
}
|
|
638
|
+
llm.currentModel = modelInfo.id;
|
|
639
|
+
return {
|
|
640
|
+
llm,
|
|
641
|
+
wsManager,
|
|
642
|
+
models,
|
|
643
|
+
modelInfo
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/bootstrap/buildSandbox.ts
|
|
648
|
+
/**
|
|
649
|
+
* Initialize the sandbox orchestrator for OS-level filesystem isolation, wire
|
|
650
|
+
* the network-proxy event handler, attach the violation store, and start the
|
|
651
|
+
* proxy when enabled.
|
|
652
|
+
*
|
|
653
|
+
* Pure bootstrap seam: no React hooks, no Zustand state. Sandbox modules are
|
|
654
|
+
* imported dynamically (as before) so the cost is only paid when init runs.
|
|
655
|
+
*/
|
|
656
|
+
async function buildSandbox(input) {
|
|
657
|
+
const { config, sessionId, permissionManager, checkpointStore } = input;
|
|
658
|
+
const [{ createSandboxRuntime }, { SandboxOrchestrator }, { DEFAULT_SANDBOX_CONFIG }, { ProxyManager }, { ViolationLogStore }] = await Promise.all([
|
|
659
|
+
import("./SandboxRuntimeAdapter-CKelGICD.mjs"),
|
|
660
|
+
import("./SandboxOrchestrator-BFPVpmB5.mjs"),
|
|
661
|
+
import("./types-CqscS34o.mjs"),
|
|
662
|
+
import("./ProxyManager-Bqr7Lmsd.mjs"),
|
|
663
|
+
import("./ViolationLogStore-byEhxa2A.mjs")
|
|
664
|
+
]);
|
|
665
|
+
const sandboxConfig = config.sandbox ?? DEFAULT_SANDBOX_CONFIG;
|
|
666
|
+
const [sandboxRuntime] = await Promise.all([createSandboxRuntime(), checkpointStore.init(sessionId).catch(() => {})]);
|
|
667
|
+
const proxyManager = new ProxyManager(sandboxConfig.network);
|
|
668
|
+
const sandboxOrchestrator = new SandboxOrchestrator(sandboxConfig, sandboxRuntime, proxyManager);
|
|
669
|
+
proxyManager.onEvent((event) => {
|
|
670
|
+
if (event.type === "blocked") {
|
|
671
|
+
console.error(`\n\x1b[41m\x1b[97m BLOCKED \x1b[0m \x1b[31mNetwork proxy denied connection to\x1b[0m \x1b[1m${event.domain}\x1b[0m \x1b[90m(${event.method})\x1b[0m`);
|
|
672
|
+
console.error(`\x1b[90m Tip: /sandbox:trust-domain ${event.domain}\x1b[0m\n`);
|
|
673
|
+
sandboxOrchestrator.recordViolation({
|
|
674
|
+
type: "network",
|
|
675
|
+
domain: event.domain,
|
|
676
|
+
command: `[network] ${event.method} ${event.domain}`,
|
|
677
|
+
blockedBy: "proxy",
|
|
678
|
+
timestamp: event.timestamp,
|
|
679
|
+
detail: `Blocked ${event.method} to ${event.domain}`
|
|
680
|
+
}).catch(() => {});
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
const violationStore = new ViolationLogStore();
|
|
684
|
+
sandboxOrchestrator.setViolationStore(violationStore);
|
|
685
|
+
permissionManager.setSandboxState(sandboxConfig.mode, sandboxOrchestrator.isActive());
|
|
686
|
+
if (sandboxConfig.enabled && sandboxConfig.mode !== "disabled") {
|
|
687
|
+
if (sandboxRuntime) console.log(`🔒 Sandbox: ${sandboxConfig.mode} (${sandboxRuntime.name})`);
|
|
688
|
+
else console.log("⚠️ Sandbox: enabled but runtime not available on this platform");
|
|
689
|
+
if (sandboxConfig.network.enabled) {
|
|
690
|
+
await proxyManager.start();
|
|
691
|
+
if (proxyManager.isRunning()) console.log(`🌐 Network proxy: filtering on port ${proxyManager.getPort()} (${sandboxConfig.network.allowedDomains.length} domains)`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { sandboxOrchestrator };
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/bootstrap/buildSupportingStores.ts
|
|
698
|
+
/**
|
|
699
|
+
* Build the supporting stores and orchestration the agent needs: CLI tools
|
|
700
|
+
* (permission-wrapped + server-routed), MCP manager, agent store, context
|
|
701
|
+
* files, the deferred-tool registry partition, the subagent orchestrator, and
|
|
702
|
+
* the background-agent manager.
|
|
703
|
+
*
|
|
704
|
+
* Pure bootstrap seam: no React hooks, no Zustand state. React-owned values
|
|
705
|
+
* (permission/user-question prompt functions, the agent context, and the
|
|
706
|
+
* background-agent status callbacks) are passed in. Tool *assembly* that weaves
|
|
707
|
+
* the React workflow-store refs (decision/blocker/review-gate tools) stays in
|
|
708
|
+
* the shell; this module returns only the agent-construction materials.
|
|
709
|
+
*/
|
|
710
|
+
async function buildSupportingStores(input) {
|
|
711
|
+
const { config, llm, modelId, permissionManager, apiClient, configStore, customCommandStore, checkpointStore, sandboxOrchestrator, additionalDirectories, agentContext, promptFn, userQuestionFn, startupLog, silentLogger, onBackgroundStatusChange, onGroupCompletion, onSubagentUsage } = input;
|
|
712
|
+
const { tools: b4mTools } = await generateCliTools(config.userId, llm, modelId, permissionManager, promptFn, agentContext, configStore, apiClient, void 0, userQuestionFn, checkpointStore, sandboxOrchestrator, additionalDirectories);
|
|
713
|
+
const mcpManager = new McpManager(config);
|
|
714
|
+
const builtinAgentsDir = new URL("../agents/defaults/", import.meta.url).pathname;
|
|
715
|
+
const agentProjectDir = configStore.getProjectConfigDir();
|
|
716
|
+
const agentStore = new AgentStore(builtinAgentsDir, agentProjectDir || process.cwd());
|
|
717
|
+
const [, , contextResult] = await Promise.all([
|
|
718
|
+
mcpManager.initialize(),
|
|
719
|
+
agentStore.loadAgents(),
|
|
720
|
+
loadContextFiles(agentProjectDir)
|
|
721
|
+
]);
|
|
722
|
+
const mcpTools = mcpManager.getTools();
|
|
723
|
+
const deferredB4mToolNames = /* @__PURE__ */ new Set([
|
|
724
|
+
"math_evaluate",
|
|
725
|
+
"dice_roll",
|
|
726
|
+
"current_datetime",
|
|
727
|
+
"recent_changes",
|
|
728
|
+
"prompt_enhancement"
|
|
729
|
+
]);
|
|
730
|
+
const deferredB4mTools = b4mTools.filter((t) => deferredB4mToolNames.has(t.toolSchema.name));
|
|
731
|
+
const loadedB4mTools = b4mTools.filter((t) => !deferredB4mToolNames.has(t.toolSchema.name));
|
|
732
|
+
deferredToolRegistry.register([...mcpTools, ...deferredB4mTools]);
|
|
733
|
+
if (mcpTools.length > 0) {
|
|
734
|
+
const serverSummaries = mcpManager.getToolCount().map((s) => `${s.serverName} (${s.count})`).join(", ");
|
|
735
|
+
startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M + ${mcpTools.length} MCP tool(s, ${deferredB4mTools.length + mcpTools.length} deferred): ${serverSummaries}`);
|
|
736
|
+
} else {
|
|
737
|
+
const suffix = deferredB4mTools.length > 0 ? ` (${deferredB4mTools.length} deferred)` : "";
|
|
738
|
+
startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M tool(s)${suffix}, no MCP tools`);
|
|
739
|
+
}
|
|
740
|
+
const agentSummary = agentStore.getSummary();
|
|
741
|
+
startupLog.push(`🤖 Loaded ${agentSummary.total} agent(s): ${agentSummary.builtin} built-in, ${agentSummary.global} global, ${agentSummary.project} project`);
|
|
742
|
+
const historyStore = new AgentHistoryStore(config.preferences.subagentHistoryTtlMs ?? 36e5);
|
|
743
|
+
const orchestrator = new SubagentOrchestrator({
|
|
744
|
+
userId: config.userId,
|
|
745
|
+
llm,
|
|
746
|
+
logger: silentLogger,
|
|
747
|
+
permissionManager,
|
|
748
|
+
showPermissionPrompt: promptFn,
|
|
749
|
+
configStore,
|
|
750
|
+
apiClient,
|
|
751
|
+
agentStore,
|
|
752
|
+
customCommandStore,
|
|
753
|
+
enableParallelToolExecution: config.preferences.enableParallelToolExecution === true,
|
|
754
|
+
showUserQuestion: userQuestionFn,
|
|
755
|
+
checkpointStore,
|
|
756
|
+
onSubagentUsage,
|
|
757
|
+
historyStore
|
|
758
|
+
});
|
|
759
|
+
const backgroundManager = new BackgroundAgentManager(orchestrator);
|
|
760
|
+
backgroundManager.setOnStatusChange(onBackgroundStatusChange);
|
|
761
|
+
backgroundManager.setOnGroupCompletion(onGroupCompletion);
|
|
762
|
+
return {
|
|
763
|
+
mcpManager,
|
|
764
|
+
agentStore,
|
|
765
|
+
contextResult,
|
|
766
|
+
mcpTools,
|
|
767
|
+
loadedB4mTools,
|
|
768
|
+
deferredB4mTools,
|
|
769
|
+
orchestrator,
|
|
770
|
+
backgroundManager,
|
|
771
|
+
historyStore
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/bootstrap/buildAgent.ts
|
|
776
|
+
/**
|
|
777
|
+
* Construct the main ReAct agent with the system prompt selected by config
|
|
778
|
+
* variant, wire the tool_search closure to the agent's live tools array, and
|
|
779
|
+
* record the agent in the shared observation context.
|
|
780
|
+
*
|
|
781
|
+
* Pure bootstrap seam: no React hooks, no Zustand state. The interaction-mode
|
|
782
|
+
* subscription (`useCliStore.subscribe`) stays in the shell and uses the
|
|
783
|
+
* returned `buildPromptForMode`. Ordering is load-bearing: agent built ->
|
|
784
|
+
* agentToolsRef wired -> agentContext.currentAgent set, all here, before the
|
|
785
|
+
* shell registers the subscription (which guards on currentAgent === agent).
|
|
786
|
+
*/
|
|
787
|
+
function buildAgent(input) {
|
|
788
|
+
const { config, modelId, notifyingLlm, allTools, agentContext, agentToolsRef, silentLogger, sessionId, initialInteractionMode, contextContent, agentStore, customCommandStore, enableSkillTool, additionalDirectories, featureModulePrompts } = input;
|
|
789
|
+
const promptVariant = config.preferences.promptVariant ?? "current";
|
|
790
|
+
const buildPromptForMode = (mode) => buildSystemPrompt(promptVariant, {
|
|
791
|
+
contextContent,
|
|
792
|
+
agentStore,
|
|
793
|
+
customCommands: customCommandStore.getAllCommands(),
|
|
794
|
+
enableSkillTool,
|
|
795
|
+
enableDynamicAgentCreation: config.preferences.enableDynamicAgentCreation === true,
|
|
796
|
+
additionalDirectories,
|
|
797
|
+
featureModulePrompts: featureModulePrompts || void 0,
|
|
798
|
+
planModeFilePath: mode === "plan" ? getPlanModeFilePath(sessionId) : void 0,
|
|
799
|
+
appendSystemPrompt: process.env.B4M_APPEND_SYSTEM_PROMPT,
|
|
800
|
+
deferredToolNames: deferredToolRegistry.getDirectoryNames()
|
|
801
|
+
});
|
|
802
|
+
const cliSystemPrompt = buildPromptForMode(initialInteractionMode);
|
|
803
|
+
const maxIterations = config.preferences.maxIterations === null ? 999999 : config.preferences.maxIterations;
|
|
804
|
+
const agent = new ReActAgent({
|
|
805
|
+
userId: config.userId,
|
|
806
|
+
logger: silentLogger,
|
|
807
|
+
llm: notifyingLlm,
|
|
808
|
+
model: modelId,
|
|
809
|
+
tools: allTools,
|
|
810
|
+
maxIterations,
|
|
811
|
+
maxTokens: config.preferences.maxTokens,
|
|
812
|
+
temperature: config.preferences.temperature,
|
|
813
|
+
systemPrompt: cliSystemPrompt,
|
|
814
|
+
unknownToolResolver: async (toolName) => deferredToolRegistry.get(toolName) ?? null
|
|
815
|
+
});
|
|
816
|
+
agentToolsRef.current = agent.getTools();
|
|
817
|
+
agentContext.currentAgent = agent;
|
|
818
|
+
return {
|
|
819
|
+
agent,
|
|
820
|
+
buildPromptForMode
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
//#endregion
|
|
824
|
+
export { createToolSearchTool as a, buildLlmBackend as i, buildSupportingStores as n, deferredToolRegistry as o, buildSandbox as r, NotifyingLlmBackend as s, buildAgent as t };
|