@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.
Files changed (41) hide show
  1. package/LICENSE +17 -3
  2. package/README.md +204 -35
  3. package/bin/bike4mind-cli.mjs +137 -24
  4. package/bin/hearth-hook.mjs +292 -0
  5. package/dist/AgentHistoryStore-C8uUKjjC.mjs +35512 -0
  6. package/dist/ApiClient-B_CQrUiF.mjs +277 -0
  7. package/dist/{ConfigStore-Cq20962p.mjs → ConfigStore-DD3DcC3-.mjs} +6256 -3911
  8. package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
  9. package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
  10. package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
  11. package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
  12. package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
  13. package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
  14. package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
  15. package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
  16. package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
  17. package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
  18. package/dist/buildAgent-mVuXU_H4.mjs +824 -0
  19. package/dist/commands/acpCommand.mjs +798 -0
  20. package/dist/commands/apiCommand.mjs +14 -16
  21. package/dist/commands/doctorCommand.mjs +5 -5
  22. package/dist/commands/envCommand.mjs +1 -1
  23. package/dist/commands/headlessCommand.mjs +272 -76
  24. package/dist/commands/mcpCommand.mjs +14 -1
  25. package/dist/commands/pluginCommand.mjs +232 -0
  26. package/dist/commands/updateCommand.mjs +10 -9
  27. package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
  28. package/dist/index.mjs +3281 -2322
  29. package/dist/{package-CBaK53NX.mjs → package-BqKSCbso.mjs} +1 -1
  30. package/dist/serve-CuF0I5en.mjs +772 -0
  31. package/dist/store-BG3e54c8.mjs +3 -0
  32. package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
  33. package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
  34. package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
  35. package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
  36. package/package.json +48 -43
  37. package/dist/BackgroundAgentManager-DOesheMD.mjs +0 -27171
  38. package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
  39. package/dist/store-DgzCTRkN.mjs +0 -3
  40. package/dist/utils-Cdktpk_k.mjs +0 -158
  41. 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 };