@bike4mind/cli 0.16.0 → 0.17.1

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/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { $ as SessionStore, A as substituteArguments, B as DEFAULT_RETRY_CONFIG, C as WebSocketToolExecutor, D as ServerLlmBackend, E as WebSocketLlmBackend, F as generateCliTools, G as getPlanModeFilePath, H as clearFeatureModuleTools, I as getProcessHooks, J as isReadOnlyTool, K as buildSystemPrompt, L as ALWAYS_DENIED_FOR_AGENTS, M as extractCompactInstructions, N as loadContextFiles, O as isTransientNetworkError, P as PermissionManager, Q as CommandHistoryStore, R as DEFAULT_AGENT_MODEL, S as ApiClient, T as FallbackLlmBackend, U as registerFeatureModuleTools, V as DEFAULT_THOROUGHNESS, W as setWebSocketToolExecutor, X as CustomCommandStore, Y as ReActAgent, Z as CheckpointStore, _ as createAgentDelegateTool, a as createBlockerTools, at as formatFileSize, b as createSkillTool, c as createDecisionStore, d as createFindDefinitionTool, et as OAuthClient, f as createTodoStore, g as BackgroundAgentManager, h as createBackgroundAgentTools, i as createBlockerStore, it as mergeCommands, j as formatStep, k as McpManager, l as formatDecisionsOutput, m as createCoordinateTaskTool, n as createReviewGateTool, nt as processFileReferences, o as formatBlockersOutput, ot as searchFiles, p as createWriteTodosTool, q as buildSkillsPromptSection, r as formatReviewGatesOutput, rt as searchCommands, s as createDecisionLogTool, st as warmFileCache, t as createReviewGateStore, tt as hasFileReferences, u as createGetFileStructureTool, v as AgentStore, w as WebSocketConnectionManager, x as parseAgentConfig, y as SubagentOrchestrator, z as DEFAULT_MAX_ITERATIONS } from "./tools-D9eSRR7Q.mjs";
2
+ import { $ as CommandHistoryStore, A as substituteArguments, B as DEFAULT_RETRY_CONFIG, C as createCoordinateTaskTool, D as FallbackLlmBackend, E as ApiClient, F as generateCliTools, G as ReActAgent, H as clearFeatureModuleTools, I as getProcessHooks, J as buildSkillsPromptSection, K as getPlanModeFilePath, L as ALWAYS_DENIED_FOR_AGENTS, M as extractCompactInstructions, N as loadContextFiles, O as ServerLlmBackend, P as PermissionManager, Q as CheckpointStore, R as DEFAULT_AGENT_MODEL, S as parseAgentConfig, T as createAgentDelegateTool, U as registerFeatureModuleTools, V as DEFAULT_THOROUGHNESS, W as setWebSocketToolExecutor, X as RemoteSkillSource, Y as isReadOnlyTool, Z as CustomCommandStore, _ as createGetFileStructureTool, a as WebSocketToolExecutor, at as mergeCommands, b as createWriteTodosTool, c as createReviewGateStore, ct as warmFileCache, d as createBlockerStore, et as SessionStore, f as createBlockerTools, g as formatDecisionsOutput, h as createDecisionStore, i as McpManager, it as searchCommands, j as formatStep, k as isTransientNetworkError, l as createReviewGateTool, m as createDecisionLogTool, n as SubagentOrchestrator, nt as hasFileReferences, o as WebSocketConnectionManager, ot as formatFileSize, p as formatBlockersOutput, q as buildSystemPrompt, r as AgentStore, rt as processFileReferences, s as WebSocketLlmBackend, st as searchFiles, t as BackgroundAgentManager, tt as OAuthClient, u as formatReviewGatesOutput, v as createFindDefinitionTool, w as createBackgroundAgentTools, x as createSkillTool, y as createTodoStore, z as DEFAULT_MAX_ITERATIONS } from "./BackgroundAgentManager-C5HEirFN.mjs";
3
3
  import { n as useCliStore, t as selectActiveBackgroundAgents } from "./store-DV5s-qni.mjs";
4
- import { Gt as validateNotebookPath$1, Wt as validateJupyterKernelName, g as CREDIT_DEDUCT_TRANSACTION_TYPES, i as getEnvironmentName, n as logger, r as getApiUrl, t as ConfigStore, v as ChatModels } from "./ConfigStore-CtGc4fF2.mjs";
5
- import { t as version } from "./package-3pouvthv.mjs";
4
+ import { Jt as validateJupyterKernelName, Yt as validateNotebookPath$1, g as CREDIT_DEDUCT_TRANSACTION_TYPES, i as getEnvironmentName, n as logger, r as getApiUrl, t as ConfigStore, v as ChatModels } from "./ConfigStore-gV8aHo9b.mjs";
5
+ import { t as version } from "./package-Dzetdo6D.mjs";
6
6
  import { r as checkForUpdate } from "./updateChecker-C8xsNY2L.mjs";
7
7
  import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
8
8
  import { Box, Static, Text, render, useApp, useInput, usePaste, useStdout } from "ink";
@@ -3287,1104 +3287,625 @@ var MessageBuilder = class {
3287
3287
  }
3288
3288
  };
3289
3289
  //#endregion
3290
- //#region src/utils/jupyterClient.ts
3290
+ //#region src/llm/NotifyingLlmBackend.ts
3291
3291
  /**
3292
- * Jupyter Server REST API Client
3293
- *
3294
- * Provides methods for interacting with a local Jupyter Server instance.
3295
- * Used by Keep commands to execute notebooks on the user's local machine.
3292
+ * LLM backend wrapper that injects background agent notifications
3293
+ * into the message array before each completion call.
3296
3294
  *
3297
- * @see https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html
3298
- * @see https://jupyter-client.readthedocs.io/en/latest/messaging.html
3295
+ * When a background agent completes (or fails), the notification
3296
+ * appears as a system message so the main agent naturally sees it
3297
+ * in context — no polling required.
3299
3298
  */
3300
- var JupyterClientError = class extends Error {
3301
- constructor(message, statusCode, response) {
3302
- super(message);
3303
- this.statusCode = statusCode;
3304
- this.response = response;
3305
- this.name = "JupyterClientError";
3299
+ var NotifyingLlmBackend = class {
3300
+ constructor(inner, backgroundManager) {
3301
+ this.inner = inner;
3302
+ this.backgroundManager = backgroundManager;
3303
+ }
3304
+ get currentModel() {
3305
+ return this.inner.currentModel;
3306
+ }
3307
+ set currentModel(model) {
3308
+ this.inner.currentModel = model;
3309
+ }
3310
+ async complete(model, messages, options, callback) {
3311
+ const notifications = this.backgroundManager.drainNotifications();
3312
+ let effectiveMessages = messages;
3313
+ if (notifications.length > 0) {
3314
+ const notificationMessage = {
3315
+ role: "user",
3316
+ 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.`
3317
+ };
3318
+ effectiveMessages = [...messages, notificationMessage];
3319
+ }
3320
+ return this.inner.complete(model, effectiveMessages, options, callback);
3321
+ }
3322
+ pushToolMessages(messages, tool, result) {
3323
+ return this.inner.pushToolMessages(messages, tool, result);
3324
+ }
3325
+ async getModelInfo() {
3326
+ return this.inner.getModelInfo();
3306
3327
  }
3307
3328
  };
3329
+ //#endregion
3330
+ //#region src/utils/peonNotifier.ts
3308
3331
  /**
3309
- * Validate Jupyter server URL
3332
+ * peon-ping notifier native b4m adapter for peon-ping (https://peonping.com).
3333
+ *
3334
+ * peon-ping plays game-character voice lines + on-screen banners when an AI
3335
+ * coding agent finishes a turn or needs attention. It consumes a small JSON
3336
+ * event on stdin (the "CESP" hook contract shared by Claude Code and every
3337
+ * peon-ping adapter):
3338
+ *
3339
+ * { hook_event_name, notification_type, cwd, session_id, permission_mode }
3340
+ *
3341
+ * This module is the b4m CLI's equivalent of the shell adapters peon-ping
3342
+ * ships for other IDEs (see `adapters/openclaw.sh`), except it runs in-process
3343
+ * and is wired directly to the CLI's lifecycle via a store subscription.
3344
+ *
3345
+ * Behaviour is auto-detect: if a `peon.sh` is found on disk it is enabled,
3346
+ * otherwise every call is a silent no-op. Set `B4M_PEON_PING=0` to force it
3347
+ * off even when installed.
3310
3348
  */
3311
- function validateServerUrl(url) {
3312
- if (!url || typeof url !== "string") throw new JupyterClientError("Server URL is required");
3313
- let parsed;
3349
+ /**
3350
+ * Resolve the path to `peon.sh`, checking the same locations peon-ping's own
3351
+ * adapters use. Returns null when peon-ping is not installed. Resolved once and
3352
+ * cached: `null` means "looked and found nothing".
3353
+ */
3354
+ let resolvedScript;
3355
+ function findPeonScript() {
3356
+ if (resolvedScript !== void 0) return resolvedScript;
3357
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude");
3358
+ resolvedScript = [
3359
+ process.env.CLAUDE_PEON_DIR ? path.join(process.env.CLAUDE_PEON_DIR, "peon.sh") : null,
3360
+ path.join(claudeDir, "hooks", "peon-ping", "peon.sh"),
3361
+ path.join(homedir(), ".openpeon", "peon.sh")
3362
+ ].filter((p) => p !== null).find((p) => existsSync(p)) ?? null;
3363
+ return resolvedScript;
3364
+ }
3365
+ /** Explicit off-switch via env, even when peon-ping is installed. */
3366
+ function isDisabledByEnv() {
3367
+ const v = process.env.B4M_PEON_PING?.toLowerCase();
3368
+ return v === "0" || v === "false" || v === "off";
3369
+ }
3370
+ /** True when peon-ping is installed and not disabled. */
3371
+ function isPeonAvailable() {
3372
+ return !isDisabledByEnv() && findPeonScript() !== null;
3373
+ }
3374
+ /**
3375
+ * Fire a single peon-ping event. Fire-and-forget and fail-safe: the child is
3376
+ * detached and unref'd, all output is discarded, and any spawn error is logged
3377
+ * to the debug log but never surfaced — the CLI must never break because of a
3378
+ * missing or misbehaving peon install.
3379
+ */
3380
+ function notifyPeon(event, options = {}) {
3381
+ if (isDisabledByEnv()) return;
3382
+ const script = findPeonScript();
3383
+ if (!script) return;
3384
+ const payload = JSON.stringify({
3385
+ hook_event_name: event,
3386
+ notification_type: options.notificationType ?? "",
3387
+ cwd: process.cwd(),
3388
+ session_id: useCliStore.getState().session?.id ?? `b4m-${process.pid}`,
3389
+ permission_mode: ""
3390
+ });
3314
3391
  try {
3315
- parsed = new URL(url);
3316
- } catch {
3317
- throw new JupyterClientError(`Invalid server URL: ${url}`);
3392
+ const child = spawn("bash", [script], {
3393
+ stdio: [
3394
+ "pipe",
3395
+ "ignore",
3396
+ "ignore"
3397
+ ],
3398
+ detached: true
3399
+ });
3400
+ child.on("error", (err) => logger.debug(`peon-ping spawn failed: ${err.message}`));
3401
+ child.stdin?.on("error", () => {});
3402
+ child.stdin?.end(payload);
3403
+ child.unref();
3404
+ } catch (err) {
3405
+ logger.debug(`peon-ping notify error: ${err.message}`);
3318
3406
  }
3319
- if (!["http:", "https:"].includes(parsed.protocol)) throw new JupyterClientError(`Invalid protocol: ${parsed.protocol}. Only http and https are allowed`);
3320
3407
  }
3321
3408
  /**
3322
- * Validate notebook path to prevent path traversal (throws on invalid)
3409
+ * Subscribe to CLI lifecycle and emit peon-ping events:
3410
+ * - `Stop` when the agent finishes a turn (isThinking true → false)
3411
+ * - `Notification` (permission_prompt) when a permission / user-question /
3412
+ * review-gate prompt first appears and the user needs to act
3413
+ *
3414
+ * Fires `SessionStart` immediately. Returns an unsubscribe function; call it
3415
+ * (and pass `emitSessionEnd`) on exit to emit `SessionEnd`.
3323
3416
  */
3324
- function validateNotebookPath(path) {
3325
- const result = validateNotebookPath$1(path);
3326
- if (!result.valid) throw new JupyterClientError(result.error || "Invalid notebook path");
3417
+ function startPeonNotifier() {
3418
+ if (!isPeonAvailable()) return () => {};
3419
+ notifyPeon("SessionStart");
3420
+ return useCliStore.subscribe((state, prev) => {
3421
+ if (prev.isThinking && !state.isThinking) notifyPeon("Stop");
3422
+ if (!prev.permissionPrompt && !!state.permissionPrompt || !prev.userQuestionPrompt && !!state.userQuestionPrompt || !prev.reviewGatePrompt && !!state.reviewGatePrompt) notifyPeon("Notification", { notificationType: "permission_prompt" });
3423
+ });
3424
+ }
3425
+ /** Emit `SessionEnd`. Call on CLI exit. */
3426
+ function emitPeonSessionEnd() {
3427
+ notifyPeon("SessionEnd");
3327
3428
  }
3429
+ //#endregion
3430
+ //#region src/agents/dynamicAgentTool.ts
3328
3431
  /**
3329
- * Validate kernel name against whitelist (throws on invalid)
3432
+ * Create the create_dynamic_agent tool
3433
+ *
3434
+ * This tool allows the main agent to create and spawn a one-off agent at runtime
3435
+ * with a custom system prompt, model, and tool restrictions. Unlike agent_delegate,
3436
+ * this does not require a pre-defined agent markdown file.
3437
+ *
3438
+ * @param orchestrator - The SubagentOrchestrator instance
3439
+ * @param parentSessionId - Current session ID
3440
+ * @param backgroundManager - Optional background manager for async execution
3441
+ * @returns Tool definition compatible with agent tools
3330
3442
  */
3331
- function validateKernelName(name) {
3332
- const result = validateJupyterKernelName(name);
3333
- if (!result.valid) throw new JupyterClientError(result.error || "Invalid kernel name");
3334
- }
3335
- var JupyterClient = class {
3336
- constructor(config) {
3337
- validateServerUrl(config.serverUrl);
3338
- this.serverUrl = config.serverUrl.replace(/\/$/, "");
3339
- this.token = config.token;
3340
- }
3341
- getHeaders() {
3342
- const headers = { "Content-Type": "application/json" };
3343
- if (this.token) headers["Authorization"] = `token ${this.token}`;
3344
- return headers;
3345
- }
3346
- async request(method, path, body) {
3347
- const url = `${this.serverUrl}${path}`;
3348
- const options = {
3349
- method,
3350
- headers: this.getHeaders()
3351
- };
3352
- if (body) options.body = JSON.stringify(body);
3353
- const response = await fetch(url, options);
3354
- if (!response.ok) {
3355
- let errorBody;
3356
- try {
3357
- errorBody = await response.json();
3358
- } catch {
3359
- errorBody = await response.text();
3443
+ function createDynamicAgentTool(orchestrator, parentSessionId, backgroundManager) {
3444
+ return {
3445
+ toolFn: async (args) => {
3446
+ const params = args;
3447
+ if (!params.task) throw new Error("create_dynamic_agent: task parameter is required");
3448
+ if (!params.name) throw new Error("create_dynamic_agent: name parameter is required");
3449
+ if (!/^[a-zA-Z0-9_-]+$/.test(params.name)) throw new Error("create_dynamic_agent: name must contain only alphanumeric characters, hyphens, and underscores");
3450
+ if (!params.systemPrompt) throw new Error("create_dynamic_agent: systemPrompt parameter is required");
3451
+ const deniedTools = [...params.deniedTools || [], ...ALWAYS_DENIED_FOR_AGENTS];
3452
+ const agentDefinition = {
3453
+ description: params.description || `Dynamic agent: ${params.name}`,
3454
+ model: params.model || DEFAULT_AGENT_MODEL,
3455
+ modelResolved: true,
3456
+ systemPrompt: params.systemPrompt,
3457
+ allowedTools: params.allowedTools,
3458
+ deniedTools,
3459
+ maxIterations: { ...DEFAULT_MAX_ITERATIONS },
3460
+ defaultThoroughness: DEFAULT_THOROUGHNESS,
3461
+ defaultVariables: params.variables,
3462
+ retry: { ...DEFAULT_RETRY_CONFIG }
3463
+ };
3464
+ const spawnOptions = {
3465
+ task: params.task,
3466
+ agentName: params.name,
3467
+ thoroughness: params.thoroughness,
3468
+ variables: params.variables,
3469
+ parentSessionId,
3470
+ model: params.model,
3471
+ allowedTools: params.allowedTools,
3472
+ agentDefinition
3473
+ };
3474
+ if (params.run_in_background && backgroundManager) {
3475
+ const jobId = backgroundManager.spawn({
3476
+ ...spawnOptions,
3477
+ groupDescription: params.group_description
3478
+ });
3479
+ return `Dynamic background agent "${params.name}" started. Job ID: ${jobId}. Use check_agent_status tool with this job ID to retrieve results when ready.`;
3360
3480
  }
3361
- throw new JupyterClientError(`Jupyter API error: ${response.status} ${response.statusText}`, response.status, errorBody);
3362
- }
3363
- if (response.status === 204) return {};
3364
- return response.json();
3365
- }
3366
- /**
3367
- * Check if the Jupyter server is running and accessible
3368
- */
3369
- async checkStatus() {
3370
- return this.request("GET", "/api/status");
3371
- }
3372
- /**
3373
- * Get available kernel specifications
3374
- */
3375
- async getKernelSpecs() {
3376
- return this.request("GET", "/api/kernelspecs");
3377
- }
3378
- /**
3379
- * List all active sessions
3380
- */
3381
- async listSessions() {
3382
- return this.request("GET", "/api/sessions");
3383
- }
3384
- /**
3385
- * Start a new kernel session for a notebook
3386
- */
3387
- async startSession(notebookPath, kernelName) {
3388
- validateNotebookPath(notebookPath);
3389
- const kernel = kernelName || "python3";
3390
- validateKernelName(kernel);
3391
- return this.request("POST", "/api/sessions", {
3392
- path: notebookPath,
3393
- type: "notebook",
3394
- name: notebookPath.split("/").pop() || "Untitled",
3395
- kernel: { name: kernel }
3396
- });
3481
+ return (await orchestrator.delegateToAgent(spawnOptions)).summary;
3482
+ },
3483
+ toolSchema: {
3484
+ name: "create_dynamic_agent",
3485
+ description: `Create and spawn a one-off agent at runtime with a custom system prompt.
3486
+
3487
+ Unlike agent_delegate (which uses pre-defined agents), this tool lets you compose a new agent on the fly with custom instructions, model, and tool restrictions.
3488
+
3489
+ **When to use this tool:**
3490
+ - When no existing agent fits the task at hand
3491
+ - When you need a specialized agent with custom instructions for a one-off task
3492
+ - When you need fine-grained control over what tools the agent can access
3493
+
3494
+ **Constraints:**
3495
+ - Dynamic agents CANNOT call agent_delegate or create_dynamic_agent (no recursive spawning)
3496
+ - Dynamic agents are ephemeral — they are not saved or reusable across sessions
3497
+
3498
+ **Example uses:**
3499
+ - Create a security auditor agent with specific review criteria
3500
+ - Create a migration agent that follows a custom checklist
3501
+ - Create a specialized code generator with domain-specific instructions`,
3502
+ parameters: {
3503
+ type: "object",
3504
+ properties: {
3505
+ task: {
3506
+ type: "string",
3507
+ description: "Clear description of what you want the dynamic agent to accomplish."
3508
+ },
3509
+ name: {
3510
+ type: "string",
3511
+ description: "Unique name for this dynamic agent (e.g., \"security-auditor\", \"migration-helper\"). Used for logging and identification."
3512
+ },
3513
+ systemPrompt: {
3514
+ type: "string",
3515
+ description: "Custom system prompt for the agent. This defines the agent's role, capabilities, and constraints. Use $TASK to reference the task parameter."
3516
+ },
3517
+ description: {
3518
+ type: "string",
3519
+ description: "Short description of the agent's purpose (for logging)."
3520
+ },
3521
+ model: {
3522
+ type: "string",
3523
+ description: "Model to use for this agent (e.g., \"claude-sonnet-4-5-20250929\"). Defaults to the agent system default."
3524
+ },
3525
+ allowedTools: {
3526
+ type: "array",
3527
+ items: { type: "string" },
3528
+ description: "Whitelist of tool name patterns the agent can use. Supports wildcards (e.g., \"file_*\", \"mcp__github__*\"). If omitted, all non-denied tools are available."
3529
+ },
3530
+ deniedTools: {
3531
+ type: "array",
3532
+ items: { type: "string" },
3533
+ description: "Blacklist of tool name patterns the agent cannot use. agent_delegate and create_dynamic_agent are always denied."
3534
+ },
3535
+ thoroughness: {
3536
+ type: "string",
3537
+ enum: [
3538
+ "quick",
3539
+ "medium",
3540
+ "very_thorough"
3541
+ ],
3542
+ description: `How thoroughly to execute:
3543
+ - quick: Fast, 1-2 iterations
3544
+ - medium: Balanced, 3-5 iterations (default)
3545
+ - very_thorough: Comprehensive, 8-10+ iterations`
3546
+ },
3547
+ variables: {
3548
+ type: "object",
3549
+ additionalProperties: { type: "string" },
3550
+ description: "Variables to substitute in the system prompt. For example: { \"DOMAIN\": \"auth\" } replaces $DOMAIN in the prompt."
3551
+ },
3552
+ run_in_background: {
3553
+ type: "boolean",
3554
+ description: "Run the agent in the background (non-blocking). Returns a job ID immediately. Use check_agent_status to poll for results."
3555
+ },
3556
+ group_description: {
3557
+ type: "string",
3558
+ description: "Short description of what this group of background agents is working on. Only needed for the first background agent in a group."
3559
+ }
3560
+ },
3561
+ required: [
3562
+ "task",
3563
+ "name",
3564
+ "systemPrompt"
3565
+ ]
3566
+ }
3567
+ }
3568
+ };
3569
+ }
3570
+ //#endregion
3571
+ //#region src/tools/deferredToolRegistry.ts
3572
+ /**
3573
+ * Registry of tool schemas that are NOT loaded into the model's initial tool
3574
+ * list. The model sees only the names (via the system prompt directory) and
3575
+ * must call the `tool_search` meta-tool to load schemas on demand.
3576
+ *
3577
+ * This mirrors Claude Code's deferred-tool pattern. The win is large for
3578
+ * heavy MCP integrations (e.g. 41 GitHub MCP tools at ~250-350 tokens of
3579
+ * JSONSchema each = ~10-15k tokens per turn that's now ~1-1.5k of names).
3580
+ */
3581
+ var DeferredToolRegistry = class {
3582
+ constructor() {
3583
+ this.byName = /* @__PURE__ */ new Map();
3397
3584
  }
3398
- /**
3399
- * Get a session by ID
3400
- */
3401
- async getSession(sessionId) {
3402
- return this.request("GET", `/api/sessions/${sessionId}`);
3585
+ /** Replace registry contents with the supplied tools. Idempotent. */
3586
+ register(tools) {
3587
+ this.byName.clear();
3588
+ for (const tool of tools) this.byName.set(tool.toolSchema.name, tool);
3589
+ logger.debug(`[DeferredToolRegistry] Registered ${tools.length} deferred tool(s)`);
3403
3590
  }
3404
- /**
3405
- * Stop a kernel session
3406
- */
3407
- async stopSession(sessionId) {
3408
- await this.request("DELETE", `/api/sessions/${sessionId}`);
3591
+ clear() {
3592
+ this.byName.clear();
3409
3593
  }
3410
- /**
3411
- * Get WebSocket URL for kernel channels
3412
- */
3413
- getKernelWebSocketUrl(kernelId) {
3414
- const httpUrl = new URL(this.serverUrl);
3415
- const wsUrl = `${httpUrl.protocol === "https:" ? "wss:" : "ws:"}//${httpUrl.host}/api/kernels/${kernelId}/channels`;
3416
- if (this.token) return `${wsUrl}?token=${this.token}`;
3417
- return wsUrl;
3594
+ size() {
3595
+ return this.byName.size;
3418
3596
  }
3419
- /**
3420
- * Generate a unique message ID for Jupyter protocol
3421
- */
3422
- generateMsgId() {
3423
- return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
3597
+ has(name) {
3598
+ return this.byName.has(name);
3599
+ }
3600
+ get(name) {
3601
+ return this.byName.get(name);
3602
+ }
3603
+ getAll() {
3604
+ return Array.from(this.byName.values());
3605
+ }
3606
+ /** Return tools whose names appear in the supplied list, in input order. */
3607
+ getByNames(names) {
3608
+ const found = [];
3609
+ for (const name of names) {
3610
+ const tool = this.byName.get(name);
3611
+ if (tool) found.push(tool);
3612
+ }
3613
+ return found;
3424
3614
  }
3425
3615
  /**
3426
- * Execute code in a kernel using the Jupyter WebSocket protocol.
3427
- *
3428
- * Connects to the kernel's channels WebSocket, sends an execute_request,
3429
- * and collects outputs until the kernel returns to idle state.
3430
- *
3431
- * @param kernelId - The kernel ID to execute code in
3432
- * @param code - The code to execute
3433
- * @param timeoutMs - Execution timeout in milliseconds (default: 30000)
3616
+ * Rank-search deferred tools by query terms. Name matches outrank
3617
+ * description matches; exact substring on name wins ties.
3434
3618
  */
3435
- async executeCell(kernelId, code, timeoutMs = 3e4) {
3436
- const wsUrl = this.getKernelWebSocketUrl(kernelId);
3437
- const msgId = this.generateMsgId();
3438
- return new Promise((resolve, reject) => {
3439
- const outputs = [];
3440
- let executionCount = null;
3441
- let hasError = false;
3442
- let errorInfo;
3443
- const ws = new WsWebSocket(wsUrl);
3444
- const timeoutHandle = setTimeout(() => {
3445
- cleanup();
3446
- reject(new JupyterClientError(`Cell execution timed out after ${timeoutMs}ms`));
3447
- }, timeoutMs);
3448
- const cleanup = () => {
3449
- clearTimeout(timeoutHandle);
3450
- if (ws.readyState === WsWebSocket.OPEN || ws.readyState === WsWebSocket.CONNECTING) ws.close();
3451
- };
3452
- ws.on("error", (err) => {
3453
- cleanup();
3454
- reject(new JupyterClientError(`WebSocket error: ${err.message}`));
3455
- });
3456
- ws.on("open", () => {
3457
- const executeRequest = {
3458
- header: {
3459
- msg_id: msgId,
3460
- msg_type: "execute_request",
3461
- username: "b4m-cli",
3462
- session: this.generateMsgId(),
3463
- date: (/* @__PURE__ */ new Date()).toISOString(),
3464
- version: "5.3"
3465
- },
3466
- parent_header: {},
3467
- metadata: {},
3468
- content: {
3469
- code,
3470
- silent: false,
3471
- store_history: true,
3472
- user_expressions: {},
3473
- allow_stdin: false,
3474
- stop_on_error: true
3475
- },
3476
- buffers: [],
3477
- channel: "shell"
3478
- };
3479
- ws.send(JSON.stringify(executeRequest));
3619
+ searchByKeywords(query, maxResults) {
3620
+ const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
3621
+ if (terms.length === 0) return [];
3622
+ const scored = [];
3623
+ for (const tool of this.byName.values()) {
3624
+ const name = tool.toolSchema.name.toLowerCase();
3625
+ const desc = (tool.toolSchema.description || "").toLowerCase();
3626
+ let score = 0;
3627
+ for (const term of terms) {
3628
+ if (name.includes(term)) score += 10;
3629
+ if (desc.includes(term)) score += 1;
3630
+ }
3631
+ if (score > 0) scored.push({
3632
+ tool,
3633
+ score
3480
3634
  });
3481
- ws.on("message", (data) => {
3482
- try {
3483
- const msg = JSON.parse(data.toString());
3484
- if (msg.parent_header?.msg_id !== msgId) return;
3485
- switch (msg.header?.msg_type || msg.msg_type) {
3486
- case "stream":
3487
- outputs.push({
3488
- output_type: "stream",
3489
- name: msg.content.name,
3490
- text: msg.content.text
3491
- });
3492
- break;
3493
- case "execute_result":
3494
- executionCount = msg.content.execution_count;
3495
- outputs.push({
3496
- output_type: "execute_result",
3497
- data: msg.content.data,
3498
- execution_count: msg.content.execution_count,
3499
- metadata: msg.content.metadata
3500
- });
3501
- break;
3502
- case "display_data":
3503
- outputs.push({
3504
- output_type: "display_data",
3505
- data: msg.content.data,
3506
- metadata: msg.content.metadata
3507
- });
3508
- break;
3509
- case "error":
3510
- hasError = true;
3511
- errorInfo = {
3512
- ename: msg.content.ename,
3513
- evalue: msg.content.evalue,
3514
- traceback: msg.content.traceback
3515
- };
3516
- outputs.push({
3517
- output_type: "error",
3518
- ename: msg.content.ename,
3519
- evalue: msg.content.evalue,
3520
- traceback: msg.content.traceback
3521
- });
3522
- break;
3523
- case "execute_reply":
3524
- if (msg.content.status === "ok" || msg.content.status === "error") {
3525
- if (msg.content.execution_count !== void 0) executionCount = msg.content.execution_count;
3526
- cleanup();
3527
- resolve({
3528
- success: !hasError,
3529
- outputs,
3530
- executionCount,
3531
- error: errorInfo
3532
- });
3533
- }
3534
- break;
3535
- case "status": break;
3536
- }
3537
- } catch {}
3538
- });
3539
- ws.on("close", () => {
3540
- clearTimeout(timeoutHandle);
3541
- });
3542
- });
3543
- }
3544
- /**
3545
- * @deprecated Use executeCell instead. This method exists for backwards compatibility.
3546
- */
3547
- async executeCode(kernelId, code) {
3548
- return this.executeCell(kernelId, code);
3549
- }
3550
- /**
3551
- * Interrupt a running kernel
3552
- */
3553
- async interruptKernel(kernelId) {
3554
- await this.request("POST", `/api/kernels/${kernelId}/interrupt`);
3635
+ }
3636
+ scored.sort((a, b) => b.score - a.score);
3637
+ return scored.slice(0, maxResults).map((s) => s.tool);
3555
3638
  }
3556
- /**
3557
- * Restart a kernel
3558
- */
3559
- async restartKernel(kernelId) {
3560
- return this.request("POST", `/api/kernels/${kernelId}/restart`);
3639
+ /** Return the directory entries used to render the system-prompt reminder. */
3640
+ getDirectoryNames() {
3641
+ return Array.from(this.byName.keys()).sort();
3561
3642
  }
3562
3643
  };
3563
- /**
3564
- * Create a JupyterClient from environment variables or config
3565
- */
3566
- function createJupyterClientFromEnv() {
3567
- const serverUrl = process.env.JUPYTER_SERVER_URL;
3568
- const token = process.env.JUPYTER_TOKEN;
3569
- if (!serverUrl) return null;
3570
- return new JupyterClient({
3571
- serverUrl,
3572
- token
3573
- });
3574
- }
3644
+ const deferredToolRegistry = new DeferredToolRegistry();
3575
3645
  //#endregion
3576
- //#region src/commands/executeNotebookHandler.ts
3646
+ //#region src/tools/toolSearchTool.ts
3577
3647
  /**
3578
- * Jupyter Notebook Execution Handler
3579
- *
3580
- * Handles the jupyter_execute_notebook Keep command.
3581
- * Orchestrates cell-by-cell execution of a notebook via local Jupyter server.
3648
+ * Default number of tools returned for a keyword search. Matches Claude
3649
+ * Code's ToolSearch convention. 5 keeps the response payload small while
3650
+ * surfacing enough alternatives for the model to refine its query.
3582
3651
  */
3652
+ const DEFAULT_MAX_RESULTS = 5;
3583
3653
  /**
3584
- * Zod schema for validating execute notebook parameters.
3585
- * Replaces unsafe `as` casts with proper runtime validation.
3654
+ * Runtime validation for tool_search params. The LLM produces these
3655
+ * values, so we validate at this boundary rather than trusting the
3656
+ * shape. Coerces `max_results` from string→number for models that emit
3657
+ * numeric-looking strings.
3586
3658
  */
3587
- const ExecuteNotebookParams = z.object({
3588
- notebookJson: z.string().min(1, "notebookJson is required"),
3589
- sessionId: z.string().min(1, "sessionId is required"),
3590
- kernelName: z.string().default("python3"),
3591
- timeoutPerCell: z.number().default(3e4)
3659
+ const ToolSearchParamsSchema = z.object({
3660
+ query: z.string().min(1, "query must be a non-empty string"),
3661
+ max_results: z.coerce.number().int().min(1).max(20).optional()
3592
3662
  });
3593
3663
  /**
3594
- * Get cell source as string (handles both string and string[] formats).
3664
+ * Parse the query string. Two forms:
3665
+ * - `select:name1,name2,...` — exact-name selection
3666
+ * - free text — keyword search across name + description
3595
3667
  */
3596
- function getCellSource(cell) {
3597
- return Array.isArray(cell.source) ? cell.source.join("") : cell.source;
3668
+ function parseQuery(query) {
3669
+ const trimmed = query.trim();
3670
+ const selectMatch = trimmed.match(/^select:(.+)$/i);
3671
+ if (selectMatch) return {
3672
+ mode: "select",
3673
+ names: selectMatch[1].split(",").map((n) => n.trim()).filter((n) => n.length > 0)
3674
+ };
3675
+ return {
3676
+ mode: "search",
3677
+ text: trimmed
3678
+ };
3598
3679
  }
3599
3680
  /**
3600
- * Execute a Jupyter notebook cell by cell.
3681
+ * Format the loaded-tools response. Mirrors Claude Code's convention:
3682
+ * one <function>{...}</function> line per matched tool. The model has
3683
+ * already seen this format in its tool-registration system messages, so
3684
+ * it parses without additional explanation.
3601
3685
  *
3602
- * @param params - Validated notebook execution parameters
3603
- * @param deps - Dependencies (jupyter client, websocket manager, logger)
3604
- * @returns Execution result with success status and executed notebook
3686
+ * Note: the schemas are *also* injected into context.tools by the caller,
3687
+ * so on the next iteration the model gets them as native tool definitions.
3688
+ * The text response here is for in-turn awareness and audit trail.
3605
3689
  */
3606
- async function executeNotebook(params, deps) {
3607
- const { jupyterClient, wsManager, logger, requestId } = deps;
3608
- const { notebookJson, sessionId, kernelName, timeoutPerCell } = ExecuteNotebookParams.parse(params);
3609
- let notebook;
3610
- try {
3611
- notebook = JSON.parse(notebookJson);
3612
- if (!notebook.cells || !Array.isArray(notebook.cells)) throw new Error("Invalid notebook: missing cells array");
3613
- } catch (parseErr) {
3614
- throw new Error(`Failed to parse notebook JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
3615
- }
3616
- const totalCodeCells = notebook.cells.filter((c) => c.cell_type === "code").length;
3617
- logger.info(`[Keep] Starting notebook execution: ${totalCodeCells} code cells, kernel: ${kernelName}`);
3618
- const tempNotebookPath = `/tmp/b4m-notebook-${randomUUID()}.ipynb`;
3619
- await promises.writeFile(tempNotebookPath, notebookJson, "utf-8");
3620
- let jupyterSession = null;
3621
- let cellsExecuted = 0;
3622
- let cellsFailed = 0;
3623
- try {
3624
- logger.info(`[Keep] Starting Jupyter kernel: ${kernelName}`);
3625
- jupyterSession = await jupyterClient.startSession(tempNotebookPath, kernelName);
3626
- const kernelId = jupyterSession.kernel.id;
3627
- const jupyterSessionId = jupyterSession.id;
3628
- logger.info(`[Keep] Kernel started: session=${jupyterSessionId}, kernel=${kernelId}`);
3629
- let codeCellIndex = 0;
3630
- for (let i = 0; i < notebook.cells.length; i++) {
3631
- const cell = notebook.cells[i];
3632
- if (cell.cell_type !== "code") continue;
3633
- const cellCode = getCellSource(cell);
3634
- if (!cellCode.trim()) {
3635
- codeCellIndex++;
3636
- continue;
3637
- }
3638
- logger.info(`[Keep] Executing cell ${codeCellIndex + 1}/${totalCodeCells}`);
3639
- wsManager?.send({
3640
- action: "jupyter_cell_output",
3641
- requestId,
3642
- sessionId,
3643
- jupyterSessionId,
3644
- cellIndex: codeCellIndex,
3645
- outputType: "stream",
3646
- content: {
3647
- text: `Executing cell ${codeCellIndex + 1}/${totalCodeCells}...`,
3648
- name: "stdout"
3649
- },
3650
- executionCount: null,
3651
- isComplete: false
3652
- });
3653
- try {
3654
- const cellResult = await jupyterClient.executeCell(kernelId, cellCode, timeoutPerCell);
3655
- cell.outputs = cellResult.outputs;
3656
- cell.execution_count = cellResult.executionCount;
3657
- if (cellResult.success) {
3658
- cellsExecuted++;
3659
- wsManager?.send({
3660
- action: "jupyter_cell_output",
3661
- requestId,
3662
- sessionId,
3663
- jupyterSessionId,
3664
- cellIndex: codeCellIndex,
3665
- outputType: "execute_result",
3666
- content: {
3667
- text: cellResult.outputs.map((o) => {
3668
- if (o.text) return Array.isArray(o.text) ? o.text.join("") : o.text;
3669
- if (o.data && typeof o.data === "object" && "text/plain" in o.data) {
3670
- const textPlain = o.data["text/plain"];
3671
- return Array.isArray(textPlain) ? textPlain.join("") : String(textPlain);
3672
- }
3673
- return "";
3674
- }).join(""),
3675
- data: cellResult.outputs.find((o) => o.data)?.data
3676
- },
3677
- executionCount: cellResult.executionCount,
3678
- isComplete: true
3679
- });
3680
- } else {
3681
- cellsFailed++;
3682
- wsManager?.send({
3683
- action: "jupyter_cell_output",
3684
- requestId,
3685
- sessionId,
3686
- jupyterSessionId,
3687
- cellIndex: codeCellIndex,
3688
- outputType: "error",
3689
- content: {
3690
- ename: cellResult.error?.ename || "ExecutionError",
3691
- evalue: cellResult.error?.evalue || "Cell execution failed",
3692
- traceback: cellResult.error?.traceback || []
3693
- },
3694
- executionCount: cellResult.executionCount,
3695
- isComplete: true
3696
- });
3697
- logger.warn(`[Keep] Cell ${codeCellIndex + 1} failed: ${cellResult.error?.ename}: ${cellResult.error?.evalue}`);
3698
- }
3699
- } catch (cellErr) {
3700
- cellsFailed++;
3701
- const errMsg = cellErr instanceof Error ? cellErr.message : String(cellErr);
3702
- wsManager?.send({
3703
- action: "jupyter_cell_output",
3704
- requestId,
3705
- sessionId,
3706
- jupyterSessionId,
3707
- cellIndex: codeCellIndex,
3708
- outputType: "error",
3709
- content: {
3710
- ename: "ExecutionError",
3711
- evalue: errMsg,
3712
- traceback: []
3713
- },
3714
- executionCount: null,
3715
- isComplete: true
3716
- });
3717
- logger.error(`[Keep] Cell ${codeCellIndex + 1} threw error: ${errMsg}`);
3718
- }
3719
- codeCellIndex++;
3720
- }
3721
- return {
3722
- success: cellsFailed === 0,
3723
- cellsExecuted,
3724
- cellsFailed,
3725
- totalCodeCells,
3726
- executedNotebook: JSON.stringify(notebook)
3690
+ function renderToolsBlock(tools) {
3691
+ if (tools.length === 0) return "";
3692
+ return `<functions>\n${tools.map((tool) => {
3693
+ const schema = {
3694
+ description: tool.toolSchema.description,
3695
+ name: tool.toolSchema.name,
3696
+ parameters: tool.toolSchema.parameters
3727
3697
  };
3728
- } finally {
3729
- if (jupyterSession) try {
3730
- logger.info(`[Keep] Stopping Jupyter session: ${jupyterSession.id}`);
3731
- await jupyterClient.stopSession(jupyterSession.id);
3732
- } catch (stopErr) {
3733
- logger.warn(`[Keep] Failed to stop session: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`);
3698
+ return `<function>${JSON.stringify(schema)}</function>`;
3699
+ }).join("\n")}\n</functions>`;
3700
+ }
3701
+ /**
3702
+ * Build the tool_search meta-tool. The returned tool has a closure over
3703
+ * the supplied `toolListAccessor`, which it uses to push newly-resolved
3704
+ * tool schemas into the live agent context.
3705
+ *
3706
+ * Idempotent: re-loading a tool that's already in the context is a no-op.
3707
+ */
3708
+ function createToolSearchTool(toolListAccessor) {
3709
+ return {
3710
+ toolSchema: {
3711
+ name: "tool_search",
3712
+ 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.",
3713
+ parameters: {
3714
+ type: "object",
3715
+ properties: {
3716
+ query: {
3717
+ type: "string",
3718
+ description: "Either 'select:<comma-separated names>' to fetch specific tools, or free-text keywords (e.g. 'github pull request') to rank-search deferred tools."
3719
+ },
3720
+ max_results: {
3721
+ type: "number",
3722
+ description: `Maximum number of tools to return for keyword search. Defaults to ${DEFAULT_MAX_RESULTS}. Ignored for 'select:' queries.`
3723
+ }
3724
+ },
3725
+ required: ["query"]
3726
+ }
3727
+ },
3728
+ toolFn: async (params) => {
3729
+ const parsedParams = ToolSearchParamsSchema.safeParse(params ?? {});
3730
+ if (!parsedParams.success) {
3731
+ const issue = parsedParams.error.issues[0];
3732
+ return `tool_search: invalid parameters — ${issue.path.join(".") || "params"}: ${issue.message}`;
3733
+ }
3734
+ const { query, max_results } = parsedParams.data;
3735
+ const parsed = parseQuery(query);
3736
+ let matched;
3737
+ let unmatched = [];
3738
+ if (parsed.mode === "select") {
3739
+ matched = deferredToolRegistry.getByNames(parsed.names);
3740
+ const foundNames = new Set(matched.map((t) => t.toolSchema.name));
3741
+ unmatched = parsed.names.filter((n) => !foundNames.has(n));
3742
+ } else {
3743
+ const max = max_results ?? DEFAULT_MAX_RESULTS;
3744
+ matched = deferredToolRegistry.searchByKeywords(parsed.text, max);
3745
+ }
3746
+ 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}".`;
3747
+ const liveTools = toolListAccessor();
3748
+ const liveNames = new Set(liveTools.map((t) => t.toolSchema.name));
3749
+ let added = 0;
3750
+ for (const tool of matched) if (!liveNames.has(tool.toolSchema.name)) {
3751
+ liveTools.push(tool);
3752
+ added++;
3753
+ }
3754
+ logger.debug(`[tool_search] query="${query}" matched=${matched.length} added=${added} alreadyLoaded=${matched.length - added}`);
3755
+ const block = renderToolsBlock(matched);
3756
+ 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}`;
3734
3757
  }
3735
- try {
3736
- await promises.unlink(tempNotebookPath);
3737
- } catch {}
3738
- }
3758
+ };
3739
3759
  }
3740
3760
  //#endregion
3741
- //#region src/llm/NotifyingLlmBackend.ts
3761
+ //#region src/features/FeatureModuleRegistry.ts
3742
3762
  /**
3743
- * LLM backend wrapper that injects background agent notifications
3744
- * into the message array before each completion call.
3763
+ * Manages the lifecycle of opt-in CLI feature modules.
3745
3764
  *
3746
- * When a background agent completes (or fails), the notification
3747
- * appears as a system message so the main agent naturally sees it
3748
- * in context — no polling required.
3765
+ * Created during bootstrap, modules are conditionally registered based on config,
3766
+ * then the registry's outputs are fed into tool generation and prompt building.
3749
3767
  */
3750
- var NotifyingLlmBackend = class {
3751
- constructor(inner, backgroundManager) {
3752
- this.inner = inner;
3753
- this.backgroundManager = backgroundManager;
3768
+ var FeatureModuleRegistry = class {
3769
+ constructor() {
3770
+ this.modules = [];
3754
3771
  }
3755
- get currentModel() {
3756
- return this.inner.currentModel;
3772
+ /** Register a feature module */
3773
+ register(module) {
3774
+ if (this.modules.some((m) => m.name === module.name)) throw new Error(`Feature module '${module.name}' is already registered`);
3775
+ this.modules.push(module);
3757
3776
  }
3758
- set currentModel(model) {
3759
- this.inner.currentModel = model;
3777
+ /** Collect all tools from all registered modules */
3778
+ getAllTools() {
3779
+ return this.modules.flatMap((m) => m.getTools());
3760
3780
  }
3761
- async complete(model, messages, options, callback) {
3762
- const notifications = this.backgroundManager.drainNotifications();
3763
- let effectiveMessages = messages;
3764
- if (notifications.length > 0) {
3765
- const notificationMessage = {
3766
- role: "user",
3767
- 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.`
3768
- };
3769
- effectiveMessages = [...messages, notificationMessage];
3781
+ /** Get all tool names from all registered modules */
3782
+ getAllToolNames() {
3783
+ return this.getAllTools().map((t) => t.toolSchema.name);
3784
+ }
3785
+ /** Build combined system prompt section from all modules */
3786
+ getSystemPromptSections() {
3787
+ const sections = this.modules.map((m) => m.getSystemPromptSection()).filter((s) => s.length > 0);
3788
+ return sections.length > 0 ? "\n\n" + sections.join("\n\n") : "";
3789
+ }
3790
+ /** Register all WS handlers from all modules */
3791
+ registerAllWsHandlers(wsManager) {
3792
+ for (const module of this.modules) module.registerWsHandlers?.(wsManager);
3793
+ }
3794
+ /** Collect all slash commands from all registered modules */
3795
+ getAllCommands() {
3796
+ return this.modules.flatMap((m) => m.getCommands?.() ?? []);
3797
+ }
3798
+ /** Try to execute a slash command. Returns true if handled. */
3799
+ executeCommand(name, args) {
3800
+ for (const module of this.modules) {
3801
+ const command = (module.getCommands?.() ?? []).find((c) => c.name === name);
3802
+ if (command) {
3803
+ command.execute(args);
3804
+ return true;
3805
+ }
3770
3806
  }
3771
- return this.inner.complete(model, effectiveMessages, options, callback);
3807
+ return false;
3772
3808
  }
3773
- pushToolMessages(messages, tool, result) {
3774
- return this.inner.pushToolMessages(messages, tool, result);
3809
+ /** Cleanup all modules */
3810
+ disposeAll() {
3811
+ for (const module of this.modules) module.dispose?.();
3775
3812
  }
3776
- async getModelInfo() {
3777
- return this.inner.getModelInfo();
3813
+ /** Get names of all registered modules */
3814
+ getModuleNames() {
3815
+ return this.modules.map((m) => m.name);
3816
+ }
3817
+ /** Check if any modules are registered */
3818
+ get hasModules() {
3819
+ return this.modules.length > 0;
3778
3820
  }
3779
3821
  };
3780
3822
  //#endregion
3781
- //#region src/llm/MultiLlmBackend.ts
3823
+ //#region src/features/tavern/TavernService.ts
3782
3824
  /**
3783
- * Routes completions between B4M server and a local Ollama instance
3784
- * based on the selected model's backend type.
3825
+ * HTTP implementation of ITavernService using the shared ApiClient.
3826
+ *
3827
+ * Each method maps to one /api/tavern/* endpoint.
3828
+ * Pure transport — no business logic.
3785
3829
  */
3786
- var MultiLlmBackend = class {
3787
- constructor(serverBackend, ollamaBackend, serverModels, ollamaModels, initialModel) {
3788
- this.serverBackend = serverBackend;
3789
- this.ollamaBackend = ollamaBackend;
3790
- this.serverModels = serverModels;
3791
- this.ollamaModels = ollamaModels;
3792
- this.currentModel = initialModel;
3793
- this.ollamaModelIds = new Set(ollamaModels.map((m) => m.id));
3830
+ var TavernService = class {
3831
+ constructor(apiClient) {
3832
+ this.apiClient = apiClient;
3794
3833
  }
3795
- get activeBackend() {
3796
- return this.ollamaModelIds.has(this.currentModel) ? this.ollamaBackend : this.serverBackend;
3834
+ async listAgents() {
3835
+ return this.apiClient.get("/api/agents?limit=100");
3797
3836
  }
3798
- async complete(model, messages, options, callback) {
3799
- return (this.ollamaModelIds.has(model) ? this.ollamaBackend : this.serverBackend).complete(model, messages, options, callback);
3837
+ async createAgent(request) {
3838
+ return this.apiClient.post("/api/agents", request);
3800
3839
  }
3801
- pushToolMessages(messages, tool, result, thinkingBlocks) {
3802
- this.activeBackend.pushToolMessages(messages, tool, result, thinkingBlocks);
3840
+ async updateAgent(agentId, request) {
3841
+ return this.apiClient.put(`/api/agents/${encodeURIComponent(agentId)}`, request);
3803
3842
  }
3804
- async getModelInfo() {
3805
- return [...this.serverModels, ...this.ollamaModels];
3843
+ async deleteAgent(agentId) {
3844
+ await this.apiClient.delete(`/api/agents/${encodeURIComponent(agentId)}`);
3845
+ }
3846
+ async mentionAgent(agentName, message, config) {
3847
+ return this.apiClient.post("/api/tavern/mention", {
3848
+ agentName,
3849
+ message,
3850
+ config
3851
+ });
3852
+ }
3853
+ async listQuests() {
3854
+ return this.apiClient.get("/api/tavern/quests");
3855
+ }
3856
+ async postQuest(request) {
3857
+ return this.apiClient.post("/api/tavern/quests", request);
3858
+ }
3859
+ async deleteQuest(questId) {
3860
+ await this.apiClient.delete(`/api/tavern/quests`, { data: { questId } });
3861
+ }
3862
+ async getAgentNotebook(agentId, limit = 50) {
3863
+ return this.apiClient.get(`/api/tavern/agent-notebook?agentId=${encodeURIComponent(agentId)}&limit=${limit}`);
3864
+ }
3865
+ async listGates() {
3866
+ return this.apiClient.get("/api/tavern/gates");
3867
+ }
3868
+ async resolveGate(gateId, resolution) {
3869
+ return this.apiClient.post("/api/tavern/gate-resolve", {
3870
+ gateId,
3871
+ resolution
3872
+ });
3873
+ }
3874
+ async toggleHeartbeats(enabled) {
3875
+ return this.apiClient.post("/api/tavern/toggle-heartbeats", { enabled });
3876
+ }
3877
+ async triggerHeartbeat(config) {
3878
+ return this.apiClient.post("/api/tavern/trigger-heartbeat", { config });
3879
+ }
3880
+ async abortHeartbeats() {
3881
+ await this.apiClient.post("/api/tavern/abort-heartbeats", { abort: true });
3882
+ }
3883
+ async getQuestPlan(planId) {
3884
+ return this.apiClient.get(`/api/quest-master-plans/${encodeURIComponent(planId)}`);
3885
+ }
3886
+ async updateReviewGate(planId, questId, subQuestId, reviewStatus, reviewNote) {
3887
+ return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/review-gate`, {
3888
+ questId,
3889
+ subQuestId,
3890
+ reviewStatus,
3891
+ reviewNote
3892
+ });
3893
+ }
3894
+ async updateSubQuestProgress(planId, questId, subQuestId, updates) {
3895
+ return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/subquest-progress`, {
3896
+ questId,
3897
+ subQuestId,
3898
+ ...updates
3899
+ });
3900
+ }
3901
+ async updateHandoff(planId, handoff) {
3902
+ return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/handoff`, handoff);
3806
3903
  }
3807
3904
  };
3808
3905
  //#endregion
3809
- //#region src/utils/peonNotifier.ts
3906
+ //#region src/features/tavern/types.ts
3810
3907
  /**
3811
- * peon-ping notifier native b4m adapter for peon-ping (https://peonping.com).
3812
- *
3813
- * peon-ping plays game-character voice lines + on-screen banners when an AI
3814
- * coding agent finishes a turn or needs attention. It consumes a small JSON
3815
- * event on stdin (the "CESP" hook contract shared by Claude Code and every
3816
- * peon-ping adapter):
3817
- *
3818
- * { hook_event_name, notification_type, cwd, session_id, permission_mode }
3819
- *
3820
- * This module is the b4m CLI's equivalent of the shell adapters peon-ping
3821
- * ships for other IDEs (see `adapters/openclaw.sh`), except it runs in-process
3822
- * and is wired directly to the CLI's lifecycle via a store subscription.
3823
- *
3824
- * Behaviour is auto-detect: if a `peon.sh` is found on disk it is enabled,
3825
- * otherwise every call is a silent no-op. Set `B4M_PEON_PING=0` to force it
3826
- * off even when installed.
3827
- */
3828
- /**
3829
- * Resolve the path to `peon.sh`, checking the same locations peon-ping's own
3830
- * adapters use. Returns null when peon-ping is not installed. Resolved once and
3831
- * cached: `null` means "looked and found nothing".
3832
- */
3833
- let resolvedScript;
3834
- function findPeonScript() {
3835
- if (resolvedScript !== void 0) return resolvedScript;
3836
- const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude");
3837
- resolvedScript = [
3838
- process.env.CLAUDE_PEON_DIR ? path.join(process.env.CLAUDE_PEON_DIR, "peon.sh") : null,
3839
- path.join(claudeDir, "hooks", "peon-ping", "peon.sh"),
3840
- path.join(homedir(), ".openpeon", "peon.sh")
3841
- ].filter((p) => p !== null).find((p) => existsSync(p)) ?? null;
3842
- return resolvedScript;
3843
- }
3844
- /** Explicit off-switch via env, even when peon-ping is installed. */
3845
- function isDisabledByEnv() {
3846
- const v = process.env.B4M_PEON_PING?.toLowerCase();
3847
- return v === "0" || v === "false" || v === "off";
3848
- }
3849
- /** True when peon-ping is installed and not disabled. */
3850
- function isPeonAvailable() {
3851
- return !isDisabledByEnv() && findPeonScript() !== null;
3852
- }
3853
- /**
3854
- * Fire a single peon-ping event. Fire-and-forget and fail-safe: the child is
3855
- * detached and unref'd, all output is discarded, and any spawn error is logged
3856
- * to the debug log but never surfaced — the CLI must never break because of a
3857
- * missing or misbehaving peon install.
3858
- */
3859
- function notifyPeon(event, options = {}) {
3860
- if (isDisabledByEnv()) return;
3861
- const script = findPeonScript();
3862
- if (!script) return;
3863
- const payload = JSON.stringify({
3864
- hook_event_name: event,
3865
- notification_type: options.notificationType ?? "",
3866
- cwd: process.cwd(),
3867
- session_id: useCliStore.getState().session?.id ?? `b4m-${process.pid}`,
3868
- permission_mode: ""
3869
- });
3870
- try {
3871
- const child = spawn("bash", [script], {
3872
- stdio: [
3873
- "pipe",
3874
- "ignore",
3875
- "ignore"
3876
- ],
3877
- detached: true
3878
- });
3879
- child.on("error", (err) => logger.debug(`peon-ping spawn failed: ${err.message}`));
3880
- child.stdin?.on("error", () => {});
3881
- child.stdin?.end(payload);
3882
- child.unref();
3883
- } catch (err) {
3884
- logger.debug(`peon-ping notify error: ${err.message}`);
3885
- }
3886
- }
3887
- /**
3888
- * Subscribe to CLI lifecycle and emit peon-ping events:
3889
- * - `Stop` when the agent finishes a turn (isThinking true → false)
3890
- * - `Notification` (permission_prompt) when a permission / user-question /
3891
- * review-gate prompt first appears and the user needs to act
3892
- *
3893
- * Fires `SessionStart` immediately. Returns an unsubscribe function; call it
3894
- * (and pass `emitSessionEnd`) on exit to emit `SessionEnd`.
3895
- */
3896
- function startPeonNotifier() {
3897
- if (!isPeonAvailable()) return () => {};
3898
- notifyPeon("SessionStart");
3899
- return useCliStore.subscribe((state, prev) => {
3900
- if (prev.isThinking && !state.isThinking) notifyPeon("Stop");
3901
- if (!prev.permissionPrompt && !!state.permissionPrompt || !prev.userQuestionPrompt && !!state.userQuestionPrompt || !prev.reviewGatePrompt && !!state.reviewGatePrompt) notifyPeon("Notification", { notificationType: "permission_prompt" });
3902
- });
3903
- }
3904
- /** Emit `SessionEnd`. Call on CLI exit. */
3905
- function emitPeonSessionEnd() {
3906
- notifyPeon("SessionEnd");
3907
- }
3908
- //#endregion
3909
- //#region src/agents/dynamicAgentTool.ts
3910
- /**
3911
- * Create the create_dynamic_agent tool
3912
- *
3913
- * This tool allows the main agent to create and spawn a one-off agent at runtime
3914
- * with a custom system prompt, model, and tool restrictions. Unlike agent_delegate,
3915
- * this does not require a pre-defined agent markdown file.
3916
- *
3917
- * @param orchestrator - The SubagentOrchestrator instance
3918
- * @param parentSessionId - Current session ID
3919
- * @param backgroundManager - Optional background manager for async execution
3920
- * @returns Tool definition compatible with agent tools
3921
- */
3922
- function createDynamicAgentTool(orchestrator, parentSessionId, backgroundManager) {
3923
- return {
3924
- toolFn: async (args) => {
3925
- const params = args;
3926
- if (!params.task) throw new Error("create_dynamic_agent: task parameter is required");
3927
- if (!params.name) throw new Error("create_dynamic_agent: name parameter is required");
3928
- if (!/^[a-zA-Z0-9_-]+$/.test(params.name)) throw new Error("create_dynamic_agent: name must contain only alphanumeric characters, hyphens, and underscores");
3929
- if (!params.systemPrompt) throw new Error("create_dynamic_agent: systemPrompt parameter is required");
3930
- const deniedTools = [...params.deniedTools || [], ...ALWAYS_DENIED_FOR_AGENTS];
3931
- const agentDefinition = {
3932
- description: params.description || `Dynamic agent: ${params.name}`,
3933
- model: params.model || DEFAULT_AGENT_MODEL,
3934
- modelResolved: true,
3935
- systemPrompt: params.systemPrompt,
3936
- allowedTools: params.allowedTools,
3937
- deniedTools,
3938
- maxIterations: { ...DEFAULT_MAX_ITERATIONS },
3939
- defaultThoroughness: DEFAULT_THOROUGHNESS,
3940
- defaultVariables: params.variables,
3941
- retry: { ...DEFAULT_RETRY_CONFIG }
3942
- };
3943
- const spawnOptions = {
3944
- task: params.task,
3945
- agentName: params.name,
3946
- thoroughness: params.thoroughness,
3947
- variables: params.variables,
3948
- parentSessionId,
3949
- model: params.model,
3950
- allowedTools: params.allowedTools,
3951
- agentDefinition
3952
- };
3953
- if (params.run_in_background && backgroundManager) {
3954
- const jobId = backgroundManager.spawn({
3955
- ...spawnOptions,
3956
- groupDescription: params.group_description
3957
- });
3958
- return `Dynamic background agent "${params.name}" started. Job ID: ${jobId}. Use check_agent_status tool with this job ID to retrieve results when ready.`;
3959
- }
3960
- return (await orchestrator.delegateToAgent(spawnOptions)).summary;
3961
- },
3962
- toolSchema: {
3963
- name: "create_dynamic_agent",
3964
- description: `Create and spawn a one-off agent at runtime with a custom system prompt.
3965
-
3966
- Unlike agent_delegate (which uses pre-defined agents), this tool lets you compose a new agent on the fly with custom instructions, model, and tool restrictions.
3967
-
3968
- **When to use this tool:**
3969
- - When no existing agent fits the task at hand
3970
- - When you need a specialized agent with custom instructions for a one-off task
3971
- - When you need fine-grained control over what tools the agent can access
3972
-
3973
- **Constraints:**
3974
- - Dynamic agents CANNOT call agent_delegate or create_dynamic_agent (no recursive spawning)
3975
- - Dynamic agents are ephemeral — they are not saved or reusable across sessions
3976
-
3977
- **Example uses:**
3978
- - Create a security auditor agent with specific review criteria
3979
- - Create a migration agent that follows a custom checklist
3980
- - Create a specialized code generator with domain-specific instructions`,
3981
- parameters: {
3982
- type: "object",
3983
- properties: {
3984
- task: {
3985
- type: "string",
3986
- description: "Clear description of what you want the dynamic agent to accomplish."
3987
- },
3988
- name: {
3989
- type: "string",
3990
- description: "Unique name for this dynamic agent (e.g., \"security-auditor\", \"migration-helper\"). Used for logging and identification."
3991
- },
3992
- systemPrompt: {
3993
- type: "string",
3994
- description: "Custom system prompt for the agent. This defines the agent's role, capabilities, and constraints. Use $TASK to reference the task parameter."
3995
- },
3996
- description: {
3997
- type: "string",
3998
- description: "Short description of the agent's purpose (for logging)."
3999
- },
4000
- model: {
4001
- type: "string",
4002
- description: "Model to use for this agent (e.g., \"claude-sonnet-4-5-20250929\"). Defaults to the agent system default."
4003
- },
4004
- allowedTools: {
4005
- type: "array",
4006
- items: { type: "string" },
4007
- description: "Whitelist of tool name patterns the agent can use. Supports wildcards (e.g., \"file_*\", \"mcp__github__*\"). If omitted, all non-denied tools are available."
4008
- },
4009
- deniedTools: {
4010
- type: "array",
4011
- items: { type: "string" },
4012
- description: "Blacklist of tool name patterns the agent cannot use. agent_delegate and create_dynamic_agent are always denied."
4013
- },
4014
- thoroughness: {
4015
- type: "string",
4016
- enum: [
4017
- "quick",
4018
- "medium",
4019
- "very_thorough"
4020
- ],
4021
- description: `How thoroughly to execute:
4022
- - quick: Fast, 1-2 iterations
4023
- - medium: Balanced, 3-5 iterations (default)
4024
- - very_thorough: Comprehensive, 8-10+ iterations`
4025
- },
4026
- variables: {
4027
- type: "object",
4028
- additionalProperties: { type: "string" },
4029
- description: "Variables to substitute in the system prompt. For example: { \"DOMAIN\": \"auth\" } replaces $DOMAIN in the prompt."
4030
- },
4031
- run_in_background: {
4032
- type: "boolean",
4033
- description: "Run the agent in the background (non-blocking). Returns a job ID immediately. Use check_agent_status to poll for results."
4034
- },
4035
- group_description: {
4036
- type: "string",
4037
- description: "Short description of what this group of background agents is working on. Only needed for the first background agent in a group."
4038
- }
4039
- },
4040
- required: [
4041
- "task",
4042
- "name",
4043
- "systemPrompt"
4044
- ]
4045
- }
4046
- }
4047
- };
4048
- }
4049
- //#endregion
4050
- //#region src/tools/deferredToolRegistry.ts
4051
- /**
4052
- * Registry of tool schemas that are NOT loaded into the model's initial tool
4053
- * list. The model sees only the names (via the system prompt directory) and
4054
- * must call the `tool_search` meta-tool to load schemas on demand.
4055
- *
4056
- * This mirrors Claude Code's deferred-tool pattern. The win is large for
4057
- * heavy MCP integrations (e.g. 41 GitHub MCP tools at ~250-350 tokens of
4058
- * JSONSchema each = ~10-15k tokens per turn that's now ~1-1.5k of names).
4059
- */
4060
- var DeferredToolRegistry = class {
4061
- constructor() {
4062
- this.byName = /* @__PURE__ */ new Map();
4063
- }
4064
- /** Replace registry contents with the supplied tools. Idempotent. */
4065
- register(tools) {
4066
- this.byName.clear();
4067
- for (const tool of tools) this.byName.set(tool.toolSchema.name, tool);
4068
- logger.debug(`[DeferredToolRegistry] Registered ${tools.length} deferred tool(s)`);
4069
- }
4070
- clear() {
4071
- this.byName.clear();
4072
- }
4073
- size() {
4074
- return this.byName.size;
4075
- }
4076
- has(name) {
4077
- return this.byName.has(name);
4078
- }
4079
- get(name) {
4080
- return this.byName.get(name);
4081
- }
4082
- getAll() {
4083
- return Array.from(this.byName.values());
4084
- }
4085
- /** Return tools whose names appear in the supplied list, in input order. */
4086
- getByNames(names) {
4087
- const found = [];
4088
- for (const name of names) {
4089
- const tool = this.byName.get(name);
4090
- if (tool) found.push(tool);
4091
- }
4092
- return found;
4093
- }
4094
- /**
4095
- * Rank-search deferred tools by query terms. Name matches outrank
4096
- * description matches; exact substring on name wins ties.
4097
- */
4098
- searchByKeywords(query, maxResults) {
4099
- const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
4100
- if (terms.length === 0) return [];
4101
- const scored = [];
4102
- for (const tool of this.byName.values()) {
4103
- const name = tool.toolSchema.name.toLowerCase();
4104
- const desc = (tool.toolSchema.description || "").toLowerCase();
4105
- let score = 0;
4106
- for (const term of terms) {
4107
- if (name.includes(term)) score += 10;
4108
- if (desc.includes(term)) score += 1;
4109
- }
4110
- if (score > 0) scored.push({
4111
- tool,
4112
- score
4113
- });
4114
- }
4115
- scored.sort((a, b) => b.score - a.score);
4116
- return scored.slice(0, maxResults).map((s) => s.tool);
4117
- }
4118
- /** Return the directory entries used to render the system-prompt reminder. */
4119
- getDirectoryNames() {
4120
- return Array.from(this.byName.keys()).sort();
4121
- }
4122
- };
4123
- const deferredToolRegistry = new DeferredToolRegistry();
4124
- //#endregion
4125
- //#region src/tools/toolSearchTool.ts
4126
- /**
4127
- * Default number of tools returned for a keyword search. Matches Claude
4128
- * Code's ToolSearch convention. 5 keeps the response payload small while
4129
- * surfacing enough alternatives for the model to refine its query.
4130
- */
4131
- const DEFAULT_MAX_RESULTS = 5;
4132
- /**
4133
- * Runtime validation for tool_search params. The LLM produces these
4134
- * values, so we validate at this boundary rather than trusting the
4135
- * shape. Coerces `max_results` from string→number for models that emit
4136
- * numeric-looking strings.
4137
- */
4138
- const ToolSearchParamsSchema = z.object({
4139
- query: z.string().min(1, "query must be a non-empty string"),
4140
- max_results: z.coerce.number().int().min(1).max(20).optional()
4141
- });
4142
- /**
4143
- * Parse the query string. Two forms:
4144
- * - `select:name1,name2,...` — exact-name selection
4145
- * - free text — keyword search across name + description
4146
- */
4147
- function parseQuery(query) {
4148
- const trimmed = query.trim();
4149
- const selectMatch = trimmed.match(/^select:(.+)$/i);
4150
- if (selectMatch) return {
4151
- mode: "select",
4152
- names: selectMatch[1].split(",").map((n) => n.trim()).filter((n) => n.length > 0)
4153
- };
4154
- return {
4155
- mode: "search",
4156
- text: trimmed
4157
- };
4158
- }
4159
- /**
4160
- * Format the loaded-tools response. Mirrors Claude Code's convention:
4161
- * one <function>{...}</function> line per matched tool. The model has
4162
- * already seen this format in its tool-registration system messages, so
4163
- * it parses without additional explanation.
4164
- *
4165
- * Note: the schemas are *also* injected into context.tools by the caller,
4166
- * so on the next iteration the model gets them as native tool definitions.
4167
- * The text response here is for in-turn awareness and audit trail.
4168
- */
4169
- function renderToolsBlock(tools) {
4170
- if (tools.length === 0) return "";
4171
- return `<functions>\n${tools.map((tool) => {
4172
- const schema = {
4173
- description: tool.toolSchema.description,
4174
- name: tool.toolSchema.name,
4175
- parameters: tool.toolSchema.parameters
4176
- };
4177
- return `<function>${JSON.stringify(schema)}</function>`;
4178
- }).join("\n")}\n</functions>`;
4179
- }
4180
- /**
4181
- * Build the tool_search meta-tool. The returned tool has a closure over
4182
- * the supplied `toolListAccessor`, which it uses to push newly-resolved
4183
- * tool schemas into the live agent context.
4184
- *
4185
- * Idempotent: re-loading a tool that's already in the context is a no-op.
4186
- */
4187
- function createToolSearchTool(toolListAccessor) {
4188
- return {
4189
- toolSchema: {
4190
- name: "tool_search",
4191
- 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.",
4192
- parameters: {
4193
- type: "object",
4194
- properties: {
4195
- query: {
4196
- type: "string",
4197
- description: "Either 'select:<comma-separated names>' to fetch specific tools, or free-text keywords (e.g. 'github pull request') to rank-search deferred tools."
4198
- },
4199
- max_results: {
4200
- type: "number",
4201
- description: `Maximum number of tools to return for keyword search. Defaults to ${DEFAULT_MAX_RESULTS}. Ignored for 'select:' queries.`
4202
- }
4203
- },
4204
- required: ["query"]
4205
- }
4206
- },
4207
- toolFn: async (params) => {
4208
- const parsedParams = ToolSearchParamsSchema.safeParse(params ?? {});
4209
- if (!parsedParams.success) {
4210
- const issue = parsedParams.error.issues[0];
4211
- return `tool_search: invalid parameters — ${issue.path.join(".") || "params"}: ${issue.message}`;
4212
- }
4213
- const { query, max_results } = parsedParams.data;
4214
- const parsed = parseQuery(query);
4215
- let matched;
4216
- let unmatched = [];
4217
- if (parsed.mode === "select") {
4218
- matched = deferredToolRegistry.getByNames(parsed.names);
4219
- const foundNames = new Set(matched.map((t) => t.toolSchema.name));
4220
- unmatched = parsed.names.filter((n) => !foundNames.has(n));
4221
- } else {
4222
- const max = max_results ?? DEFAULT_MAX_RESULTS;
4223
- matched = deferredToolRegistry.searchByKeywords(parsed.text, max);
4224
- }
4225
- 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}".`;
4226
- const liveTools = toolListAccessor();
4227
- const liveNames = new Set(liveTools.map((t) => t.toolSchema.name));
4228
- let added = 0;
4229
- for (const tool of matched) if (!liveNames.has(tool.toolSchema.name)) {
4230
- liveTools.push(tool);
4231
- added++;
4232
- }
4233
- logger.debug(`[tool_search] query="${query}" matched=${matched.length} added=${added} alreadyLoaded=${matched.length - added}`);
4234
- const block = renderToolsBlock(matched);
4235
- 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}`;
4236
- }
4237
- };
4238
- }
4239
- //#endregion
4240
- //#region src/features/FeatureModuleRegistry.ts
4241
- /**
4242
- * Manages the lifecycle of opt-in CLI feature modules.
4243
- *
4244
- * Created during bootstrap, modules are conditionally registered based on config,
4245
- * then the registry's outputs are fed into tool generation and prompt building.
4246
- */
4247
- var FeatureModuleRegistry = class {
4248
- constructor() {
4249
- this.modules = [];
4250
- }
4251
- /** Register a feature module */
4252
- register(module) {
4253
- if (this.modules.some((m) => m.name === module.name)) throw new Error(`Feature module '${module.name}' is already registered`);
4254
- this.modules.push(module);
4255
- }
4256
- /** Collect all tools from all registered modules */
4257
- getAllTools() {
4258
- return this.modules.flatMap((m) => m.getTools());
4259
- }
4260
- /** Get all tool names from all registered modules */
4261
- getAllToolNames() {
4262
- return this.getAllTools().map((t) => t.toolSchema.name);
4263
- }
4264
- /** Build combined system prompt section from all modules */
4265
- getSystemPromptSections() {
4266
- const sections = this.modules.map((m) => m.getSystemPromptSection()).filter((s) => s.length > 0);
4267
- return sections.length > 0 ? "\n\n" + sections.join("\n\n") : "";
4268
- }
4269
- /** Register all WS handlers from all modules */
4270
- registerAllWsHandlers(wsManager) {
4271
- for (const module of this.modules) module.registerWsHandlers?.(wsManager);
4272
- }
4273
- /** Collect all slash commands from all registered modules */
4274
- getAllCommands() {
4275
- return this.modules.flatMap((m) => m.getCommands?.() ?? []);
4276
- }
4277
- /** Try to execute a slash command. Returns true if handled. */
4278
- executeCommand(name, args) {
4279
- for (const module of this.modules) {
4280
- const command = (module.getCommands?.() ?? []).find((c) => c.name === name);
4281
- if (command) {
4282
- command.execute(args);
4283
- return true;
4284
- }
4285
- }
4286
- return false;
4287
- }
4288
- /** Cleanup all modules */
4289
- disposeAll() {
4290
- for (const module of this.modules) module.dispose?.();
4291
- }
4292
- /** Get names of all registered modules */
4293
- getModuleNames() {
4294
- return this.modules.map((m) => m.name);
4295
- }
4296
- /** Check if any modules are registered */
4297
- get hasModules() {
4298
- return this.modules.length > 0;
4299
- }
4300
- };
4301
- //#endregion
4302
- //#region src/features/tavern/TavernService.ts
4303
- /**
4304
- * HTTP implementation of ITavernService using the shared ApiClient.
4305
- *
4306
- * Each method maps to one /api/tavern/* endpoint.
4307
- * Pure transport — no business logic.
4308
- */
4309
- var TavernService = class {
4310
- constructor(apiClient) {
4311
- this.apiClient = apiClient;
4312
- }
4313
- async listAgents() {
4314
- return this.apiClient.get("/api/agents?limit=100");
4315
- }
4316
- async createAgent(request) {
4317
- return this.apiClient.post("/api/agents", request);
4318
- }
4319
- async updateAgent(agentId, request) {
4320
- return this.apiClient.put(`/api/agents/${encodeURIComponent(agentId)}`, request);
4321
- }
4322
- async deleteAgent(agentId) {
4323
- await this.apiClient.delete(`/api/agents/${encodeURIComponent(agentId)}`);
4324
- }
4325
- async mentionAgent(agentName, message, config) {
4326
- return this.apiClient.post("/api/tavern/mention", {
4327
- agentName,
4328
- message,
4329
- config
4330
- });
4331
- }
4332
- async listQuests() {
4333
- return this.apiClient.get("/api/tavern/quests");
4334
- }
4335
- async postQuest(request) {
4336
- return this.apiClient.post("/api/tavern/quests", request);
4337
- }
4338
- async deleteQuest(questId) {
4339
- await this.apiClient.delete(`/api/tavern/quests`, { data: { questId } });
4340
- }
4341
- async getAgentNotebook(agentId, limit = 50) {
4342
- return this.apiClient.get(`/api/tavern/agent-notebook?agentId=${encodeURIComponent(agentId)}&limit=${limit}`);
4343
- }
4344
- async listGates() {
4345
- return this.apiClient.get("/api/tavern/gates");
4346
- }
4347
- async resolveGate(gateId, resolution) {
4348
- return this.apiClient.post("/api/tavern/gate-resolve", {
4349
- gateId,
4350
- resolution
4351
- });
4352
- }
4353
- async toggleHeartbeats(enabled) {
4354
- return this.apiClient.post("/api/tavern/toggle-heartbeats", { enabled });
4355
- }
4356
- async triggerHeartbeat(config) {
4357
- return this.apiClient.post("/api/tavern/trigger-heartbeat", { config });
4358
- }
4359
- async abortHeartbeats() {
4360
- await this.apiClient.post("/api/tavern/abort-heartbeats", { abort: true });
4361
- }
4362
- async getQuestPlan(planId) {
4363
- return this.apiClient.get(`/api/quest-master-plans/${encodeURIComponent(planId)}`);
4364
- }
4365
- async updateReviewGate(planId, questId, subQuestId, reviewStatus, reviewNote) {
4366
- return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/review-gate`, {
4367
- questId,
4368
- subQuestId,
4369
- reviewStatus,
4370
- reviewNote
4371
- });
4372
- }
4373
- async updateSubQuestProgress(planId, questId, subQuestId, updates) {
4374
- return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/subquest-progress`, {
4375
- questId,
4376
- subQuestId,
4377
- ...updates
4378
- });
4379
- }
4380
- async updateHandoff(planId, handoff) {
4381
- return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/handoff`, handoff);
4382
- }
4383
- };
4384
- //#endregion
4385
- //#region src/features/tavern/types.ts
4386
- /**
4387
- * Zod schemas and TypeScript types for the Tavern CLI integration.
3908
+ * Zod schemas and TypeScript types for the Tavern CLI integration.
4388
3909
  *
4389
3910
  * These map to the REST API shapes in apps/client/pages/api/tavern/*.
4390
3911
  */
@@ -5207,713 +4728,1697 @@ function createToggleHeartbeatsTool(service) {
5207
4728
  }
5208
4729
  },
5209
4730
  toolFn: async (params) => {
5210
- const { enabled } = params;
5211
- const result = await service.toggleHeartbeats(enabled);
4731
+ const { enabled } = params;
4732
+ const result = await service.toggleHeartbeats(enabled);
4733
+ return JSON.stringify(result);
4734
+ }
4735
+ };
4736
+ }
4737
+ function createTriggerHeartbeatTool(service) {
4738
+ return {
4739
+ toolSchema: {
4740
+ name: "tavern_trigger_heartbeat",
4741
+ description: "Manually trigger a heartbeat cycle for all Tavern agents. Useful for testing or forcing immediate agent activity.",
4742
+ parameters: {
4743
+ type: "object",
4744
+ properties: { config: {
4745
+ type: "object",
4746
+ description: "Optional configuration overrides for the heartbeat. Known keys: preferredModelId (string — model ID to use), costMode (\"low\" | \"normal\" | \"high\")",
4747
+ additionalProperties: true
4748
+ } }
4749
+ }
4750
+ },
4751
+ toolFn: async (params) => {
4752
+ const { config } = params ?? {};
4753
+ const result = await service.triggerHeartbeat(config);
4754
+ return JSON.stringify(result);
4755
+ }
4756
+ };
4757
+ }
4758
+ function createAbortHeartbeatsTool(service) {
4759
+ return {
4760
+ toolSchema: {
4761
+ name: "tavern_abort_heartbeats",
4762
+ description: "Emergency stop — abort all in-flight Tavern agent heartbeats immediately.",
4763
+ parameters: {
4764
+ type: "object",
4765
+ properties: {}
4766
+ }
4767
+ },
4768
+ toolFn: async () => {
4769
+ await service.abortHeartbeats();
4770
+ return JSON.stringify({
4771
+ success: true,
4772
+ message: "All in-flight heartbeats aborted"
4773
+ });
4774
+ }
4775
+ };
4776
+ }
4777
+ function createStatusTool(service) {
4778
+ return {
4779
+ toolSchema: {
4780
+ name: "tavern_status",
4781
+ description: "Get a quick overview of the Tavern: agent count, heartbeat status, active quests, and pending gates. Use this for situational awareness before taking action.",
4782
+ parameters: {
4783
+ type: "object",
4784
+ properties: {}
4785
+ }
4786
+ },
4787
+ toolFn: async () => {
4788
+ const [agents, quests, gates] = await Promise.all([
4789
+ service.listAgents(),
4790
+ service.listQuests(),
4791
+ service.listGates()
4792
+ ]);
4793
+ const agentList = agents.data ?? [];
4794
+ const heartbeatEnabled = agentList.filter((a) => a.heartbeatConfig?.enabled);
4795
+ return JSON.stringify({
4796
+ agents: {
4797
+ total: agentList.length,
4798
+ withHeartbeats: heartbeatEnabled.length,
4799
+ names: agentList.map((a) => ({
4800
+ name: a.name,
4801
+ heartbeat: a.heartbeatConfig?.enabled ?? false
4802
+ }))
4803
+ },
4804
+ quests: {
4805
+ total: quests.quests.length,
4806
+ byStatus: quests.quests.reduce((acc, q) => {
4807
+ acc[q.status] = (acc[q.status] ?? 0) + 1;
4808
+ return acc;
4809
+ }, {})
4810
+ },
4811
+ gates: { pending: gates.gates.length }
4812
+ });
4813
+ }
4814
+ };
4815
+ }
4816
+ function createGetQuestPlanTool(service) {
4817
+ return {
4818
+ toolSchema: {
4819
+ name: "tavern_get_quest_plan",
4820
+ description: "Fetch a quest master plan by ID. Returns the full plan with all quests, sub-quests, review gate status, handoff state, and progress metrics. Use this to check which sub-quests have review gates and their current status.",
4821
+ parameters: {
4822
+ type: "object",
4823
+ properties: { plan_id: {
4824
+ type: "string",
4825
+ description: "The MongoDB ObjectId of the quest master plan"
4826
+ } },
4827
+ required: ["plan_id"]
4828
+ }
4829
+ },
4830
+ toolFn: async (params) => {
4831
+ const { plan_id } = GetQuestPlanParamsSchema.parse(params);
4832
+ const result = await service.getQuestPlan(plan_id);
4833
+ return JSON.stringify(result);
4834
+ }
4835
+ };
4836
+ }
4837
+ function createUpdateReviewGateTool(service) {
4838
+ return {
4839
+ toolSchema: {
4840
+ name: "tavern_update_review_gate",
4841
+ description: "Approve or reject a review gate on a sub-quest. Review gates are human approval checkpoints — when a sub-quest has reviewGate: true, the AI must stop and wait for human approval before proceeding.",
4842
+ parameters: {
4843
+ type: "object",
4844
+ properties: {
4845
+ plan_id: {
4846
+ type: "string",
4847
+ description: "The MongoDB ObjectId of the quest master plan"
4848
+ },
4849
+ quest_id: {
4850
+ type: "string",
4851
+ description: "The ID of the parent quest"
4852
+ },
4853
+ sub_quest_id: {
4854
+ type: "string",
4855
+ description: "The ID of the sub-quest with the review gate"
4856
+ },
4857
+ review_status: {
4858
+ type: "string",
4859
+ description: "The review decision",
4860
+ enum: [
4861
+ "pending",
4862
+ "approved",
4863
+ "rejected"
4864
+ ]
4865
+ },
4866
+ review_note: {
4867
+ type: "string",
4868
+ description: "Optional note explaining the review decision"
4869
+ }
4870
+ },
4871
+ required: [
4872
+ "plan_id",
4873
+ "quest_id",
4874
+ "sub_quest_id",
4875
+ "review_status"
4876
+ ]
4877
+ }
4878
+ },
4879
+ toolFn: async (params) => {
4880
+ const { plan_id, quest_id, sub_quest_id, review_status, review_note } = UpdateReviewGateParamsSchema.parse(params);
4881
+ const result = await service.updateReviewGate(plan_id, quest_id, sub_quest_id, review_status, review_note);
4882
+ return JSON.stringify(result);
4883
+ }
4884
+ };
4885
+ }
4886
+ function createUpdateQuestProgressTool(service) {
4887
+ return {
4888
+ toolSchema: {
4889
+ name: "tavern_update_quest_progress",
4890
+ description: "Update a sub-quest's progress. Set status to track completion, add evidence of what was accomplished, and optionally record time spent. Setting status to \"in_progress\" auto-resumes a paused plan.",
4891
+ parameters: {
4892
+ type: "object",
4893
+ properties: {
4894
+ plan_id: {
4895
+ type: "string",
4896
+ description: "The MongoDB ObjectId of the quest master plan"
4897
+ },
4898
+ quest_id: {
4899
+ type: "string",
4900
+ description: "The ID of the parent quest"
4901
+ },
4902
+ sub_quest_id: {
4903
+ type: "string",
4904
+ description: "The ID of the sub-quest to update"
4905
+ },
4906
+ status: {
4907
+ type: "string",
4908
+ description: "New status for the sub-quest",
4909
+ enum: [
4910
+ "not_started",
4911
+ "in_progress",
4912
+ "completed",
4913
+ "skipped",
4914
+ "deleted"
4915
+ ]
4916
+ },
4917
+ evidence: {
4918
+ type: "string",
4919
+ description: "Evidence of completion — links to artifacts, descriptions of output, or references to results"
4920
+ },
4921
+ time_spent: {
4922
+ type: "number",
4923
+ description: "Time spent on this sub-quest in milliseconds"
4924
+ }
4925
+ },
4926
+ required: [
4927
+ "plan_id",
4928
+ "quest_id",
4929
+ "sub_quest_id"
4930
+ ]
4931
+ }
4932
+ },
4933
+ toolFn: async (params) => {
4934
+ const { plan_id, quest_id, sub_quest_id, status, evidence, time_spent } = UpdateQuestProgressParamsSchema.parse(params);
4935
+ const updates = {};
4936
+ if (status !== void 0) updates.status = status;
4937
+ if (evidence !== void 0) updates.evidence = evidence;
4938
+ if (time_spent !== void 0) updates.timeSpent = time_spent;
4939
+ const result = await service.updateSubQuestProgress(plan_id, quest_id, sub_quest_id, updates);
5212
4940
  return JSON.stringify(result);
5213
4941
  }
5214
4942
  };
5215
4943
  }
5216
- function createTriggerHeartbeatTool(service) {
4944
+ function createWriteHandoffTool(service) {
5217
4945
  return {
5218
4946
  toolSchema: {
5219
- name: "tavern_trigger_heartbeat",
5220
- description: "Manually trigger a heartbeat cycle for all Tavern agents. Useful for testing or forcing immediate agent activity.",
4947
+ name: "tavern_write_handoff",
4948
+ description: "Write a handoff state for session continuity. Called when ending a session so the next session can resume with full context. Includes a summary of progress, next steps, pending decisions, and blockers.",
5221
4949
  parameters: {
5222
4950
  type: "object",
5223
- properties: { config: {
5224
- type: "object",
5225
- description: "Optional configuration overrides for the heartbeat. Known keys: preferredModelId (string — model ID to use), costMode (\"low\" | \"normal\" | \"high\")",
5226
- additionalProperties: true
5227
- } }
4951
+ properties: {
4952
+ plan_id: {
4953
+ type: "string",
4954
+ description: "The MongoDB ObjectId of the quest master plan"
4955
+ },
4956
+ summary: {
4957
+ type: "string",
4958
+ description: "Summary of what was accomplished in this session"
4959
+ },
4960
+ next_steps: {
4961
+ type: "array",
4962
+ items: { type: "string" },
4963
+ description: "List of next steps for the following session"
4964
+ },
4965
+ pending_decisions: {
4966
+ type: "array",
4967
+ items: { type: "string" },
4968
+ description: "Decisions that still need to be made"
4969
+ },
4970
+ blockers: {
4971
+ type: "array",
4972
+ items: { type: "string" },
4973
+ description: "Current blockers preventing progress"
4974
+ }
4975
+ },
4976
+ required: [
4977
+ "plan_id",
4978
+ "summary",
4979
+ "next_steps",
4980
+ "pending_decisions",
4981
+ "blockers"
4982
+ ]
5228
4983
  }
5229
4984
  },
5230
4985
  toolFn: async (params) => {
5231
- const { config } = params ?? {};
5232
- const result = await service.triggerHeartbeat(config);
4986
+ const { plan_id, summary, next_steps, pending_decisions, blockers } = WriteHandoffParamsSchema.parse(params);
4987
+ const result = await service.updateHandoff(plan_id, {
4988
+ summary,
4989
+ nextSteps: next_steps,
4990
+ pendingDecisions: pending_decisions,
4991
+ blockers
4992
+ });
5233
4993
  return JSON.stringify(result);
5234
4994
  }
5235
4995
  };
5236
4996
  }
5237
- function createAbortHeartbeatsTool(service) {
5238
- return {
5239
- toolSchema: {
5240
- name: "tavern_abort_heartbeats",
5241
- description: "Emergency stop — abort all in-flight Tavern agent heartbeats immediately.",
5242
- parameters: {
5243
- type: "object",
5244
- properties: {}
4997
+ //#endregion
4998
+ //#region src/features/tavern/TavernModule.ts
4999
+ /** Icons for heartbeat log actions shown in /tavern command */
5000
+ const ACTION_ICONS = {
5001
+ speech: "💬",
5002
+ thought: "💭",
5003
+ memory: "🧠",
5004
+ move: "🚶",
5005
+ reply: "📩",
5006
+ post_quest: "📜",
5007
+ claim_quest: "⚔️",
5008
+ complete_quest: "✅",
5009
+ tool_use: "🔧",
5010
+ email: "📧",
5011
+ gate_paused: "⏸️",
5012
+ gate_timed: "⏳",
5013
+ gate_proceed: "▶️",
5014
+ idle: "💤",
5015
+ intent: "🎯",
5016
+ report: "📋",
5017
+ credits: "🪙",
5018
+ move_decoration: "🎨",
5019
+ yolo_override: "⚡"
5020
+ };
5021
+ /**
5022
+ * ICliFeatureModule implementation for the Tavern.
5023
+ *
5024
+ * Composes the service, tool adapters, WS activity stream, and slash
5025
+ * commands into a single module that the FeatureModuleRegistry can manage.
5026
+ */
5027
+ var TavernModule = class {
5028
+ constructor(apiClient, onLogEntry, getActivityLog) {
5029
+ this.name = "tavern";
5030
+ this.description = "Interact with autonomous AI agents in the B4M Tavern";
5031
+ this.service = new TavernService(apiClient);
5032
+ this.activityStream = new TavernActivityStream(onLogEntry);
5033
+ this.getActivityLog = getActivityLog;
5034
+ }
5035
+ getTools() {
5036
+ return createTavernTools(this.service);
5037
+ }
5038
+ getSystemPromptSection() {
5039
+ return `## Tavern Integration
5040
+ You can interact with autonomous AI agents in the B4M Tavern using tavern_* tools.
5041
+
5042
+ IMPORTANT: Many tools require agent IDs (MongoDB ObjectIds like "6540b58d1f703ade3ea1e82b"). Always use tavern_list_agents FIRST to discover agent names and their IDs before using tools that need an agent_id.
5043
+
5044
+ Available actions:
5045
+ - **tavern_list_agents**: List all agents with their IDs, names, and status — USE THIS FIRST
5046
+ - **tavern_create_agent**: Create a new agent with a personality (heartbeats disabled by default)
5047
+ - **tavern_edit_agent**: Update an agent's personality, system prompt, or heartbeat config (per-agent toggle)
5048
+ - **tavern_delete_agent**: Permanently delete an agent
5049
+ - **tavern_mention**: Talk to a specific agent by name or broadcast to all agents
5050
+ - **tavern_list_quests**: View the quest board
5051
+ - **tavern_post_quest**: Post a new quest for agents to claim
5052
+ - **tavern_delete_quest**: Remove a quest from the board
5053
+ - **tavern_read_notebook**: Read an agent's activity history (requires agent ID from tavern_list_agents)
5054
+ - **tavern_list_gates**: See pending confidence gates awaiting human approval
5055
+ - **tavern_resolve_gate**: Approve or reject a confidence gate
5056
+ - **tavern_toggle_heartbeats**: Enable/disable agent background heartbeats
5057
+ - **tavern_trigger_heartbeat**: Manually trigger a heartbeat cycle
5058
+ - **tavern_abort_heartbeats**: Emergency stop all in-flight heartbeats
5059
+ - **tavern_status**: Quick overview of agents, quests, and gates — good for situational awareness
5060
+ - **tavern_get_quest_plan**: Fetch a quest master plan with review gate status and handoff state
5061
+ - **tavern_update_review_gate**: Approve or reject a review gate on a sub-quest
5062
+ - **tavern_update_quest_progress**: Update sub-quest status and record evidence of completion
5063
+ - **tavern_write_handoff**: Write session handoff for continuity across sessions
5064
+
5065
+ When the user mentions talking to agents, checking the quest board, or managing the tavern, use these tools.
5066
+ Agents have personalities, moods, quests, and memories — they are autonomous entities, not chatbots.
5067
+
5068
+ ## Quest Workflow (Review Gates)
5069
+ When working on a quest plan with review gates (reviewGate: true on sub-quests), you MUST:
5070
+ 1. Check review gate status with tavern_get_quest_plan before proceeding past a gated step
5071
+ 2. If the next sub-quest has reviewGate: true and reviewStatus is not 'approved', STOP and inform the user
5072
+ 3. Record evidence of completion with tavern_update_quest_progress when finishing a sub-quest
5073
+ 4. Write a handoff with tavern_write_handoff before ending a session with an active quest plan
5074
+
5075
+ ## Session Handoff & Resume
5076
+ When the user runs /quest resume <plan_id>, read the handoff context and continue from where the previous session left off. The handoff contains: summary of prior work, next steps, pending decisions, and blockers.`;
5077
+ }
5078
+ getCommands() {
5079
+ return [{
5080
+ name: "tavern",
5081
+ description: "Show recent Tavern agent activity stream",
5082
+ execute: () => {
5083
+ const activityLog = this.getActivityLog();
5084
+ if (activityLog.length === 0) {
5085
+ console.log("\nTavern Activity: No activity yet.");
5086
+ console.log(" Agents broadcast activity during heartbeats.");
5087
+ console.log(" Try: \"trigger a heartbeat cycle\" to generate activity.\n");
5088
+ return;
5089
+ }
5090
+ const recentEntries = activityLog.slice(-20);
5091
+ console.log(`\nTavern Activity (last ${recentEntries.length} of ${activityLog.length} entries):\n`);
5092
+ for (const entry of recentEntries) {
5093
+ const time = new Date(entry.timestamp).toLocaleTimeString();
5094
+ const icon = ACTION_ICONS[entry.action] ?? "·";
5095
+ const target = entry.targetAgentName ? ` \u2192 ${entry.targetAgentName}` : "";
5096
+ const text = entry.text ? `: ${entry.text.slice(0, 120)}${entry.text.length > 120 ? "..." : ""}` : "";
5097
+ console.log(` ${time} ${icon} ${entry.agentName}${target} [${entry.action}]${text}`);
5098
+ }
5099
+ console.log("");
5100
+ }
5101
+ }, {
5102
+ name: "quest",
5103
+ description: "Quest workflow commands: review gates, resume from handoff",
5104
+ execute: async (args) => {
5105
+ const subCommand = args[0];
5106
+ if (subCommand === "resume") {
5107
+ const planId = args[1];
5108
+ if (!planId) {
5109
+ console.log("\nUsage: /quest resume <plan_id>");
5110
+ console.log(" Loads the quest plan and displays the session handoff for continuity.\n");
5111
+ return;
5112
+ }
5113
+ try {
5114
+ const plan = await this.service.getQuestPlan(planId);
5115
+ console.log(`\nQuest Plan: ${plan.goal}`);
5116
+ console.log(`State: ${plan.state ?? "unknown"}`);
5117
+ if (plan.metrics) {
5118
+ const pct = Math.round(plan.metrics.completionRate * 100);
5119
+ console.log(`Progress: ${plan.metrics.subQuestsCompleted}/${plan.metrics.subQuestsTotal} sub-quests (${pct}%)`);
5120
+ }
5121
+ if (!plan.handoff) {
5122
+ console.log("\n No handoff found — this plan has no saved session context.");
5123
+ console.log(" The AI can still read the plan via tavern_get_quest_plan.\n");
5124
+ return;
5125
+ }
5126
+ const { handoff } = plan;
5127
+ console.log(`\nSession Handoff (${new Date(handoff.updatedAt).toLocaleString()}):`);
5128
+ console.log(`\n Summary: ${handoff.summary}`);
5129
+ if (handoff.nextSteps.length > 0) {
5130
+ console.log("\n Next Steps:");
5131
+ for (const step of handoff.nextSteps) console.log(` \u2022 ${step}`);
5132
+ }
5133
+ if (handoff.pendingDecisions.length > 0) {
5134
+ console.log("\n Pending Decisions:");
5135
+ for (const decision of handoff.pendingDecisions) console.log(` \u2753 ${decision}`);
5136
+ }
5137
+ if (handoff.blockers.length > 0) {
5138
+ console.log("\n Blockers:");
5139
+ for (const blocker of handoff.blockers) console.log(` \u{1F6D1} ${blocker}`);
5140
+ }
5141
+ console.log("");
5142
+ } catch (error) {
5143
+ const message = error instanceof Error ? error.message : String(error);
5144
+ console.log(`\nError fetching quest plan: ${message}\n`);
5145
+ }
5146
+ return;
5147
+ }
5148
+ if (subCommand === "review") {
5149
+ const planId = args[1];
5150
+ if (!planId) {
5151
+ console.log("\nUsage: /quest review <plan_id>");
5152
+ console.log(" Fetches the quest plan and shows sub-quests with pending review gates.\n");
5153
+ return;
5154
+ }
5155
+ try {
5156
+ const plan = await this.service.getQuestPlan(planId);
5157
+ const pendingGates = [];
5158
+ for (const quest of plan.quests) for (const sq of quest.subQuests) if (sq.reviewGate) pendingGates.push({
5159
+ questTitle: quest.title,
5160
+ questId: quest.id,
5161
+ subQuestTitle: sq.title,
5162
+ subQuestId: sq.id,
5163
+ status: sq.reviewStatus ?? "pending"
5164
+ });
5165
+ if (pendingGates.length === 0) {
5166
+ console.log(`\nQuest Plan: ${plan.goal}`);
5167
+ console.log(" No review gates configured in this plan.\n");
5168
+ return;
5169
+ }
5170
+ console.log(`\nQuest Plan: ${plan.goal}`);
5171
+ console.log(`State: ${plan.state ?? "unknown"}\n`);
5172
+ console.log("Review Gates:");
5173
+ for (const gate of pendingGates) {
5174
+ const icon = gate.status === "approved" ? "✅" : gate.status === "rejected" ? "❌" : "⏸️";
5175
+ console.log(` ${icon} [${gate.status}] ${gate.questTitle} > ${gate.subQuestTitle}`);
5176
+ console.log(` quest_id: ${gate.questId} sub_quest_id: ${gate.subQuestId}`);
5177
+ }
5178
+ console.log("\n To approve: ask the AI to approve a review gate, or use tavern_update_review_gate tool.\n");
5179
+ } catch (error) {
5180
+ const message = error instanceof Error ? error.message : String(error);
5181
+ console.log(`\nError fetching quest plan: ${message}\n`);
5182
+ }
5183
+ return;
5184
+ }
5185
+ console.log("\nUsage:");
5186
+ console.log(" /quest review <plan_id> — Show review gates and their status");
5187
+ console.log(" /quest resume <plan_id> — Load handoff and resume from where you left off");
5188
+ console.log("");
5245
5189
  }
5246
- },
5247
- toolFn: async () => {
5248
- await service.abortHeartbeats();
5249
- return JSON.stringify({
5250
- success: true,
5251
- message: "All in-flight heartbeats aborted"
5252
- });
5253
- }
5254
- };
5190
+ }];
5191
+ }
5192
+ registerWsHandlers(wsManager) {
5193
+ this.activityStream.registerHandlers(wsManager);
5194
+ }
5195
+ dispose() {
5196
+ this.activityStream.dispose();
5197
+ }
5198
+ };
5199
+ //#endregion
5200
+ //#region src/features/bridgePresence/BridgePresence.ts
5201
+ const DEFAULT_PORT = Number(process.env.CC_BRIDGE_PORT ?? 48732);
5202
+ const CONFIG_PATH = join(homedir(), ".b4m", "cc-bridge.json");
5203
+ const ANNOUNCE_TIMEOUT_MS = 2e3;
5204
+ async function readBridgeConfig() {
5205
+ try {
5206
+ const raw = await promises.readFile(CONFIG_PATH, "utf8");
5207
+ const parsed = JSON.parse(raw);
5208
+ if (typeof parsed.hookSecret !== "string" || !parsed.hookSecret) return null;
5209
+ return {
5210
+ port: typeof parsed.port === "number" ? parsed.port : DEFAULT_PORT,
5211
+ hookSecret: parsed.hookSecret
5212
+ };
5213
+ } catch {
5214
+ return null;
5215
+ }
5255
5216
  }
5256
- function createStatusTool(service) {
5257
- return {
5258
- toolSchema: {
5259
- name: "tavern_status",
5260
- description: "Get a quick overview of the Tavern: agent count, heartbeat status, active quests, and pending gates. Use this for situational awareness before taking action.",
5261
- parameters: {
5262
- type: "object",
5263
- properties: {}
5264
- }
5265
- },
5266
- toolFn: async () => {
5267
- const [agents, quests, gates] = await Promise.all([
5268
- service.listAgents(),
5269
- service.listQuests(),
5270
- service.listGates()
5271
- ]);
5272
- const agentList = agents.data ?? [];
5273
- const heartbeatEnabled = agentList.filter((a) => a.heartbeatConfig?.enabled);
5274
- return JSON.stringify({
5275
- agents: {
5276
- total: agentList.length,
5277
- withHeartbeats: heartbeatEnabled.length,
5278
- names: agentList.map((a) => ({
5279
- name: a.name,
5280
- heartbeat: a.heartbeatConfig?.enabled ?? false
5281
- }))
5282
- },
5283
- quests: {
5284
- total: quests.quests.length,
5285
- byStatus: quests.quests.reduce((acc, q) => {
5286
- acc[q.status] = (acc[q.status] ?? 0) + 1;
5287
- return acc;
5288
- }, {})
5289
- },
5290
- gates: { pending: gates.gates.length }
5291
- });
5217
+ var BridgePresence = class {
5218
+ constructor() {
5219
+ this.config = null;
5220
+ this.instanceId = null;
5221
+ this.ws = null;
5222
+ this.callbacks = {};
5223
+ this.started = false;
5224
+ this.stopped = false;
5225
+ this.reconnectTimer = null;
5226
+ this.reconnectAttempts = 0;
5227
+ this.announceRetryTimer = null;
5228
+ this.announceAttempts = 0;
5229
+ this.startOpts = null;
5230
+ this.pendingWorkspaceName = null;
5231
+ this.pendingCapabilities = null;
5232
+ this.pendingSource = null;
5233
+ this.emitQueue = Promise.resolve();
5234
+ }
5235
+ setCallbacks(cbs) {
5236
+ this.callbacks = cbs;
5237
+ }
5238
+ /**
5239
+ * Probe the local bridge and, if present, announce this CLI session.
5240
+ * Returns true iff the announce succeeded (tavern presence is active).
5241
+ * Safe to call multiple times — second call no-ops.
5242
+ *
5243
+ * If announce fails (bridge absent or not yet up), a background retry
5244
+ * loop keeps trying with bounded backoff so the sprite appears when the
5245
+ * bridge comes online later in the CLI's lifetime.
5246
+ */
5247
+ async start(opts) {
5248
+ if (this.started) return this.instanceId !== null;
5249
+ this.stopped = false;
5250
+ this.started = true;
5251
+ const config = await readBridgeConfig();
5252
+ if (!config) {
5253
+ logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
5254
+ return false;
5292
5255
  }
5293
- };
5294
- }
5295
- function createGetQuestPlanTool(service) {
5296
- return {
5297
- toolSchema: {
5298
- name: "tavern_get_quest_plan",
5299
- description: "Fetch a quest master plan by ID. Returns the full plan with all quests, sub-quests, review gate status, handoff state, and progress metrics. Use this to check which sub-quests have review gates and their current status.",
5300
- parameters: {
5301
- type: "object",
5302
- properties: { plan_id: {
5303
- type: "string",
5304
- description: "The MongoDB ObjectId of the quest master plan"
5305
- } },
5306
- required: ["plan_id"]
5307
- }
5308
- },
5309
- toolFn: async (params) => {
5310
- const { plan_id } = GetQuestPlanParamsSchema.parse(params);
5311
- const result = await service.getQuestPlan(plan_id);
5312
- return JSON.stringify(result);
5256
+ this.config = config;
5257
+ this.startOpts = opts;
5258
+ this.pendingWorkspaceName = opts.workspaceName ?? (basename(opts.workspacePath) || "workspace");
5259
+ this.pendingCapabilities = opts.capabilities ?? ["interactive"];
5260
+ this.pendingSource = opts.source ?? "b4m-cli";
5261
+ return this.attemptAnnounce();
5262
+ }
5263
+ /** One announce attempt. Schedules a retry on failure; wires up the
5264
+ * command WS + initial status on success. Idempotent: re-entering after
5265
+ * a successful announce short-circuits at the instanceId guard. */
5266
+ async attemptAnnounce() {
5267
+ if (this.stopped || !this.config || !this.startOpts) return false;
5268
+ if (this.instanceId) return true;
5269
+ const instanceId = v4();
5270
+ const workspaceName = this.pendingWorkspaceName;
5271
+ const capabilities = this.pendingCapabilities;
5272
+ const source = this.pendingSource;
5273
+ if (!await this.announce({
5274
+ instanceId,
5275
+ source,
5276
+ workspaceName,
5277
+ workspacePath: this.startOpts.workspacePath,
5278
+ capabilities
5279
+ })) {
5280
+ this.scheduleAnnounceRetry();
5281
+ return false;
5313
5282
  }
5314
- };
5315
- }
5316
- function createUpdateReviewGateTool(service) {
5317
- return {
5318
- toolSchema: {
5319
- name: "tavern_update_review_gate",
5320
- description: "Approve or reject a review gate on a sub-quest. Review gates are human approval checkpoints — when a sub-quest has reviewGate: true, the AI must stop and wait for human approval before proceeding.",
5321
- parameters: {
5322
- type: "object",
5323
- properties: {
5324
- plan_id: {
5325
- type: "string",
5326
- description: "The MongoDB ObjectId of the quest master plan"
5327
- },
5328
- quest_id: {
5329
- type: "string",
5330
- description: "The ID of the parent quest"
5331
- },
5332
- sub_quest_id: {
5333
- type: "string",
5334
- description: "The ID of the sub-quest with the review gate"
5335
- },
5336
- review_status: {
5337
- type: "string",
5338
- description: "The review decision",
5339
- enum: [
5340
- "pending",
5341
- "approved",
5342
- "rejected"
5343
- ]
5344
- },
5345
- review_note: {
5346
- type: "string",
5347
- description: "Optional note explaining the review decision"
5348
- }
5349
- },
5350
- required: [
5351
- "plan_id",
5352
- "quest_id",
5353
- "sub_quest_id",
5354
- "review_status"
5355
- ]
5356
- }
5357
- },
5358
- toolFn: async (params) => {
5359
- const { plan_id, quest_id, sub_quest_id, review_status, review_note } = UpdateReviewGateParamsSchema.parse(params);
5360
- const result = await service.updateReviewGate(plan_id, quest_id, sub_quest_id, review_status, review_note);
5361
- return JSON.stringify(result);
5283
+ this.instanceId = instanceId;
5284
+ this.announceAttempts = 0;
5285
+ logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
5286
+ this.connectCommandWs();
5287
+ this.emitEvent({
5288
+ type: "status",
5289
+ status: "idle"
5290
+ });
5291
+ return true;
5292
+ }
5293
+ scheduleAnnounceRetry() {
5294
+ if (this.stopped || this.announceRetryTimer) return;
5295
+ this.announceAttempts += 1;
5296
+ const delay = Math.min(1e3 * 2 ** (this.announceAttempts - 1), 3e4);
5297
+ this.announceRetryTimer = setTimeout(() => {
5298
+ this.announceRetryTimer = null;
5299
+ this.attemptAnnounce();
5300
+ }, delay);
5301
+ }
5302
+ /** Emit an event for this session. No-op if the bridge isn't up. Events
5303
+ * leave in strict order see `emitQueue` comment. */
5304
+ async emitEvent(event) {
5305
+ if (!this.config || !this.instanceId) return;
5306
+ const task = () => this.post("/event", {
5307
+ instanceId: this.instanceId,
5308
+ event
5309
+ }).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
5310
+ this.emitQueue = this.emitQueue.then(task, task);
5311
+ return this.emitQueue;
5312
+ }
5313
+ /**
5314
+ * Tear down the tavern presence cleanly. Halts the announce-retry and
5315
+ * command-WS reconnect loops, closes the socket, and best-effort signals
5316
+ * disconnect to the bridge.
5317
+ *
5318
+ * After this resolves the instance is fully reset, so a later `start()`
5319
+ * re-announces — the same singleton can be toggled off (Tavern feature
5320
+ * disabled at runtime) and back on without restarting the CLI. The
5321
+ * `stopped` latch is left true here purely so any straggler retry callback
5322
+ * already queued short-circuits; `start()` clears it.
5323
+ */
5324
+ async stop(reason = "cli_exit") {
5325
+ if (this.stopped || !this.started) return;
5326
+ this.stopped = true;
5327
+ if (this.reconnectTimer) {
5328
+ clearTimeout(this.reconnectTimer);
5329
+ this.reconnectTimer = null;
5362
5330
  }
5363
- };
5364
- }
5365
- function createUpdateQuestProgressTool(service) {
5366
- return {
5367
- toolSchema: {
5368
- name: "tavern_update_quest_progress",
5369
- description: "Update a sub-quest's progress. Set status to track completion, add evidence of what was accomplished, and optionally record time spent. Setting status to \"in_progress\" auto-resumes a paused plan.",
5370
- parameters: {
5371
- type: "object",
5372
- properties: {
5373
- plan_id: {
5374
- type: "string",
5375
- description: "The MongoDB ObjectId of the quest master plan"
5376
- },
5377
- quest_id: {
5378
- type: "string",
5379
- description: "The ID of the parent quest"
5380
- },
5381
- sub_quest_id: {
5382
- type: "string",
5383
- description: "The ID of the sub-quest to update"
5384
- },
5385
- status: {
5386
- type: "string",
5387
- description: "New status for the sub-quest",
5388
- enum: [
5389
- "not_started",
5390
- "in_progress",
5391
- "completed",
5392
- "skipped",
5393
- "deleted"
5394
- ]
5395
- },
5396
- evidence: {
5397
- type: "string",
5398
- description: "Evidence of completion — links to artifacts, descriptions of output, or references to results"
5399
- },
5400
- time_spent: {
5401
- type: "number",
5402
- description: "Time spent on this sub-quest in milliseconds"
5403
- }
5404
- },
5405
- required: [
5406
- "plan_id",
5407
- "quest_id",
5408
- "sub_quest_id"
5409
- ]
5410
- }
5411
- },
5412
- toolFn: async (params) => {
5413
- const { plan_id, quest_id, sub_quest_id, status, evidence, time_spent } = UpdateQuestProgressParamsSchema.parse(params);
5414
- const updates = {};
5415
- if (status !== void 0) updates.status = status;
5416
- if (evidence !== void 0) updates.evidence = evidence;
5417
- if (time_spent !== void 0) updates.timeSpent = time_spent;
5418
- const result = await service.updateSubQuestProgress(plan_id, quest_id, sub_quest_id, updates);
5419
- return JSON.stringify(result);
5331
+ if (this.announceRetryTimer) {
5332
+ clearTimeout(this.announceRetryTimer);
5333
+ this.announceRetryTimer = null;
5420
5334
  }
5421
- };
5422
- }
5423
- function createWriteHandoffTool(service) {
5424
- return {
5425
- toolSchema: {
5426
- name: "tavern_write_handoff",
5427
- description: "Write a handoff state for session continuity. Called when ending a session so the next session can resume with full context. Includes a summary of progress, next steps, pending decisions, and blockers.",
5428
- parameters: {
5429
- type: "object",
5430
- properties: {
5431
- plan_id: {
5432
- type: "string",
5433
- description: "The MongoDB ObjectId of the quest master plan"
5434
- },
5435
- summary: {
5436
- type: "string",
5437
- description: "Summary of what was accomplished in this session"
5438
- },
5439
- next_steps: {
5440
- type: "array",
5441
- items: { type: "string" },
5442
- description: "List of next steps for the following session"
5443
- },
5444
- pending_decisions: {
5445
- type: "array",
5446
- items: { type: "string" },
5447
- description: "Decisions that still need to be made"
5448
- },
5449
- blockers: {
5450
- type: "array",
5451
- items: { type: "string" },
5452
- description: "Current blockers preventing progress"
5453
- }
5454
- },
5455
- required: [
5456
- "plan_id",
5457
- "summary",
5458
- "next_steps",
5459
- "pending_decisions",
5460
- "blockers"
5461
- ]
5335
+ if (this.ws) {
5336
+ try {
5337
+ this.ws.close();
5338
+ } catch {}
5339
+ this.ws = null;
5340
+ }
5341
+ if (this.config && this.instanceId) await this.post("/disconnect", {
5342
+ instanceId: this.instanceId,
5343
+ reason
5344
+ }).catch(() => {});
5345
+ this.started = false;
5346
+ this.instanceId = null;
5347
+ this.config = null;
5348
+ this.startOpts = null;
5349
+ this.pendingWorkspaceName = null;
5350
+ this.pendingCapabilities = null;
5351
+ this.pendingSource = null;
5352
+ this.announceAttempts = 0;
5353
+ this.reconnectAttempts = 0;
5354
+ this.emitQueue = Promise.resolve();
5355
+ }
5356
+ async announce(body) {
5357
+ try {
5358
+ await this.post("/announce", body);
5359
+ return true;
5360
+ } catch (err) {
5361
+ logger.info(`[tavern] bridge announce failed: ${err.message}`);
5362
+ return false;
5363
+ }
5364
+ }
5365
+ async post(path, body) {
5366
+ if (!this.config) throw new Error("bridge config not loaded");
5367
+ const url = `http://127.0.0.1:${this.config.port ?? DEFAULT_PORT}${path}?secret=${encodeURIComponent(this.config.hookSecret)}`;
5368
+ const res = await fetch(url, {
5369
+ method: "POST",
5370
+ headers: { "Content-Type": "application/json" },
5371
+ body: JSON.stringify(body),
5372
+ signal: AbortSignal.timeout(ANNOUNCE_TIMEOUT_MS)
5373
+ });
5374
+ if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
5375
+ }
5376
+ connectCommandWs() {
5377
+ if (this.stopped || !this.config || !this.instanceId) return;
5378
+ const url = `ws://127.0.0.1:${this.config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(this.instanceId)}&secret=${encodeURIComponent(this.config.hookSecret)}`;
5379
+ let ws;
5380
+ try {
5381
+ ws = new WsWebSocket(url);
5382
+ } catch (err) {
5383
+ logger.debug(`[tavern] command WS construct failed: ${err.message}`);
5384
+ this.scheduleReconnect();
5385
+ return;
5386
+ }
5387
+ this.ws = ws;
5388
+ ws.on("open", () => {
5389
+ this.reconnectAttempts = 0;
5390
+ logger.debug("[tavern] command WS open");
5391
+ });
5392
+ ws.on("message", (raw) => {
5393
+ let frame = null;
5394
+ try {
5395
+ frame = JSON.parse(raw.toString());
5396
+ } catch {
5397
+ logger.debug("[tavern] malformed command frame; ignored");
5398
+ return;
5462
5399
  }
5463
- },
5464
- toolFn: async (params) => {
5465
- const { plan_id, summary, next_steps, pending_decisions, blockers } = WriteHandoffParamsSchema.parse(params);
5466
- const result = await service.updateHandoff(plan_id, {
5467
- summary,
5468
- nextSteps: next_steps,
5469
- pendingDecisions: pending_decisions,
5470
- blockers
5471
- });
5472
- return JSON.stringify(result);
5400
+ if (!frame?.command) return;
5401
+ this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
5402
+ });
5403
+ ws.on("close", () => {
5404
+ this.ws = null;
5405
+ if (this.stopped) return;
5406
+ logger.debug("[tavern] command WS closed; reconnecting");
5407
+ this.scheduleReconnect();
5408
+ });
5409
+ ws.on("error", (err) => {
5410
+ logger.debug(`[tavern] command WS error: ${err.message}`);
5411
+ });
5412
+ }
5413
+ scheduleReconnect() {
5414
+ if (this.stopped || this.reconnectTimer) return;
5415
+ this.reconnectAttempts += 1;
5416
+ const delay = Math.min(500 * 2 ** (this.reconnectAttempts - 1), 1e4);
5417
+ this.reconnectTimer = setTimeout(() => {
5418
+ this.reconnectTimer = null;
5419
+ this.connectCommandWs();
5420
+ }, delay);
5421
+ }
5422
+ async dispatchCommand(command) {
5423
+ switch (command.type) {
5424
+ case "send_prompt":
5425
+ if (this.callbacks.onSendPrompt) await this.callbacks.onSendPrompt(command.text);
5426
+ break;
5427
+ case "resolve_permission":
5428
+ if (this.callbacks.onResolvePermission) await this.callbacks.onResolvePermission(command.requestId, command.allow);
5429
+ break;
5430
+ case "abort":
5431
+ if (this.callbacks.onAbort) await this.callbacks.onAbort();
5432
+ break;
5473
5433
  }
5474
- };
5475
- }
5434
+ }
5435
+ };
5436
+ /** Process-wide singleton — the CLI only ever has one tavern presence per run. */
5437
+ const bridgePresence = new BridgePresence();
5476
5438
  //#endregion
5477
- //#region src/features/tavern/TavernModule.ts
5478
- /** Icons for heartbeat log actions shown in /tavern command */
5479
- const ACTION_ICONS = {
5480
- speech: "💬",
5481
- thought: "💭",
5482
- memory: "🧠",
5483
- move: "🚶",
5484
- reply: "📩",
5485
- post_quest: "📜",
5486
- claim_quest: "⚔️",
5487
- complete_quest: "✅",
5488
- tool_use: "🔧",
5489
- email: "📧",
5490
- gate_paused: "⏸️",
5491
- gate_timed: "⏳",
5492
- gate_proceed: "▶️",
5493
- idle: "💤",
5494
- intent: "🎯",
5495
- report: "📋",
5496
- credits: "🪙",
5497
- move_decoration: "🎨",
5498
- yolo_override: "⚡"
5439
+ //#region src/llm/MultiLlmBackend.ts
5440
+ /**
5441
+ * Routes completions between B4M server and a local Ollama instance
5442
+ * based on the selected model's backend type.
5443
+ */
5444
+ var MultiLlmBackend = class {
5445
+ constructor(serverBackend, ollamaBackend, serverModels, ollamaModels, initialModel) {
5446
+ this.serverBackend = serverBackend;
5447
+ this.ollamaBackend = ollamaBackend;
5448
+ this.serverModels = serverModels;
5449
+ this.ollamaModels = ollamaModels;
5450
+ this.currentModel = initialModel;
5451
+ this.ollamaModelIds = new Set(ollamaModels.map((m) => m.id));
5452
+ }
5453
+ get activeBackend() {
5454
+ return this.ollamaModelIds.has(this.currentModel) ? this.ollamaBackend : this.serverBackend;
5455
+ }
5456
+ async complete(model, messages, options, callback) {
5457
+ return (this.ollamaModelIds.has(model) ? this.ollamaBackend : this.serverBackend).complete(model, messages, options, callback);
5458
+ }
5459
+ pushToolMessages(messages, tool, result, thinkingBlocks) {
5460
+ this.activeBackend.pushToolMessages(messages, tool, result, thinkingBlocks);
5461
+ }
5462
+ async getModelInfo() {
5463
+ return [...this.serverModels, ...this.ollamaModels];
5464
+ }
5499
5465
  };
5466
+ //#endregion
5467
+ //#region src/utils/jupyterClient.ts
5500
5468
  /**
5501
- * ICliFeatureModule implementation for the Tavern.
5469
+ * Jupyter Server REST API Client
5502
5470
  *
5503
- * Composes the service, tool adapters, WS activity stream, and slash
5504
- * commands into a single module that the FeatureModuleRegistry can manage.
5471
+ * Provides methods for interacting with a local Jupyter Server instance.
5472
+ * Used by Keep commands to execute notebooks on the user's local machine.
5473
+ *
5474
+ * @see https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html
5475
+ * @see https://jupyter-client.readthedocs.io/en/latest/messaging.html
5505
5476
  */
5506
- var TavernModule = class {
5507
- constructor(apiClient, onLogEntry, getActivityLog) {
5508
- this.name = "tavern";
5509
- this.description = "Interact with autonomous AI agents in the B4M Tavern";
5510
- this.service = new TavernService(apiClient);
5511
- this.activityStream = new TavernActivityStream(onLogEntry);
5512
- this.getActivityLog = getActivityLog;
5477
+ var JupyterClientError = class extends Error {
5478
+ constructor(message, statusCode, response) {
5479
+ super(message);
5480
+ this.statusCode = statusCode;
5481
+ this.response = response;
5482
+ this.name = "JupyterClientError";
5513
5483
  }
5514
- getTools() {
5515
- return createTavernTools(this.service);
5484
+ };
5485
+ /**
5486
+ * Validate Jupyter server URL
5487
+ */
5488
+ function validateServerUrl(url) {
5489
+ if (!url || typeof url !== "string") throw new JupyterClientError("Server URL is required");
5490
+ let parsed;
5491
+ try {
5492
+ parsed = new URL(url);
5493
+ } catch {
5494
+ throw new JupyterClientError(`Invalid server URL: ${url}`);
5516
5495
  }
5517
- getSystemPromptSection() {
5518
- return `## Tavern Integration
5519
- You can interact with autonomous AI agents in the B4M Tavern using tavern_* tools.
5520
-
5521
- IMPORTANT: Many tools require agent IDs (MongoDB ObjectIds like "6540b58d1f703ade3ea1e82b"). Always use tavern_list_agents FIRST to discover agent names and their IDs before using tools that need an agent_id.
5522
-
5523
- Available actions:
5524
- - **tavern_list_agents**: List all agents with their IDs, names, and status — USE THIS FIRST
5525
- - **tavern_create_agent**: Create a new agent with a personality (heartbeats disabled by default)
5526
- - **tavern_edit_agent**: Update an agent's personality, system prompt, or heartbeat config (per-agent toggle)
5527
- - **tavern_delete_agent**: Permanently delete an agent
5528
- - **tavern_mention**: Talk to a specific agent by name or broadcast to all agents
5529
- - **tavern_list_quests**: View the quest board
5530
- - **tavern_post_quest**: Post a new quest for agents to claim
5531
- - **tavern_delete_quest**: Remove a quest from the board
5532
- - **tavern_read_notebook**: Read an agent's activity history (requires agent ID from tavern_list_agents)
5533
- - **tavern_list_gates**: See pending confidence gates awaiting human approval
5534
- - **tavern_resolve_gate**: Approve or reject a confidence gate
5535
- - **tavern_toggle_heartbeats**: Enable/disable agent background heartbeats
5536
- - **tavern_trigger_heartbeat**: Manually trigger a heartbeat cycle
5537
- - **tavern_abort_heartbeats**: Emergency stop all in-flight heartbeats
5538
- - **tavern_status**: Quick overview of agents, quests, and gates — good for situational awareness
5539
- - **tavern_get_quest_plan**: Fetch a quest master plan with review gate status and handoff state
5540
- - **tavern_update_review_gate**: Approve or reject a review gate on a sub-quest
5541
- - **tavern_update_quest_progress**: Update sub-quest status and record evidence of completion
5542
- - **tavern_write_handoff**: Write session handoff for continuity across sessions
5543
-
5544
- When the user mentions talking to agents, checking the quest board, or managing the tavern, use these tools.
5545
- Agents have personalities, moods, quests, and memories — they are autonomous entities, not chatbots.
5546
-
5547
- ## Quest Workflow (Review Gates)
5548
- When working on a quest plan with review gates (reviewGate: true on sub-quests), you MUST:
5549
- 1. Check review gate status with tavern_get_quest_plan before proceeding past a gated step
5550
- 2. If the next sub-quest has reviewGate: true and reviewStatus is not 'approved', STOP and inform the user
5551
- 3. Record evidence of completion with tavern_update_quest_progress when finishing a sub-quest
5552
- 4. Write a handoff with tavern_write_handoff before ending a session with an active quest plan
5553
-
5554
- ## Session Handoff & Resume
5555
- When the user runs /quest resume <plan_id>, read the handoff context and continue from where the previous session left off. The handoff contains: summary of prior work, next steps, pending decisions, and blockers.`;
5496
+ if (!["http:", "https:"].includes(parsed.protocol)) throw new JupyterClientError(`Invalid protocol: ${parsed.protocol}. Only http and https are allowed`);
5497
+ }
5498
+ /**
5499
+ * Validate notebook path to prevent path traversal (throws on invalid)
5500
+ */
5501
+ function validateNotebookPath(path) {
5502
+ const result = validateNotebookPath$1(path);
5503
+ if (!result.valid) throw new JupyterClientError(result.error || "Invalid notebook path");
5504
+ }
5505
+ /**
5506
+ * Validate kernel name against whitelist (throws on invalid)
5507
+ */
5508
+ function validateKernelName(name) {
5509
+ const result = validateJupyterKernelName(name);
5510
+ if (!result.valid) throw new JupyterClientError(result.error || "Invalid kernel name");
5511
+ }
5512
+ var JupyterClient = class {
5513
+ constructor(config) {
5514
+ validateServerUrl(config.serverUrl);
5515
+ this.serverUrl = config.serverUrl.replace(/\/$/, "");
5516
+ this.token = config.token;
5556
5517
  }
5557
- getCommands() {
5558
- return [{
5559
- name: "tavern",
5560
- description: "Show recent Tavern agent activity stream",
5561
- execute: () => {
5562
- const activityLog = this.getActivityLog();
5563
- if (activityLog.length === 0) {
5564
- console.log("\nTavern Activity: No activity yet.");
5565
- console.log(" Agents broadcast activity during heartbeats.");
5566
- console.log(" Try: \"trigger a heartbeat cycle\" to generate activity.\n");
5567
- return;
5568
- }
5569
- const recentEntries = activityLog.slice(-20);
5570
- console.log(`\nTavern Activity (last ${recentEntries.length} of ${activityLog.length} entries):\n`);
5571
- for (const entry of recentEntries) {
5572
- const time = new Date(entry.timestamp).toLocaleTimeString();
5573
- const icon = ACTION_ICONS[entry.action] ?? "·";
5574
- const target = entry.targetAgentName ? ` \u2192 ${entry.targetAgentName}` : "";
5575
- const text = entry.text ? `: ${entry.text.slice(0, 120)}${entry.text.length > 120 ? "..." : ""}` : "";
5576
- console.log(` ${time} ${icon} ${entry.agentName}${target} [${entry.action}]${text}`);
5577
- }
5578
- console.log("");
5579
- }
5580
- }, {
5581
- name: "quest",
5582
- description: "Quest workflow commands: review gates, resume from handoff",
5583
- execute: async (args) => {
5584
- const subCommand = args[0];
5585
- if (subCommand === "resume") {
5586
- const planId = args[1];
5587
- if (!planId) {
5588
- console.log("\nUsage: /quest resume <plan_id>");
5589
- console.log(" Loads the quest plan and displays the session handoff for continuity.\n");
5590
- return;
5591
- }
5592
- try {
5593
- const plan = await this.service.getQuestPlan(planId);
5594
- console.log(`\nQuest Plan: ${plan.goal}`);
5595
- console.log(`State: ${plan.state ?? "unknown"}`);
5596
- if (plan.metrics) {
5597
- const pct = Math.round(plan.metrics.completionRate * 100);
5598
- console.log(`Progress: ${plan.metrics.subQuestsCompleted}/${plan.metrics.subQuestsTotal} sub-quests (${pct}%)`);
5599
- }
5600
- if (!plan.handoff) {
5601
- console.log("\n No handoff found — this plan has no saved session context.");
5602
- console.log(" The AI can still read the plan via tavern_get_quest_plan.\n");
5603
- return;
5604
- }
5605
- const { handoff } = plan;
5606
- console.log(`\nSession Handoff (${new Date(handoff.updatedAt).toLocaleString()}):`);
5607
- console.log(`\n Summary: ${handoff.summary}`);
5608
- if (handoff.nextSteps.length > 0) {
5609
- console.log("\n Next Steps:");
5610
- for (const step of handoff.nextSteps) console.log(` \u2022 ${step}`);
5611
- }
5612
- if (handoff.pendingDecisions.length > 0) {
5613
- console.log("\n Pending Decisions:");
5614
- for (const decision of handoff.pendingDecisions) console.log(` \u2753 ${decision}`);
5615
- }
5616
- if (handoff.blockers.length > 0) {
5617
- console.log("\n Blockers:");
5618
- for (const blocker of handoff.blockers) console.log(` \u{1F6D1} ${blocker}`);
5619
- }
5620
- console.log("");
5621
- } catch (error) {
5622
- const message = error instanceof Error ? error.message : String(error);
5623
- console.log(`\nError fetching quest plan: ${message}\n`);
5624
- }
5625
- return;
5626
- }
5627
- if (subCommand === "review") {
5628
- const planId = args[1];
5629
- if (!planId) {
5630
- console.log("\nUsage: /quest review <plan_id>");
5631
- console.log(" Fetches the quest plan and shows sub-quests with pending review gates.\n");
5632
- return;
5633
- }
5634
- try {
5635
- const plan = await this.service.getQuestPlan(planId);
5636
- const pendingGates = [];
5637
- for (const quest of plan.quests) for (const sq of quest.subQuests) if (sq.reviewGate) pendingGates.push({
5638
- questTitle: quest.title,
5639
- questId: quest.id,
5640
- subQuestTitle: sq.title,
5641
- subQuestId: sq.id,
5642
- status: sq.reviewStatus ?? "pending"
5643
- });
5644
- if (pendingGates.length === 0) {
5645
- console.log(`\nQuest Plan: ${plan.goal}`);
5646
- console.log(" No review gates configured in this plan.\n");
5647
- return;
5648
- }
5649
- console.log(`\nQuest Plan: ${plan.goal}`);
5650
- console.log(`State: ${plan.state ?? "unknown"}\n`);
5651
- console.log("Review Gates:");
5652
- for (const gate of pendingGates) {
5653
- const icon = gate.status === "approved" ? "✅" : gate.status === "rejected" ? "❌" : "⏸️";
5654
- console.log(` ${icon} [${gate.status}] ${gate.questTitle} > ${gate.subQuestTitle}`);
5655
- console.log(` quest_id: ${gate.questId} sub_quest_id: ${gate.subQuestId}`);
5656
- }
5657
- console.log("\n To approve: ask the AI to approve a review gate, or use tavern_update_review_gate tool.\n");
5658
- } catch (error) {
5659
- const message = error instanceof Error ? error.message : String(error);
5660
- console.log(`\nError fetching quest plan: ${message}\n`);
5661
- }
5662
- return;
5663
- }
5664
- console.log("\nUsage:");
5665
- console.log(" /quest review <plan_id> — Show review gates and their status");
5666
- console.log(" /quest resume <plan_id> — Load handoff and resume from where you left off");
5667
- console.log("");
5518
+ getHeaders() {
5519
+ const headers = { "Content-Type": "application/json" };
5520
+ if (this.token) headers["Authorization"] = `token ${this.token}`;
5521
+ return headers;
5522
+ }
5523
+ async request(method, path, body) {
5524
+ const url = `${this.serverUrl}${path}`;
5525
+ const options = {
5526
+ method,
5527
+ headers: this.getHeaders()
5528
+ };
5529
+ if (body) options.body = JSON.stringify(body);
5530
+ const response = await fetch(url, options);
5531
+ if (!response.ok) {
5532
+ let errorBody;
5533
+ try {
5534
+ errorBody = await response.json();
5535
+ } catch {
5536
+ errorBody = await response.text();
5668
5537
  }
5669
- }];
5538
+ throw new JupyterClientError(`Jupyter API error: ${response.status} ${response.statusText}`, response.status, errorBody);
5539
+ }
5540
+ if (response.status === 204) return {};
5541
+ return response.json();
5670
5542
  }
5671
- registerWsHandlers(wsManager) {
5672
- this.activityStream.registerHandlers(wsManager);
5543
+ /**
5544
+ * Check if the Jupyter server is running and accessible
5545
+ */
5546
+ async checkStatus() {
5547
+ return this.request("GET", "/api/status");
5548
+ }
5549
+ /**
5550
+ * Get available kernel specifications
5551
+ */
5552
+ async getKernelSpecs() {
5553
+ return this.request("GET", "/api/kernelspecs");
5673
5554
  }
5674
- dispose() {
5675
- this.activityStream.dispose();
5555
+ /**
5556
+ * List all active sessions
5557
+ */
5558
+ async listSessions() {
5559
+ return this.request("GET", "/api/sessions");
5676
5560
  }
5677
- };
5678
- //#endregion
5679
- //#region src/features/bridgePresence/BridgePresence.ts
5680
- const DEFAULT_PORT = Number(process.env.CC_BRIDGE_PORT ?? 48732);
5681
- const CONFIG_PATH = join(homedir(), ".b4m", "cc-bridge.json");
5682
- const ANNOUNCE_TIMEOUT_MS = 2e3;
5683
- async function readBridgeConfig() {
5684
- try {
5685
- const raw = await promises.readFile(CONFIG_PATH, "utf8");
5686
- const parsed = JSON.parse(raw);
5687
- if (typeof parsed.hookSecret !== "string" || !parsed.hookSecret) return null;
5688
- return {
5689
- port: typeof parsed.port === "number" ? parsed.port : DEFAULT_PORT,
5690
- hookSecret: parsed.hookSecret
5691
- };
5692
- } catch {
5693
- return null;
5561
+ /**
5562
+ * Start a new kernel session for a notebook
5563
+ */
5564
+ async startSession(notebookPath, kernelName) {
5565
+ validateNotebookPath(notebookPath);
5566
+ const kernel = kernelName || "python3";
5567
+ validateKernelName(kernel);
5568
+ return this.request("POST", "/api/sessions", {
5569
+ path: notebookPath,
5570
+ type: "notebook",
5571
+ name: notebookPath.split("/").pop() || "Untitled",
5572
+ kernel: { name: kernel }
5573
+ });
5694
5574
  }
5695
- }
5696
- var BridgePresence = class {
5697
- constructor() {
5698
- this.config = null;
5699
- this.instanceId = null;
5700
- this.ws = null;
5701
- this.callbacks = {};
5702
- this.started = false;
5703
- this.stopped = false;
5704
- this.reconnectTimer = null;
5705
- this.reconnectAttempts = 0;
5706
- this.announceRetryTimer = null;
5707
- this.announceAttempts = 0;
5708
- this.startOpts = null;
5709
- this.pendingWorkspaceName = null;
5710
- this.pendingCapabilities = null;
5711
- this.pendingSource = null;
5712
- this.emitQueue = Promise.resolve();
5575
+ /**
5576
+ * Get a session by ID
5577
+ */
5578
+ async getSession(sessionId) {
5579
+ return this.request("GET", `/api/sessions/${sessionId}`);
5713
5580
  }
5714
- setCallbacks(cbs) {
5715
- this.callbacks = cbs;
5581
+ /**
5582
+ * Stop a kernel session
5583
+ */
5584
+ async stopSession(sessionId) {
5585
+ await this.request("DELETE", `/api/sessions/${sessionId}`);
5716
5586
  }
5717
5587
  /**
5718
- * Probe the local bridge and, if present, announce this CLI session.
5719
- * Returns true iff the announce succeeded (tavern presence is active).
5720
- * Safe to call multiple times — second call no-ops.
5721
- *
5722
- * If announce fails (bridge absent or not yet up), a background retry
5723
- * loop keeps trying with bounded backoff so the sprite appears when the
5724
- * bridge comes online later in the CLI's lifetime.
5588
+ * Get WebSocket URL for kernel channels
5725
5589
  */
5726
- async start(opts) {
5727
- if (this.started) return this.instanceId !== null;
5728
- this.stopped = false;
5729
- this.started = true;
5730
- const config = await readBridgeConfig();
5731
- if (!config) {
5732
- logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
5733
- return false;
5734
- }
5735
- this.config = config;
5736
- this.startOpts = opts;
5737
- this.pendingWorkspaceName = opts.workspaceName ?? (basename(opts.workspacePath) || "workspace");
5738
- this.pendingCapabilities = opts.capabilities ?? ["interactive"];
5739
- this.pendingSource = opts.source ?? "b4m-cli";
5740
- return this.attemptAnnounce();
5590
+ getKernelWebSocketUrl(kernelId) {
5591
+ const httpUrl = new URL(this.serverUrl);
5592
+ const wsUrl = `${httpUrl.protocol === "https:" ? "wss:" : "ws:"}//${httpUrl.host}/api/kernels/${kernelId}/channels`;
5593
+ if (this.token) return `${wsUrl}?token=${this.token}`;
5594
+ return wsUrl;
5741
5595
  }
5742
- /** One announce attempt. Schedules a retry on failure; wires up the
5743
- * command WS + initial status on success. Idempotent: re-entering after
5744
- * a successful announce short-circuits at the instanceId guard. */
5745
- async attemptAnnounce() {
5746
- if (this.stopped || !this.config || !this.startOpts) return false;
5747
- if (this.instanceId) return true;
5748
- const instanceId = v4();
5749
- const workspaceName = this.pendingWorkspaceName;
5750
- const capabilities = this.pendingCapabilities;
5751
- const source = this.pendingSource;
5752
- if (!await this.announce({
5753
- instanceId,
5754
- source,
5755
- workspaceName,
5756
- workspacePath: this.startOpts.workspacePath,
5757
- capabilities
5758
- })) {
5759
- this.scheduleAnnounceRetry();
5760
- return false;
5761
- }
5762
- this.instanceId = instanceId;
5763
- this.announceAttempts = 0;
5764
- logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
5765
- this.connectCommandWs();
5766
- this.emitEvent({
5767
- type: "status",
5768
- status: "idle"
5769
- });
5770
- return true;
5596
+ /**
5597
+ * Generate a unique message ID for Jupyter protocol
5598
+ */
5599
+ generateMsgId() {
5600
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
5771
5601
  }
5772
- scheduleAnnounceRetry() {
5773
- if (this.stopped || this.announceRetryTimer) return;
5774
- this.announceAttempts += 1;
5775
- const delay = Math.min(1e3 * 2 ** (this.announceAttempts - 1), 3e4);
5776
- this.announceRetryTimer = setTimeout(() => {
5777
- this.announceRetryTimer = null;
5778
- this.attemptAnnounce();
5779
- }, delay);
5602
+ /**
5603
+ * Execute code in a kernel using the Jupyter WebSocket protocol.
5604
+ *
5605
+ * Connects to the kernel's channels WebSocket, sends an execute_request,
5606
+ * and collects outputs until the kernel returns to idle state.
5607
+ *
5608
+ * @param kernelId - The kernel ID to execute code in
5609
+ * @param code - The code to execute
5610
+ * @param timeoutMs - Execution timeout in milliseconds (default: 30000)
5611
+ */
5612
+ async executeCell(kernelId, code, timeoutMs = 3e4) {
5613
+ const wsUrl = this.getKernelWebSocketUrl(kernelId);
5614
+ const msgId = this.generateMsgId();
5615
+ return new Promise((resolve, reject) => {
5616
+ const outputs = [];
5617
+ let executionCount = null;
5618
+ let hasError = false;
5619
+ let errorInfo;
5620
+ const ws = new WsWebSocket(wsUrl);
5621
+ const timeoutHandle = setTimeout(() => {
5622
+ cleanup();
5623
+ reject(new JupyterClientError(`Cell execution timed out after ${timeoutMs}ms`));
5624
+ }, timeoutMs);
5625
+ const cleanup = () => {
5626
+ clearTimeout(timeoutHandle);
5627
+ if (ws.readyState === WsWebSocket.OPEN || ws.readyState === WsWebSocket.CONNECTING) ws.close();
5628
+ };
5629
+ ws.on("error", (err) => {
5630
+ cleanup();
5631
+ reject(new JupyterClientError(`WebSocket error: ${err.message}`));
5632
+ });
5633
+ ws.on("open", () => {
5634
+ const executeRequest = {
5635
+ header: {
5636
+ msg_id: msgId,
5637
+ msg_type: "execute_request",
5638
+ username: "b4m-cli",
5639
+ session: this.generateMsgId(),
5640
+ date: (/* @__PURE__ */ new Date()).toISOString(),
5641
+ version: "5.3"
5642
+ },
5643
+ parent_header: {},
5644
+ metadata: {},
5645
+ content: {
5646
+ code,
5647
+ silent: false,
5648
+ store_history: true,
5649
+ user_expressions: {},
5650
+ allow_stdin: false,
5651
+ stop_on_error: true
5652
+ },
5653
+ buffers: [],
5654
+ channel: "shell"
5655
+ };
5656
+ ws.send(JSON.stringify(executeRequest));
5657
+ });
5658
+ ws.on("message", (data) => {
5659
+ try {
5660
+ const msg = JSON.parse(data.toString());
5661
+ if (msg.parent_header?.msg_id !== msgId) return;
5662
+ switch (msg.header?.msg_type || msg.msg_type) {
5663
+ case "stream":
5664
+ outputs.push({
5665
+ output_type: "stream",
5666
+ name: msg.content.name,
5667
+ text: msg.content.text
5668
+ });
5669
+ break;
5670
+ case "execute_result":
5671
+ executionCount = msg.content.execution_count;
5672
+ outputs.push({
5673
+ output_type: "execute_result",
5674
+ data: msg.content.data,
5675
+ execution_count: msg.content.execution_count,
5676
+ metadata: msg.content.metadata
5677
+ });
5678
+ break;
5679
+ case "display_data":
5680
+ outputs.push({
5681
+ output_type: "display_data",
5682
+ data: msg.content.data,
5683
+ metadata: msg.content.metadata
5684
+ });
5685
+ break;
5686
+ case "error":
5687
+ hasError = true;
5688
+ errorInfo = {
5689
+ ename: msg.content.ename,
5690
+ evalue: msg.content.evalue,
5691
+ traceback: msg.content.traceback
5692
+ };
5693
+ outputs.push({
5694
+ output_type: "error",
5695
+ ename: msg.content.ename,
5696
+ evalue: msg.content.evalue,
5697
+ traceback: msg.content.traceback
5698
+ });
5699
+ break;
5700
+ case "execute_reply":
5701
+ if (msg.content.status === "ok" || msg.content.status === "error") {
5702
+ if (msg.content.execution_count !== void 0) executionCount = msg.content.execution_count;
5703
+ cleanup();
5704
+ resolve({
5705
+ success: !hasError,
5706
+ outputs,
5707
+ executionCount,
5708
+ error: errorInfo
5709
+ });
5710
+ }
5711
+ break;
5712
+ case "status": break;
5713
+ }
5714
+ } catch {}
5715
+ });
5716
+ ws.on("close", () => {
5717
+ clearTimeout(timeoutHandle);
5718
+ });
5719
+ });
5780
5720
  }
5781
- /** Emit an event for this session. No-op if the bridge isn't up. Events
5782
- * leave in strict order see `emitQueue` comment. */
5783
- async emitEvent(event) {
5784
- if (!this.config || !this.instanceId) return;
5785
- const task = () => this.post("/event", {
5786
- instanceId: this.instanceId,
5787
- event
5788
- }).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
5789
- this.emitQueue = this.emitQueue.then(task, task);
5790
- return this.emitQueue;
5721
+ /**
5722
+ * @deprecated Use executeCell instead. This method exists for backwards compatibility.
5723
+ */
5724
+ async executeCode(kernelId, code) {
5725
+ return this.executeCell(kernelId, code);
5791
5726
  }
5792
5727
  /**
5793
- * Tear down the tavern presence cleanly. Halts the announce-retry and
5794
- * command-WS reconnect loops, closes the socket, and best-effort signals
5795
- * disconnect to the bridge.
5796
- *
5797
- * After this resolves the instance is fully reset, so a later `start()`
5798
- * re-announces — the same singleton can be toggled off (Tavern feature
5799
- * disabled at runtime) and back on without restarting the CLI. The
5800
- * `stopped` latch is left true here purely so any straggler retry callback
5801
- * already queued short-circuits; `start()` clears it.
5728
+ * Interrupt a running kernel
5802
5729
  */
5803
- async stop(reason = "cli_exit") {
5804
- if (this.stopped || !this.started) return;
5805
- this.stopped = true;
5806
- if (this.reconnectTimer) {
5807
- clearTimeout(this.reconnectTimer);
5808
- this.reconnectTimer = null;
5809
- }
5810
- if (this.announceRetryTimer) {
5811
- clearTimeout(this.announceRetryTimer);
5812
- this.announceRetryTimer = null;
5813
- }
5814
- if (this.ws) {
5730
+ async interruptKernel(kernelId) {
5731
+ await this.request("POST", `/api/kernels/${kernelId}/interrupt`);
5732
+ }
5733
+ /**
5734
+ * Restart a kernel
5735
+ */
5736
+ async restartKernel(kernelId) {
5737
+ return this.request("POST", `/api/kernels/${kernelId}/restart`);
5738
+ }
5739
+ };
5740
+ /**
5741
+ * Create a JupyterClient from environment variables or config
5742
+ */
5743
+ function createJupyterClientFromEnv() {
5744
+ const serverUrl = process.env.JUPYTER_SERVER_URL;
5745
+ const token = process.env.JUPYTER_TOKEN;
5746
+ if (!serverUrl) return null;
5747
+ return new JupyterClient({
5748
+ serverUrl,
5749
+ token
5750
+ });
5751
+ }
5752
+ //#endregion
5753
+ //#region src/commands/executeNotebookHandler.ts
5754
+ /**
5755
+ * Jupyter Notebook Execution Handler
5756
+ *
5757
+ * Handles the jupyter_execute_notebook Keep command.
5758
+ * Orchestrates cell-by-cell execution of a notebook via local Jupyter server.
5759
+ */
5760
+ /**
5761
+ * Zod schema for validating execute notebook parameters.
5762
+ * Replaces unsafe `as` casts with proper runtime validation.
5763
+ */
5764
+ const ExecuteNotebookParams = z.object({
5765
+ notebookJson: z.string().min(1, "notebookJson is required"),
5766
+ sessionId: z.string().min(1, "sessionId is required"),
5767
+ kernelName: z.string().default("python3"),
5768
+ timeoutPerCell: z.number().default(3e4)
5769
+ });
5770
+ /**
5771
+ * Get cell source as string (handles both string and string[] formats).
5772
+ */
5773
+ function getCellSource(cell) {
5774
+ return Array.isArray(cell.source) ? cell.source.join("") : cell.source;
5775
+ }
5776
+ /**
5777
+ * Execute a Jupyter notebook cell by cell.
5778
+ *
5779
+ * @param params - Validated notebook execution parameters
5780
+ * @param deps - Dependencies (jupyter client, websocket manager, logger)
5781
+ * @returns Execution result with success status and executed notebook
5782
+ */
5783
+ async function executeNotebook(params, deps) {
5784
+ const { jupyterClient, wsManager, logger, requestId } = deps;
5785
+ const { notebookJson, sessionId, kernelName, timeoutPerCell } = ExecuteNotebookParams.parse(params);
5786
+ let notebook;
5787
+ try {
5788
+ notebook = JSON.parse(notebookJson);
5789
+ if (!notebook.cells || !Array.isArray(notebook.cells)) throw new Error("Invalid notebook: missing cells array");
5790
+ } catch (parseErr) {
5791
+ throw new Error(`Failed to parse notebook JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
5792
+ }
5793
+ const totalCodeCells = notebook.cells.filter((c) => c.cell_type === "code").length;
5794
+ logger.info(`[Keep] Starting notebook execution: ${totalCodeCells} code cells, kernel: ${kernelName}`);
5795
+ const tempNotebookPath = `/tmp/b4m-notebook-${randomUUID()}.ipynb`;
5796
+ await promises.writeFile(tempNotebookPath, notebookJson, "utf-8");
5797
+ let jupyterSession = null;
5798
+ let cellsExecuted = 0;
5799
+ let cellsFailed = 0;
5800
+ try {
5801
+ logger.info(`[Keep] Starting Jupyter kernel: ${kernelName}`);
5802
+ jupyterSession = await jupyterClient.startSession(tempNotebookPath, kernelName);
5803
+ const kernelId = jupyterSession.kernel.id;
5804
+ const jupyterSessionId = jupyterSession.id;
5805
+ logger.info(`[Keep] Kernel started: session=${jupyterSessionId}, kernel=${kernelId}`);
5806
+ let codeCellIndex = 0;
5807
+ for (let i = 0; i < notebook.cells.length; i++) {
5808
+ const cell = notebook.cells[i];
5809
+ if (cell.cell_type !== "code") continue;
5810
+ const cellCode = getCellSource(cell);
5811
+ if (!cellCode.trim()) {
5812
+ codeCellIndex++;
5813
+ continue;
5814
+ }
5815
+ logger.info(`[Keep] Executing cell ${codeCellIndex + 1}/${totalCodeCells}`);
5816
+ wsManager?.send({
5817
+ action: "jupyter_cell_output",
5818
+ requestId,
5819
+ sessionId,
5820
+ jupyterSessionId,
5821
+ cellIndex: codeCellIndex,
5822
+ outputType: "stream",
5823
+ content: {
5824
+ text: `Executing cell ${codeCellIndex + 1}/${totalCodeCells}...`,
5825
+ name: "stdout"
5826
+ },
5827
+ executionCount: null,
5828
+ isComplete: false
5829
+ });
5815
5830
  try {
5816
- this.ws.close();
5817
- } catch {}
5818
- this.ws = null;
5831
+ const cellResult = await jupyterClient.executeCell(kernelId, cellCode, timeoutPerCell);
5832
+ cell.outputs = cellResult.outputs;
5833
+ cell.execution_count = cellResult.executionCount;
5834
+ if (cellResult.success) {
5835
+ cellsExecuted++;
5836
+ wsManager?.send({
5837
+ action: "jupyter_cell_output",
5838
+ requestId,
5839
+ sessionId,
5840
+ jupyterSessionId,
5841
+ cellIndex: codeCellIndex,
5842
+ outputType: "execute_result",
5843
+ content: {
5844
+ text: cellResult.outputs.map((o) => {
5845
+ if (o.text) return Array.isArray(o.text) ? o.text.join("") : o.text;
5846
+ if (o.data && typeof o.data === "object" && "text/plain" in o.data) {
5847
+ const textPlain = o.data["text/plain"];
5848
+ return Array.isArray(textPlain) ? textPlain.join("") : String(textPlain);
5849
+ }
5850
+ return "";
5851
+ }).join(""),
5852
+ data: cellResult.outputs.find((o) => o.data)?.data
5853
+ },
5854
+ executionCount: cellResult.executionCount,
5855
+ isComplete: true
5856
+ });
5857
+ } else {
5858
+ cellsFailed++;
5859
+ wsManager?.send({
5860
+ action: "jupyter_cell_output",
5861
+ requestId,
5862
+ sessionId,
5863
+ jupyterSessionId,
5864
+ cellIndex: codeCellIndex,
5865
+ outputType: "error",
5866
+ content: {
5867
+ ename: cellResult.error?.ename || "ExecutionError",
5868
+ evalue: cellResult.error?.evalue || "Cell execution failed",
5869
+ traceback: cellResult.error?.traceback || []
5870
+ },
5871
+ executionCount: cellResult.executionCount,
5872
+ isComplete: true
5873
+ });
5874
+ logger.warn(`[Keep] Cell ${codeCellIndex + 1} failed: ${cellResult.error?.ename}: ${cellResult.error?.evalue}`);
5875
+ }
5876
+ } catch (cellErr) {
5877
+ cellsFailed++;
5878
+ const errMsg = cellErr instanceof Error ? cellErr.message : String(cellErr);
5879
+ wsManager?.send({
5880
+ action: "jupyter_cell_output",
5881
+ requestId,
5882
+ sessionId,
5883
+ jupyterSessionId,
5884
+ cellIndex: codeCellIndex,
5885
+ outputType: "error",
5886
+ content: {
5887
+ ename: "ExecutionError",
5888
+ evalue: errMsg,
5889
+ traceback: []
5890
+ },
5891
+ executionCount: null,
5892
+ isComplete: true
5893
+ });
5894
+ logger.error(`[Keep] Cell ${codeCellIndex + 1} threw error: ${errMsg}`);
5895
+ }
5896
+ codeCellIndex++;
5819
5897
  }
5820
- if (this.config && this.instanceId) await this.post("/disconnect", {
5821
- instanceId: this.instanceId,
5822
- reason
5823
- }).catch(() => {});
5824
- this.started = false;
5825
- this.instanceId = null;
5826
- this.config = null;
5827
- this.startOpts = null;
5828
- this.pendingWorkspaceName = null;
5829
- this.pendingCapabilities = null;
5830
- this.pendingSource = null;
5831
- this.announceAttempts = 0;
5832
- this.reconnectAttempts = 0;
5833
- this.emitQueue = Promise.resolve();
5898
+ return {
5899
+ success: cellsFailed === 0,
5900
+ cellsExecuted,
5901
+ cellsFailed,
5902
+ totalCodeCells,
5903
+ executedNotebook: JSON.stringify(notebook)
5904
+ };
5905
+ } finally {
5906
+ if (jupyterSession) try {
5907
+ logger.info(`[Keep] Stopping Jupyter session: ${jupyterSession.id}`);
5908
+ await jupyterClient.stopSession(jupyterSession.id);
5909
+ } catch (stopErr) {
5910
+ logger.warn(`[Keep] Failed to stop session: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`);
5911
+ }
5912
+ try {
5913
+ await promises.unlink(tempNotebookPath);
5914
+ } catch {}
5834
5915
  }
5835
- async announce(body) {
5916
+ }
5917
+ //#endregion
5918
+ //#region src/bootstrap/registerKeepHandlers.ts
5919
+ /**
5920
+ * Get a configured JupyterClient or throw an error if not configured.
5921
+ * Centralizes the Jupyter configuration check for all Jupyter Keep commands.
5922
+ */
5923
+ function getRequiredJupyterClient() {
5924
+ const client = createJupyterClientFromEnv();
5925
+ if (!client) throw new Error("Jupyter not configured. Set JUPYTER_SERVER_URL and optionally JUPYTER_TOKEN environment variables.");
5926
+ return client;
5927
+ }
5928
+ /**
5929
+ * Register the Keep command handler on a connected WebSocket manager — allows
5930
+ * the web HUD to execute commands on this machine via the B4M cloud relay.
5931
+ *
5932
+ * Pure bootstrap seam: no React hooks, no Zustand state. The connected
5933
+ * `wsManager` is passed in by the caller. Invoked from `buildLlmBackend`'s
5934
+ * `onWsConnected` callback at exactly the point the original inline registration
5935
+ * ran, so a registration throw still propagates into the WS try/catch and
5936
+ * triggers the SSE fallback.
5937
+ */
5938
+ function registerKeepHandlers(wsManager) {
5939
+ wsManager.onAction("keep_command", async (message) => {
5940
+ const { commandType, params, requestId, originConnectionId } = message;
5941
+ let result;
5942
+ let success = true;
5943
+ let error;
5836
5944
  try {
5837
- await this.post("/announce", body);
5838
- return true;
5945
+ switch (commandType) {
5946
+ case "read_file": {
5947
+ const filePath = params.path;
5948
+ if (!filePath) throw new Error("Missing required param: path");
5949
+ logger.info(`[Keep] Reading file: ${filePath}`);
5950
+ result = {
5951
+ content: await promises.readFile(filePath, "utf-8"),
5952
+ path: filePath
5953
+ };
5954
+ break;
5955
+ }
5956
+ case "list_directory": {
5957
+ const dirPath = params.path || ".";
5958
+ logger.info(`[Keep] Listing directory: ${dirPath}`);
5959
+ result = (await promises.readdir(dirPath, { withFileTypes: true })).map((e) => ({
5960
+ name: e.name,
5961
+ isDirectory: e.isDirectory()
5962
+ }));
5963
+ break;
5964
+ }
5965
+ case "jupyter_get_kernelspecs":
5966
+ logger.info("[Keep] Getting Jupyter kernel specs");
5967
+ result = await getRequiredJupyterClient().getKernelSpecs();
5968
+ break;
5969
+ case "jupyter_start_kernel": {
5970
+ const notebookPath = params.notebookPath;
5971
+ const kernelName = params.kernelName;
5972
+ logger.info(`[Keep] Starting Jupyter kernel for: ${notebookPath}`);
5973
+ result = await getRequiredJupyterClient().startSession(notebookPath, kernelName);
5974
+ break;
5975
+ }
5976
+ case "jupyter_stop_kernel": {
5977
+ const sessionId = params.sessionId;
5978
+ if (!sessionId) throw new Error("Missing required param: sessionId");
5979
+ logger.info(`[Keep] Stopping Jupyter session: ${sessionId}`);
5980
+ await getRequiredJupyterClient().stopSession(sessionId);
5981
+ result = {
5982
+ success: true,
5983
+ sessionId
5984
+ };
5985
+ break;
5986
+ }
5987
+ case "jupyter_execute_cell": {
5988
+ const code = params.code;
5989
+ const kernelId = params.kernelId;
5990
+ const timeoutMs = params.timeoutMs || 3e4;
5991
+ if (!code) throw new Error("Missing required param: code");
5992
+ if (!kernelId) throw new Error("Missing required param: kernelId");
5993
+ logger.info(`[Keep] Executing cell (kernel: ${kernelId}, code length: ${code.length})`);
5994
+ const cellResult = await getRequiredJupyterClient().executeCell(kernelId, code, timeoutMs);
5995
+ logger.info(`[Keep] Cell execution ${cellResult.success ? "succeeded" : "failed"} (outputs: ${cellResult.outputs.length}, execution_count: ${cellResult.executionCount})`);
5996
+ result = {
5997
+ success: cellResult.success,
5998
+ outputs: cellResult.outputs,
5999
+ executionCount: cellResult.executionCount,
6000
+ error: cellResult.error
6001
+ };
6002
+ break;
6003
+ }
6004
+ case "jupyter_execute_notebook":
6005
+ result = await executeNotebook(params, {
6006
+ jupyterClient: getRequiredJupyterClient(),
6007
+ wsManager,
6008
+ logger,
6009
+ requestId
6010
+ });
6011
+ break;
6012
+ default: throw new Error(`Unknown command type: ${commandType}`);
6013
+ }
5839
6014
  } catch (err) {
5840
- logger.info(`[tavern] bridge announce failed: ${err.message}`);
5841
- return false;
6015
+ success = false;
6016
+ error = err instanceof Error ? err.message : String(err);
5842
6017
  }
5843
- }
5844
- async post(path, body) {
5845
- if (!this.config) throw new Error("bridge config not loaded");
5846
- const url = `http://127.0.0.1:${this.config.port ?? DEFAULT_PORT}${path}?secret=${encodeURIComponent(this.config.hookSecret)}`;
5847
- const res = await fetch(url, {
5848
- method: "POST",
5849
- headers: { "Content-Type": "application/json" },
5850
- body: JSON.stringify(body),
5851
- signal: AbortSignal.timeout(ANNOUNCE_TIMEOUT_MS)
6018
+ logger.info(`[Keep] Sending response: success=${success}, originConnectionId=${originConnectionId?.slice(0, 12)}..., resultSize=${JSON.stringify(result)?.length ?? 0}`);
6019
+ try {
6020
+ wsManager.send({
6021
+ action: "keep_command_response",
6022
+ requestId,
6023
+ originConnectionId,
6024
+ success,
6025
+ result,
6026
+ error
6027
+ });
6028
+ logger.info("[Keep] Response sent successfully");
6029
+ } catch (sendErr) {
6030
+ logger.info(`[Keep] Failed to send response: ${sendErr instanceof Error ? sendErr.message : String(sendErr)}`);
6031
+ }
6032
+ });
6033
+ }
6034
+ //#endregion
6035
+ //#region src/bootstrap/buildLlmBackend.ts
6036
+ /** Production wiring: real transport classes + the ToolRouter singleton. */
6037
+ const defaultLlmBackendDeps = {
6038
+ connectWebSocket: async (wsUrl, tokenGetter) => {
6039
+ const ws = new WebSocketConnectionManager(wsUrl, tokenGetter);
6040
+ await ws.connect();
6041
+ return ws;
6042
+ },
6043
+ installWebSocketToolExecutor: (ws, tokenGetter) => {
6044
+ setWebSocketToolExecutor(new WebSocketToolExecutor(ws, tokenGetter));
6045
+ },
6046
+ clearWebSocketToolExecutor: () => setWebSocketToolExecutor(null),
6047
+ createWebSocketBackend: (opts) => new WebSocketLlmBackend(opts),
6048
+ createServerBackend: (opts) => new ServerLlmBackend(opts),
6049
+ registerKeepHandlers,
6050
+ createOllamaBackend: (host) => new OllamaBackend(host, {
6051
+ debug: (...args) => logger.debug(args.map(String).join(" ")),
6052
+ info: (...args) => logger.info(args.map(String).join(" ")),
6053
+ warn: (...args) => logger.warn(args.map(String).join(" ")),
6054
+ error: (...args) => logger.error(args.map(String).join(" "))
6055
+ }),
6056
+ createMultiBackend: (server, ollama, serverModels, ollamaModels, defaultModel) => new MultiLlmBackend(server, ollama, serverModels, ollamaModels, defaultModel)
6057
+ };
6058
+ /**
6059
+ * Resolve the model to use from the available list: the requested default if
6060
+ * present, otherwise the first available model. Pure — exported for testing.
6061
+ */
6062
+ function resolveModelInfo(models, defaultModel) {
6063
+ return models.find((m) => m.id === defaultModel) || models[0];
6064
+ }
6065
+ /**
6066
+ * Build the LLM backend: WebSocket transport first (bypasses CloudFront 20s
6067
+ * timeout), SSE fallback, optional Ollama multiplexing. Resolves the default
6068
+ * model and pins it on the backend.
6069
+ *
6070
+ * Pure bootstrap seam — no React hooks, no Zustand state. The WS path registers
6071
+ * the Keep command handler inline (inside the same try-block) so a registration
6072
+ * throw still triggers the SSE fallback, exactly as before. The tool-executor
6073
+ * install/clear ordering (set on connect, cleared on fallback) is preserved.
6074
+ */
6075
+ async function buildLlmBackend(input, deps = defaultLlmBackendDeps) {
6076
+ const { config, apiClient, tokenGetter, startupLog } = input;
6077
+ let wsManager = null;
6078
+ let llm;
6079
+ let completionsUrl;
6080
+ try {
6081
+ const serverConfig = await apiClient.get("/api/settings/serverConfig");
6082
+ const wsUrl = serverConfig?.websocketUrl;
6083
+ const wsCompletionUrl = serverConfig?.wsCompletionUrl;
6084
+ completionsUrl = serverConfig?.completionsUrl;
6085
+ if (wsUrl && wsCompletionUrl) {
6086
+ wsManager = await deps.connectWebSocket(wsUrl, tokenGetter);
6087
+ deps.installWebSocketToolExecutor(wsManager, tokenGetter);
6088
+ llm = deps.createWebSocketBackend({
6089
+ wsManager,
6090
+ apiClient,
6091
+ model: config.defaultModel,
6092
+ tokenGetter,
6093
+ wsCompletionUrl
6094
+ });
6095
+ deps.registerKeepHandlers(wsManager);
6096
+ logger.debug("🔌 Using WebSocket transport (bypasses CloudFront timeout)");
6097
+ } else throw new Error("No websocketUrl or wsCompletionUrl in server config");
6098
+ } catch (wsError) {
6099
+ logger.debug("⚠️ WebSocket unavailable, using SSE fallback");
6100
+ logger.debug(`[WebSocket] Fallback reason: ${wsError instanceof Error ? wsError.message : String(wsError)}`);
6101
+ if (wsError instanceof Error && wsError.stack) logger.debug(`[WebSocket] Stack: ${wsError.stack}`);
6102
+ wsManager = null;
6103
+ deps.clearWebSocketToolExecutor();
6104
+ llm = deps.createServerBackend({
6105
+ apiClient,
6106
+ model: config.defaultModel,
6107
+ completionsUrl
5852
6108
  });
5853
- if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
5854
6109
  }
5855
- connectCommandWs() {
5856
- if (this.stopped || !this.config || !this.instanceId) return;
5857
- const url = `ws://127.0.0.1:${this.config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(this.instanceId)}&secret=${encodeURIComponent(this.config.hookSecret)}`;
5858
- let ws;
6110
+ const ollamaHost = input.ollamaHost ?? process.env.B4M_OLLAMA_HOST;
6111
+ let models;
6112
+ if (ollamaHost) {
6113
+ const ollamaBackend = deps.createOllamaBackend(ollamaHost);
6114
+ const [serverModels, ollamaModels] = await Promise.all([llm.getModelInfo(), ollamaBackend.getModelInfo()]);
6115
+ 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`);
6116
+ if (ollamaModels.length === 0) startupLog.push(`⚠️ No models found in Ollama at ${ollamaHost}. Pull one with: ollama pull qwen3.5`);
6117
+ const serverBackend = llm;
6118
+ llm = deps.createMultiBackend(serverBackend, ollamaBackend, serverModels, ollamaModels, config.defaultModel);
6119
+ models = await llm.getModelInfo();
6120
+ startupLog.push(`🦙 Self-hosted Ollama: ${ollamaModels.length} model(s) added to picker`);
6121
+ } else {
6122
+ models = await llm.getModelInfo();
6123
+ if (models.length === 0) throw new Error("No models available from server.");
6124
+ }
6125
+ logger.debug(`📋 Available models: ${models.map((m) => m.id).join(", ")}`);
6126
+ const modelInfo = resolveModelInfo(models, config.defaultModel);
6127
+ if (modelInfo.id !== config.defaultModel) {
6128
+ logger.warn(`⚠️ Requested model '${config.defaultModel}' not available`);
6129
+ logger.warn(`🤖 Using fallback model: ${modelInfo.id}`);
6130
+ }
6131
+ llm.currentModel = modelInfo.id;
6132
+ return {
6133
+ llm,
6134
+ wsManager,
6135
+ models,
6136
+ modelInfo
6137
+ };
6138
+ }
6139
+ //#endregion
6140
+ //#region src/bootstrap/buildSandbox.ts
6141
+ /**
6142
+ * Initialize the sandbox orchestrator for OS-level filesystem isolation, wire
6143
+ * the network-proxy event handler, attach the violation store, and start the
6144
+ * proxy when enabled.
6145
+ *
6146
+ * Pure bootstrap seam — no React hooks, no Zustand state. Sandbox modules are
6147
+ * imported dynamically (as before) so the cost is only paid when init runs.
6148
+ */
6149
+ async function buildSandbox(input) {
6150
+ const { config, sessionId, permissionManager, checkpointStore } = input;
6151
+ const [{ createSandboxRuntime }, { SandboxOrchestrator }, { DEFAULT_SANDBOX_CONFIG }, { ProxyManager }, { ViolationLogStore }] = await Promise.all([
6152
+ import("./SandboxRuntimeAdapter-CKelGICD.mjs"),
6153
+ import("./SandboxOrchestrator-BS6gALNq.mjs"),
6154
+ import("./types-CqscS34o.mjs"),
6155
+ import("./ProxyManager-ByuAHFMq.mjs"),
6156
+ import("./ViolationLogStore-B-plqJfn.mjs")
6157
+ ]);
6158
+ const sandboxConfig = config.sandbox ?? DEFAULT_SANDBOX_CONFIG;
6159
+ const [sandboxRuntime] = await Promise.all([createSandboxRuntime(), checkpointStore.init(sessionId).catch(() => {})]);
6160
+ const proxyManager = new ProxyManager(sandboxConfig.network);
6161
+ const sandboxOrchestrator = new SandboxOrchestrator(sandboxConfig, sandboxRuntime, proxyManager);
6162
+ proxyManager.onEvent((event) => {
6163
+ if (event.type === "blocked") {
6164
+ 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`);
6165
+ console.error(`\x1b[90m Tip: /sandbox:trust-domain ${event.domain}\x1b[0m\n`);
6166
+ sandboxOrchestrator.recordViolation({
6167
+ type: "network",
6168
+ domain: event.domain,
6169
+ command: `[network] ${event.method} ${event.domain}`,
6170
+ blockedBy: "proxy",
6171
+ timestamp: event.timestamp,
6172
+ detail: `Blocked ${event.method} to ${event.domain}`
6173
+ }).catch(() => {});
6174
+ }
6175
+ });
6176
+ const violationStore = new ViolationLogStore();
6177
+ sandboxOrchestrator.setViolationStore(violationStore);
6178
+ permissionManager.setSandboxState(sandboxConfig.mode, sandboxOrchestrator.isActive());
6179
+ if (sandboxConfig.enabled && sandboxConfig.mode !== "disabled") {
6180
+ if (sandboxRuntime) console.log(`🔒 Sandbox: ${sandboxConfig.mode} (${sandboxRuntime.name})`);
6181
+ else console.log("⚠️ Sandbox: enabled but runtime not available on this platform");
6182
+ if (sandboxConfig.network.enabled) {
6183
+ await proxyManager.start();
6184
+ if (proxyManager.isRunning()) console.log(`🌐 Network proxy: filtering on port ${proxyManager.getPort()} (${sandboxConfig.network.allowedDomains.length} domains)`);
6185
+ }
6186
+ }
6187
+ return { sandboxOrchestrator };
6188
+ }
6189
+ //#endregion
6190
+ //#region src/bootstrap/buildSupportingStores.ts
6191
+ /**
6192
+ * Build the supporting stores and orchestration the agent needs: CLI tools
6193
+ * (permission-wrapped + server-routed), MCP manager, agent store, context
6194
+ * files, the deferred-tool registry partition, the subagent orchestrator, and
6195
+ * the background-agent manager.
6196
+ *
6197
+ * Pure bootstrap seam — no React hooks, no Zustand state. React-owned values
6198
+ * (permission/user-question prompt functions, the agent context, and the
6199
+ * background-agent status callbacks) are passed in. Tool *assembly* that weaves
6200
+ * the React workflow-store refs (decision/blocker/review-gate tools) stays in
6201
+ * the shell; this module returns only the agent-construction materials.
6202
+ */
6203
+ async function buildSupportingStores(input) {
6204
+ const { config, llm, modelId, permissionManager, apiClient, configStore, customCommandStore, checkpointStore, sandboxOrchestrator, additionalDirectories, agentContext, promptFn, userQuestionFn, startupLog, silentLogger, onBackgroundStatusChange, onGroupCompletion } = input;
6205
+ const { tools: b4mTools } = await generateCliTools(config.userId, llm, modelId, permissionManager, promptFn, agentContext, configStore, apiClient, void 0, userQuestionFn, checkpointStore, sandboxOrchestrator, additionalDirectories);
6206
+ const mcpManager = new McpManager(config);
6207
+ const builtinAgentsDir = new URL("../agents/defaults/", import.meta.url).pathname;
6208
+ const agentProjectDir = configStore.getProjectConfigDir();
6209
+ const agentStore = new AgentStore(builtinAgentsDir, agentProjectDir || process.cwd());
6210
+ const [, , contextResult] = await Promise.all([
6211
+ mcpManager.initialize(),
6212
+ agentStore.loadAgents(),
6213
+ loadContextFiles(agentProjectDir)
6214
+ ]);
6215
+ const mcpTools = mcpManager.getTools();
6216
+ const deferredB4mToolNames = new Set([
6217
+ "math_evaluate",
6218
+ "dice_roll",
6219
+ "current_datetime",
6220
+ "recent_changes",
6221
+ "prompt_enhancement"
6222
+ ]);
6223
+ const deferredB4mTools = b4mTools.filter((t) => deferredB4mToolNames.has(t.toolSchema.name));
6224
+ const loadedB4mTools = b4mTools.filter((t) => !deferredB4mToolNames.has(t.toolSchema.name));
6225
+ deferredToolRegistry.register([...mcpTools, ...deferredB4mTools]);
6226
+ if (mcpTools.length > 0) {
6227
+ const serverSummaries = mcpManager.getToolCount().map((s) => `${s.serverName} (${s.count})`).join(", ");
6228
+ startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M + ${mcpTools.length} MCP tool(s, ${deferredB4mTools.length + mcpTools.length} deferred): ${serverSummaries}`);
6229
+ } else {
6230
+ const suffix = deferredB4mTools.length > 0 ? ` (${deferredB4mTools.length} deferred)` : "";
6231
+ startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M tool(s)${suffix}, no MCP tools`);
6232
+ }
6233
+ const agentSummary = agentStore.getSummary();
6234
+ startupLog.push(`🤖 Loaded ${agentSummary.total} agent(s): ${agentSummary.builtin} built-in, ${agentSummary.global} global, ${agentSummary.project} project`);
6235
+ const orchestrator = new SubagentOrchestrator({
6236
+ userId: config.userId,
6237
+ llm,
6238
+ logger: silentLogger,
6239
+ permissionManager,
6240
+ showPermissionPrompt: promptFn,
6241
+ configStore,
6242
+ apiClient,
6243
+ agentStore,
6244
+ customCommandStore,
6245
+ enableParallelToolExecution: config.preferences.enableParallelToolExecution === true,
6246
+ showUserQuestion: userQuestionFn,
6247
+ checkpointStore
6248
+ });
6249
+ const backgroundManager = new BackgroundAgentManager(orchestrator);
6250
+ backgroundManager.setOnStatusChange(onBackgroundStatusChange);
6251
+ backgroundManager.setOnGroupCompletion(onGroupCompletion);
6252
+ return {
6253
+ mcpManager,
6254
+ agentStore,
6255
+ contextResult,
6256
+ mcpTools,
6257
+ loadedB4mTools,
6258
+ deferredB4mTools,
6259
+ orchestrator,
6260
+ backgroundManager
6261
+ };
6262
+ }
6263
+ //#endregion
6264
+ //#region src/bootstrap/buildAgent.ts
6265
+ /**
6266
+ * Construct the main ReAct agent with the system prompt selected by config
6267
+ * variant, wire the tool_search closure to the agent's live tools array, and
6268
+ * record the agent in the shared observation context.
6269
+ *
6270
+ * Pure bootstrap seam — no React hooks, no Zustand state. The interaction-mode
6271
+ * subscription (`useCliStore.subscribe`) stays in the shell and uses the
6272
+ * returned `buildPromptForMode`. Ordering is load-bearing: agent built →
6273
+ * agentToolsRef wired → agentContext.currentAgent set, all here, before the
6274
+ * shell registers the subscription (which guards on currentAgent === agent).
6275
+ */
6276
+ function buildAgent(input) {
6277
+ const { config, modelId, notifyingLlm, allTools, agentContext, agentToolsRef, silentLogger, sessionId, initialInteractionMode, contextContent, agentStore, customCommandStore, enableSkillTool, additionalDirectories, featureModulePrompts } = input;
6278
+ const promptVariant = config.preferences.promptVariant ?? "current";
6279
+ const buildPromptForMode = (mode) => buildSystemPrompt(promptVariant, {
6280
+ contextContent,
6281
+ agentStore,
6282
+ customCommands: customCommandStore.getAllCommands(),
6283
+ enableSkillTool,
6284
+ enableDynamicAgentCreation: config.preferences.enableDynamicAgentCreation === true,
6285
+ additionalDirectories,
6286
+ featureModulePrompts: featureModulePrompts || void 0,
6287
+ planModeFilePath: mode === "plan" ? getPlanModeFilePath(sessionId) : void 0,
6288
+ appendSystemPrompt: process.env.B4M_APPEND_SYSTEM_PROMPT,
6289
+ deferredToolNames: deferredToolRegistry.getDirectoryNames()
6290
+ });
6291
+ const cliSystemPrompt = buildPromptForMode(initialInteractionMode);
6292
+ const maxIterations = config.preferences.maxIterations === null ? 999999 : config.preferences.maxIterations;
6293
+ const agent = new ReActAgent({
6294
+ userId: config.userId,
6295
+ logger: silentLogger,
6296
+ llm: notifyingLlm,
6297
+ model: modelId,
6298
+ tools: allTools,
6299
+ maxIterations,
6300
+ maxTokens: config.preferences.maxTokens,
6301
+ temperature: config.preferences.temperature,
6302
+ systemPrompt: cliSystemPrompt,
6303
+ unknownToolResolver: async (toolName) => deferredToolRegistry.get(toolName) ?? null
6304
+ });
6305
+ agentToolsRef.current = agent.getTools();
6306
+ agentContext.currentAgent = agent;
6307
+ return {
6308
+ agent,
6309
+ buildPromptForMode
6310
+ };
6311
+ }
6312
+ //#endregion
6313
+ //#region src/bootstrap/wireAgentEvents.ts
6314
+ /**
6315
+ * Wire the main agent's step events to the UI store and the tavern transcript,
6316
+ * and mirror the same handlers onto delegated subagents via the orchestrator's
6317
+ * before/after-run callbacks.
6318
+ *
6319
+ * Bootstrap seam: no React hooks, no useRef, no subscriptions. It reads/writes
6320
+ * the Zustand store via `getState()` (the same runtime-access pattern the agent
6321
+ * step handlers already used) and emits tavern events through the bridge
6322
+ * singleton — neither owns React-lifecycle state.
6323
+ */
6324
+ function wireAgentEvents(input) {
6325
+ const { agent, agentContext, orchestrator } = input;
6326
+ agent.observationQueue = agentContext.observationQueue;
6327
+ const stepHandler = (step) => {
6328
+ const { pendingMessages, updatePendingMessage } = useCliStore.getState();
6329
+ const lastIdx = pendingMessages.length - 1;
6330
+ if (lastIdx >= 0 && pendingMessages[lastIdx].role === "assistant") {
6331
+ const existingSteps = pendingMessages[lastIdx].metadata?.steps || [];
6332
+ const formattedStep = formatStep(step);
6333
+ updatePendingMessage(lastIdx, {
6334
+ ...pendingMessages[lastIdx],
6335
+ metadata: {
6336
+ ...pendingMessages[lastIdx].metadata,
6337
+ steps: [...existingSteps, formattedStep]
6338
+ }
6339
+ });
6340
+ }
6341
+ };
6342
+ agent.on("thought", stepHandler);
6343
+ agent.on("observation", stepHandler);
6344
+ agent.on("action", stepHandler);
6345
+ const pendingToolUseIds = /* @__PURE__ */ new Map();
6346
+ const summarizeToolInput = (input) => {
6347
+ if (input == null) return void 0;
6348
+ if (typeof input === "string") return input.slice(0, 240);
5859
6349
  try {
5860
- ws = new WsWebSocket(url);
5861
- } catch (err) {
5862
- logger.debug(`[tavern] command WS construct failed: ${err.message}`);
5863
- this.scheduleReconnect();
6350
+ return JSON.stringify(input).slice(0, 240);
6351
+ } catch {
5864
6352
  return;
5865
6353
  }
5866
- this.ws = ws;
5867
- ws.on("open", () => {
5868
- this.reconnectAttempts = 0;
5869
- logger.debug("[tavern] command WS open");
6354
+ };
6355
+ const tavernActionHandler = (step) => {
6356
+ const toolName = step.metadata?.toolName ?? "tool";
6357
+ const toolUseId = `${toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6358
+ const queue = pendingToolUseIds.get(toolName) ?? [];
6359
+ queue.push(toolUseId);
6360
+ pendingToolUseIds.set(toolName, queue);
6361
+ bridgePresence.emitEvent({
6362
+ type: "tool_use",
6363
+ tool: toolName,
6364
+ toolUseId,
6365
+ text: summarizeToolInput(step.metadata?.toolInput)
5870
6366
  });
5871
- ws.on("message", (raw) => {
5872
- let frame = null;
5873
- try {
5874
- frame = JSON.parse(raw.toString());
5875
- } catch {
5876
- logger.debug("[tavern] malformed command frame; ignored");
5877
- return;
6367
+ };
6368
+ const tavernObservationHandler = (step) => {
6369
+ const toolName = step.metadata?.toolName;
6370
+ let toolUseId;
6371
+ if (toolName) {
6372
+ const queue = pendingToolUseIds.get(toolName);
6373
+ if (queue && queue.length > 0) {
6374
+ toolUseId = queue.shift();
6375
+ if (queue.length === 0) pendingToolUseIds.delete(toolName);
6376
+ }
6377
+ }
6378
+ if (!toolUseId) {
6379
+ for (const [name, queue] of pendingToolUseIds.entries()) if (queue.length > 0) {
6380
+ toolUseId = queue.shift();
6381
+ if (queue.length === 0) pendingToolUseIds.delete(name);
6382
+ break;
5878
6383
  }
5879
- if (!frame?.command) return;
5880
- this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
5881
- });
5882
- ws.on("close", () => {
5883
- this.ws = null;
5884
- if (this.stopped) return;
5885
- logger.debug("[tavern] command WS closed; reconnecting");
5886
- this.scheduleReconnect();
6384
+ }
6385
+ if (!toolUseId) toolUseId = `orphan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6386
+ bridgePresence.emitEvent({
6387
+ type: "tool_result",
6388
+ tool: toolName,
6389
+ toolUseId,
6390
+ text: typeof step.content === "string" ? step.content.slice(0, 4e3) : void 0
5887
6391
  });
5888
- ws.on("error", (err) => {
5889
- logger.debug(`[tavern] command WS error: ${err.message}`);
6392
+ };
6393
+ const tavernFinalAnswerHandler = (step) => {
6394
+ const text = typeof step.content === "string" ? step.content : "";
6395
+ if (!text) return;
6396
+ bridgePresence.emitEvent({
6397
+ type: "message",
6398
+ role: "assistant",
6399
+ text: text.slice(0, 4e3)
5890
6400
  });
5891
- }
5892
- scheduleReconnect() {
5893
- if (this.stopped || this.reconnectTimer) return;
5894
- this.reconnectAttempts += 1;
5895
- const delay = Math.min(500 * 2 ** (this.reconnectAttempts - 1), 1e4);
5896
- this.reconnectTimer = setTimeout(() => {
5897
- this.reconnectTimer = null;
5898
- this.connectCommandWs();
5899
- }, delay);
5900
- }
5901
- async dispatchCommand(command) {
5902
- switch (command.type) {
5903
- case "send_prompt":
5904
- if (this.callbacks.onSendPrompt) await this.callbacks.onSendPrompt(command.text);
5905
- break;
5906
- case "resolve_permission":
5907
- if (this.callbacks.onResolvePermission) await this.callbacks.onResolvePermission(command.requestId, command.allow);
5908
- break;
5909
- case "abort":
5910
- if (this.callbacks.onAbort) await this.callbacks.onAbort();
5911
- break;
5912
- }
5913
- }
5914
- };
5915
- /** Process-wide singleton — the CLI only ever has one tavern presence per run. */
5916
- const bridgePresence = new BridgePresence();
6401
+ };
6402
+ agent.on("action", tavernActionHandler);
6403
+ agent.on("observation", tavernObservationHandler);
6404
+ agent.on("final_answer", tavernFinalAnswerHandler);
6405
+ orchestrator.setBeforeRunCallback((subagent, _subagentType) => {
6406
+ subagent.on("thought", stepHandler);
6407
+ subagent.on("observation", stepHandler);
6408
+ subagent.on("action", stepHandler);
6409
+ subagent.on("action", tavernActionHandler);
6410
+ subagent.on("observation", tavernObservationHandler);
6411
+ subagent.on("final_answer", tavernFinalAnswerHandler);
6412
+ });
6413
+ orchestrator.setAfterRunCallback((subagent, _subagentType) => {
6414
+ subagent.off("thought", stepHandler);
6415
+ subagent.off("observation", stepHandler);
6416
+ subagent.off("action", stepHandler);
6417
+ subagent.off("action", tavernActionHandler);
6418
+ subagent.off("observation", tavernObservationHandler);
6419
+ subagent.off("final_answer", tavernFinalAnswerHandler);
6420
+ });
6421
+ }
5917
6422
  //#endregion
5918
6423
  //#region src/index.tsx
5919
6424
  process.removeAllListeners("warning");
@@ -5922,15 +6427,6 @@ process.on("warning", (warning) => {
5922
6427
  console.warn(warning);
5923
6428
  });
5924
6429
  /**
5925
- * Get a configured JupyterClient or throw an error if not configured.
5926
- * Centralizes the Jupyter configuration check for all Jupyter Keep commands.
5927
- */
5928
- function getRequiredJupyterClient() {
5929
- const client = createJupyterClientFromEnv();
5930
- if (!client) throw new Error("Jupyter not configured. Set JUPYTER_SERVER_URL and optionally JUPYTER_TOKEN environment variables.");
5931
- return client;
5932
- }
5933
- /**
5934
6430
  * Render the first question from a UserQuestion payload as a 240-char-capped
5935
6431
  * summary for tavern status events. Returns undefined if the payload has no
5936
6432
  * questions (defensive: payload.questions[0]?.question is typed string but
@@ -6171,162 +6667,23 @@ function CliApp() {
6171
6667
  const envName = getEnvironmentName(config.apiConfig);
6172
6668
  startupLog.unshift(`🌍 API Environment: ${envName} (${apiBaseURL})`);
6173
6669
  const apiClient = new ApiClient(apiBaseURL, state.configStore);
6670
+ if (process.env.B4M_NO_REMOTE_SKILLS !== "1" && config.preferences.enableRemoteSkills !== false) try {
6671
+ state.customCommandStore.setRemoteSource(new RemoteSkillSource(apiClient));
6672
+ await state.customCommandStore.mergeRemoteCommands();
6673
+ const remoteCount = state.customCommandStore.getCommandsBySource("remote").length;
6674
+ if (remoteCount > 0) startupLog.push(`☁️ Synced ${remoteCount} skill${remoteCount === 1 ? "" : "s"} from B4M web`);
6675
+ } catch (error) {
6676
+ console.warn("Failed to sync remote skills:", error instanceof Error ? error.message : String(error));
6677
+ }
6174
6678
  const tokenGetter = async () => {
6175
6679
  return (await state.configStore.getAuthTokens())?.accessToken ?? null;
6176
6680
  };
6177
- let wsManager = null;
6178
- let llm;
6179
- let completionsUrl;
6180
- try {
6181
- const serverConfig = await apiClient.get("/api/settings/serverConfig");
6182
- const wsUrl = serverConfig?.websocketUrl;
6183
- const wsCompletionUrl = serverConfig?.wsCompletionUrl;
6184
- completionsUrl = serverConfig?.completionsUrl;
6185
- if (wsUrl && wsCompletionUrl) {
6186
- wsManager = new WebSocketConnectionManager(wsUrl, tokenGetter);
6187
- await wsManager.connect();
6188
- setWebSocketToolExecutor(new WebSocketToolExecutor(wsManager, tokenGetter));
6189
- llm = new WebSocketLlmBackend({
6190
- wsManager,
6191
- apiClient,
6192
- model: config.defaultModel,
6193
- tokenGetter,
6194
- wsCompletionUrl
6195
- });
6196
- wsManager.onAction("keep_command", async (message) => {
6197
- const { commandType, params, requestId, originConnectionId } = message;
6198
- let result;
6199
- let success = true;
6200
- let error;
6201
- try {
6202
- switch (commandType) {
6203
- case "read_file": {
6204
- const filePath = params.path;
6205
- if (!filePath) throw new Error("Missing required param: path");
6206
- logger.info(`[Keep] Reading file: ${filePath}`);
6207
- result = {
6208
- content: await promises.readFile(filePath, "utf-8"),
6209
- path: filePath
6210
- };
6211
- break;
6212
- }
6213
- case "list_directory": {
6214
- const dirPath = params.path || ".";
6215
- logger.info(`[Keep] Listing directory: ${dirPath}`);
6216
- result = (await promises.readdir(dirPath, { withFileTypes: true })).map((e) => ({
6217
- name: e.name,
6218
- isDirectory: e.isDirectory()
6219
- }));
6220
- break;
6221
- }
6222
- case "jupyter_get_kernelspecs":
6223
- logger.info("[Keep] Getting Jupyter kernel specs");
6224
- result = await getRequiredJupyterClient().getKernelSpecs();
6225
- break;
6226
- case "jupyter_start_kernel": {
6227
- const notebookPath = params.notebookPath;
6228
- const kernelName = params.kernelName;
6229
- logger.info(`[Keep] Starting Jupyter kernel for: ${notebookPath}`);
6230
- result = await getRequiredJupyterClient().startSession(notebookPath, kernelName);
6231
- break;
6232
- }
6233
- case "jupyter_stop_kernel": {
6234
- const sessionId = params.sessionId;
6235
- if (!sessionId) throw new Error("Missing required param: sessionId");
6236
- logger.info(`[Keep] Stopping Jupyter session: ${sessionId}`);
6237
- await getRequiredJupyterClient().stopSession(sessionId);
6238
- result = {
6239
- success: true,
6240
- sessionId
6241
- };
6242
- break;
6243
- }
6244
- case "jupyter_execute_cell": {
6245
- const code = params.code;
6246
- const kernelId = params.kernelId;
6247
- const timeoutMs = params.timeoutMs || 3e4;
6248
- if (!code) throw new Error("Missing required param: code");
6249
- if (!kernelId) throw new Error("Missing required param: kernelId");
6250
- logger.info(`[Keep] Executing cell (kernel: ${kernelId}, code length: ${code.length})`);
6251
- const cellResult = await getRequiredJupyterClient().executeCell(kernelId, code, timeoutMs);
6252
- logger.info(`[Keep] Cell execution ${cellResult.success ? "succeeded" : "failed"} (outputs: ${cellResult.outputs.length}, execution_count: ${cellResult.executionCount})`);
6253
- result = {
6254
- success: cellResult.success,
6255
- outputs: cellResult.outputs,
6256
- executionCount: cellResult.executionCount,
6257
- error: cellResult.error
6258
- };
6259
- break;
6260
- }
6261
- case "jupyter_execute_notebook":
6262
- result = await executeNotebook(params, {
6263
- jupyterClient: getRequiredJupyterClient(),
6264
- wsManager,
6265
- logger,
6266
- requestId
6267
- });
6268
- break;
6269
- default: throw new Error(`Unknown command type: ${commandType}`);
6270
- }
6271
- } catch (err) {
6272
- success = false;
6273
- error = err instanceof Error ? err.message : String(err);
6274
- }
6275
- logger.info(`[Keep] Sending response: success=${success}, originConnectionId=${originConnectionId?.slice(0, 12)}..., resultSize=${JSON.stringify(result)?.length ?? 0}`);
6276
- try {
6277
- wsManager.send({
6278
- action: "keep_command_response",
6279
- requestId,
6280
- originConnectionId,
6281
- success,
6282
- result,
6283
- error
6284
- });
6285
- logger.info("[Keep] Response sent successfully");
6286
- } catch (sendErr) {
6287
- logger.info(`[Keep] Failed to send response: ${sendErr instanceof Error ? sendErr.message : String(sendErr)}`);
6288
- }
6289
- });
6290
- logger.debug("🔌 Using WebSocket transport (bypasses CloudFront timeout)");
6291
- } else throw new Error("No websocketUrl or wsCompletionUrl in server config");
6292
- } catch (wsError) {
6293
- logger.debug("⚠️ WebSocket unavailable, using SSE fallback");
6294
- logger.debug(`[WebSocket] Fallback reason: ${wsError instanceof Error ? wsError.message : String(wsError)}`);
6295
- if (wsError instanceof Error && wsError.stack) logger.debug(`[WebSocket] Stack: ${wsError.stack}`);
6296
- wsManager = null;
6297
- setWebSocketToolExecutor(null);
6298
- llm = new ServerLlmBackend({
6299
- apiClient,
6300
- model: config.defaultModel,
6301
- completionsUrl
6302
- });
6303
- }
6304
- const ollamaHost = process.env.B4M_OLLAMA_HOST;
6305
- let models;
6306
- if (ollamaHost) {
6307
- const ollamaBackend = new OllamaBackend(ollamaHost, {
6308
- debug: (...args) => logger.debug(args.map(String).join(" ")),
6309
- info: (...args) => logger.info(args.map(String).join(" ")),
6310
- warn: (...args) => logger.warn(args.map(String).join(" ")),
6311
- error: (...args) => logger.error(args.map(String).join(" "))
6312
- });
6313
- const [serverModels, ollamaModels] = await Promise.all([llm.getModelInfo(), ollamaBackend.getModelInfo()]);
6314
- 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`);
6315
- if (ollamaModels.length === 0) startupLog.push(`⚠️ No models found in Ollama at ${ollamaHost}. Pull one with: ollama pull qwen3.5`);
6316
- llm = new MultiLlmBackend(llm, ollamaBackend, serverModels, ollamaModels, config.defaultModel);
6317
- models = await llm.getModelInfo();
6318
- startupLog.push(`🦙 Self-hosted Ollama: ${ollamaModels.length} model(s) added to picker`);
6319
- } else {
6320
- models = await llm.getModelInfo();
6321
- if (models.length === 0) throw new Error("No models available from server.");
6322
- }
6323
- logger.debug(`📋 Available models: ${models.map((m) => m.id).join(", ")}`);
6324
- const modelInfo = models.find((m) => m.id === config.defaultModel) || models[0];
6325
- if (modelInfo.id !== config.defaultModel) {
6326
- logger.warn(`⚠️ Requested model '${config.defaultModel}' not available`);
6327
- logger.warn(`🤖 Using fallback model: ${modelInfo.id}`);
6328
- }
6329
- llm.currentModel = modelInfo.id;
6681
+ const { llm, wsManager, models, modelInfo } = await buildLlmBackend({
6682
+ config,
6683
+ apiClient,
6684
+ tokenGetter,
6685
+ startupLog
6686
+ });
6330
6687
  const pinnedSessionId = process.env.B4M_SESSION_ID;
6331
6688
  const resumeSessionId = process.env.B4M_RESUME_ID;
6332
6689
  let newSession;
@@ -6362,43 +6719,13 @@ function CliApp() {
6362
6719
  debug: () => {}
6363
6720
  };
6364
6721
  const permissionManager = new PermissionManager(config.trustedTools || [], void 0, config.tools.disabled || []);
6365
- const [{ createSandboxRuntime }, { SandboxOrchestrator }, { DEFAULT_SANDBOX_CONFIG }, { ProxyManager }, { ViolationLogStore }] = await Promise.all([
6366
- import("./SandboxRuntimeAdapter-CKelGICD.mjs"),
6367
- import("./SandboxOrchestrator-BS6gALNq.mjs"),
6368
- import("./types-CqscS34o.mjs"),
6369
- import("./ProxyManager-ByuAHFMq.mjs"),
6370
- import("./ViolationLogStore-B-plqJfn.mjs")
6371
- ]);
6372
- const sandboxConfig = config.sandbox ?? DEFAULT_SANDBOX_CONFIG;
6373
6722
  const checkpointStore = new CheckpointStore(state.configStore.getProjectConfigDir() || process.cwd());
6374
- const [sandboxRuntime] = await Promise.all([createSandboxRuntime(), checkpointStore.init(newSession.id).catch(() => {})]);
6375
- const proxyManager = new ProxyManager(sandboxConfig.network);
6376
- const sandboxOrchestrator = new SandboxOrchestrator(sandboxConfig, sandboxRuntime, proxyManager);
6377
- proxyManager.onEvent((event) => {
6378
- if (event.type === "blocked") {
6379
- 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`);
6380
- console.error(`\x1b[90m Tip: /sandbox:trust-domain ${event.domain}\x1b[0m\n`);
6381
- sandboxOrchestrator.recordViolation({
6382
- type: "network",
6383
- domain: event.domain,
6384
- command: `[network] ${event.method} ${event.domain}`,
6385
- blockedBy: "proxy",
6386
- timestamp: event.timestamp,
6387
- detail: `Blocked ${event.method} to ${event.domain}`
6388
- }).catch(() => {});
6389
- }
6723
+ const { sandboxOrchestrator } = await buildSandbox({
6724
+ config,
6725
+ sessionId: newSession.id,
6726
+ permissionManager,
6727
+ checkpointStore
6390
6728
  });
6391
- const violationStore = new ViolationLogStore();
6392
- sandboxOrchestrator.setViolationStore(violationStore);
6393
- permissionManager.setSandboxState(sandboxConfig.mode, sandboxOrchestrator.isActive());
6394
- if (sandboxConfig.enabled && sandboxConfig.mode !== "disabled") {
6395
- if (sandboxRuntime) console.log(`🔒 Sandbox: ${sandboxConfig.mode} (${sandboxRuntime.name})`);
6396
- else console.log("⚠️ Sandbox: enabled but runtime not available on this platform");
6397
- if (sandboxConfig.network.enabled) {
6398
- await proxyManager.start();
6399
- if (proxyManager.isRunning()) console.log(`🌐 Network proxy: filtering on port ${proxyManager.getPort()} (${sandboxConfig.network.allowedDomains.length} domains)`);
6400
- }
6401
- }
6402
6729
  let permissionPromptCounter = 0;
6403
6730
  const promptFn = (toolName, args, preview) => {
6404
6731
  return new Promise((resolve) => {
@@ -6473,57 +6800,29 @@ function CliApp() {
6473
6800
  currentAgent: null,
6474
6801
  observationQueue: []
6475
6802
  };
6476
- const { tools: b4mTools } = await generateCliTools(config.userId, llm, modelInfo.id, permissionManager, promptFn, agentContext, state.configStore, apiClient, void 0, userQuestionFn, checkpointStore, sandboxOrchestrator, additionalDirectories);
6477
- const mcpManager = new McpManager(config);
6478
- const builtinAgentsDir = new URL("./agents/defaults/", import.meta.url).pathname;
6479
- const agentProjectDir = state.configStore.getProjectConfigDir();
6480
- const agentStore = new AgentStore(builtinAgentsDir, agentProjectDir || process.cwd());
6481
- const [, , contextResult] = await Promise.all([
6482
- mcpManager.initialize(),
6483
- agentStore.loadAgents(),
6484
- loadContextFiles(agentProjectDir)
6485
- ]);
6486
- const mcpTools = mcpManager.getTools();
6487
- const deferredB4mToolNames = new Set([
6488
- "math_evaluate",
6489
- "dice_roll",
6490
- "current_datetime",
6491
- "recent_changes",
6492
- "prompt_enhancement"
6493
- ]);
6494
- const deferredB4mTools = b4mTools.filter((t) => deferredB4mToolNames.has(t.toolSchema.name));
6495
- const loadedB4mTools = b4mTools.filter((t) => !deferredB4mToolNames.has(t.toolSchema.name));
6496
- deferredToolRegistry.register([...mcpTools, ...deferredB4mTools]);
6497
- if (mcpTools.length > 0) {
6498
- const serverSummaries = mcpManager.getToolCount().map((s) => `${s.serverName} (${s.count})`).join(", ");
6499
- startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M + ${mcpTools.length} MCP tool(s, ${deferredB4mTools.length + mcpTools.length} deferred): ${serverSummaries}`);
6500
- } else {
6501
- const suffix = deferredB4mTools.length > 0 ? ` (${deferredB4mTools.length} deferred)` : "";
6502
- startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M tool(s)${suffix}, no MCP tools`);
6503
- }
6504
- const agentSummary = agentStore.getSummary();
6505
- startupLog.push(`🤖 Loaded ${agentSummary.total} agent(s): ${agentSummary.builtin} built-in, ${agentSummary.global} global, ${agentSummary.project} project`);
6506
- const orchestrator = new SubagentOrchestrator({
6507
- userId: config.userId,
6803
+ const { mcpManager, agentStore, contextResult, mcpTools, loadedB4mTools, deferredB4mTools, orchestrator, backgroundManager } = await buildSupportingStores({
6804
+ config,
6508
6805
  llm,
6509
- logger: silentLogger,
6806
+ modelId: modelInfo.id,
6510
6807
  permissionManager,
6511
- showPermissionPrompt: promptFn,
6512
- configStore: state.configStore,
6513
6808
  apiClient,
6514
- agentStore,
6809
+ configStore: state.configStore,
6515
6810
  customCommandStore: state.customCommandStore,
6516
- enableParallelToolExecution: config.preferences.enableParallelToolExecution === true,
6517
- showUserQuestion: userQuestionFn,
6518
- checkpointStore
6519
- });
6520
- const backgroundManager = new BackgroundAgentManager(orchestrator);
6521
- backgroundManager.setOnStatusChange((job) => {
6522
- useCliStore.getState().upsertBackgroundAgent(job);
6523
- });
6524
- backgroundManager.setOnGroupCompletion((notification, groupDescription) => {
6525
- useCliStore.getState().addCompletedGroupNotification(notification, groupDescription);
6526
- useCliStore.getState().setPendingBackgroundTrigger(true);
6811
+ checkpointStore,
6812
+ sandboxOrchestrator,
6813
+ additionalDirectories,
6814
+ agentContext,
6815
+ promptFn,
6816
+ userQuestionFn,
6817
+ startupLog,
6818
+ silentLogger,
6819
+ onBackgroundStatusChange: (job) => {
6820
+ useCliStore.getState().upsertBackgroundAgent(job);
6821
+ },
6822
+ onGroupCompletion: (notification, groupDescription) => {
6823
+ useCliStore.getState().addCompletedGroupNotification(notification, groupDescription);
6824
+ useCliStore.getState().setPendingBackgroundTrigger(true);
6825
+ }
6527
6826
  });
6528
6827
  const agentDelegateTool = createAgentDelegateTool(orchestrator, agentStore, newSession.id, backgroundManager);
6529
6828
  const dynamicAgentTool = config.preferences.enableDynamicAgentCreation === true ? createDynamicAgentTool(orchestrator, newSession.id, backgroundManager) : null;
@@ -6600,36 +6899,23 @@ function CliApp() {
6600
6899
  if (contextResult.globalContext) startupLog.push(`📄 Global context: ${contextResult.globalContext.filename}`);
6601
6900
  if (contextResult.projectContext) startupLog.push(`📄 Project context: ${contextResult.projectContext.filename}`);
6602
6901
  for (const error of contextResult.errors) startupLog.push(`⚠️ Context file error: ${error}`);
6603
- const featureModulePrompts = featureRegistry.getSystemPromptSections();
6604
- const promptVariant = config.preferences.promptVariant ?? "current";
6605
- const buildPromptForMode = (mode) => buildSystemPrompt(promptVariant, {
6902
+ const { agent, buildPromptForMode } = buildAgent({
6903
+ config,
6904
+ modelId: modelInfo.id,
6905
+ notifyingLlm,
6906
+ allTools,
6907
+ agentContext,
6908
+ agentToolsRef,
6909
+ silentLogger,
6910
+ sessionId: newSession.id,
6911
+ initialInteractionMode: useCliStore.getState().interactionMode,
6606
6912
  contextContent: contextResult.mergedContent,
6607
6913
  agentStore,
6608
- customCommands: state.customCommandStore.getAllCommands(),
6914
+ customCommandStore: state.customCommandStore,
6609
6915
  enableSkillTool,
6610
- enableDynamicAgentCreation: config.preferences.enableDynamicAgentCreation === true,
6611
6916
  additionalDirectories,
6612
- featureModulePrompts: featureModulePrompts || void 0,
6613
- planModeFilePath: mode === "plan" ? getPlanModeFilePath(newSession.id) : void 0,
6614
- appendSystemPrompt: process.env.B4M_APPEND_SYSTEM_PROMPT,
6615
- deferredToolNames: deferredToolRegistry.getDirectoryNames()
6616
- });
6617
- const cliSystemPrompt = buildPromptForMode(useCliStore.getState().interactionMode);
6618
- const maxIterations = config.preferences.maxIterations === null ? 999999 : config.preferences.maxIterations;
6619
- const agent = new ReActAgent({
6620
- userId: config.userId,
6621
- logger: silentLogger,
6622
- llm: notifyingLlm,
6623
- model: modelInfo.id,
6624
- tools: allTools,
6625
- maxIterations,
6626
- maxTokens: config.preferences.maxTokens,
6627
- temperature: config.preferences.temperature,
6628
- systemPrompt: cliSystemPrompt,
6629
- unknownToolResolver: async (toolName) => deferredToolRegistry.get(toolName) ?? null
6917
+ featureModulePrompts: featureRegistry.getSystemPromptSections()
6630
6918
  });
6631
- agentToolsRef.current = agent.getTools();
6632
- agentContext.currentAgent = agent;
6633
6919
  let lastInteractionMode = useCliStore.getState().interactionMode;
6634
6920
  useCliStore.subscribe((s) => {
6635
6921
  if (s.interactionMode === lastInteractionMode) return;
@@ -6637,110 +6923,10 @@ function CliApp() {
6637
6923
  if (agentContext.currentAgent !== agent) return;
6638
6924
  agent.setSystemPrompt(buildPromptForMode(s.interactionMode));
6639
6925
  });
6640
- agent.observationQueue = agentContext.observationQueue;
6641
- const stepHandler = (step) => {
6642
- const { pendingMessages, updatePendingMessage } = useCliStore.getState();
6643
- const lastIdx = pendingMessages.length - 1;
6644
- if (lastIdx >= 0 && pendingMessages[lastIdx].role === "assistant") {
6645
- const existingSteps = pendingMessages[lastIdx].metadata?.steps || [];
6646
- const formattedStep = formatStep(step);
6647
- updatePendingMessage(lastIdx, {
6648
- ...pendingMessages[lastIdx],
6649
- metadata: {
6650
- ...pendingMessages[lastIdx].metadata,
6651
- steps: [...existingSteps, formattedStep]
6652
- }
6653
- });
6654
- }
6655
- };
6656
- agent.on("thought", stepHandler);
6657
- agent.on("observation", stepHandler);
6658
- agent.on("action", stepHandler);
6659
- orchestrator.setBeforeRunCallback((subagent, _subagentType) => {
6660
- subagent.on("thought", stepHandler);
6661
- subagent.on("observation", stepHandler);
6662
- subagent.on("action", stepHandler);
6663
- });
6664
- orchestrator.setAfterRunCallback((subagent, _subagentType) => {
6665
- subagent.off("thought", stepHandler);
6666
- subagent.off("observation", stepHandler);
6667
- subagent.off("action", stepHandler);
6668
- });
6669
- const pendingToolUseIds = /* @__PURE__ */ new Map();
6670
- const summarizeToolInput = (input) => {
6671
- if (input == null) return void 0;
6672
- if (typeof input === "string") return input.slice(0, 240);
6673
- try {
6674
- return JSON.stringify(input).slice(0, 240);
6675
- } catch {
6676
- return;
6677
- }
6678
- };
6679
- const tavernActionHandler = (step) => {
6680
- const toolName = step.metadata?.toolName ?? "tool";
6681
- const toolUseId = `${toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6682
- const queue = pendingToolUseIds.get(toolName) ?? [];
6683
- queue.push(toolUseId);
6684
- pendingToolUseIds.set(toolName, queue);
6685
- bridgePresence.emitEvent({
6686
- type: "tool_use",
6687
- tool: toolName,
6688
- toolUseId,
6689
- text: summarizeToolInput(step.metadata?.toolInput)
6690
- });
6691
- };
6692
- const tavernObservationHandler = (step) => {
6693
- const toolName = step.metadata?.toolName;
6694
- let toolUseId;
6695
- if (toolName) {
6696
- const queue = pendingToolUseIds.get(toolName);
6697
- if (queue && queue.length > 0) {
6698
- toolUseId = queue.shift();
6699
- if (queue.length === 0) pendingToolUseIds.delete(toolName);
6700
- }
6701
- }
6702
- if (!toolUseId) {
6703
- for (const [name, queue] of pendingToolUseIds.entries()) if (queue.length > 0) {
6704
- toolUseId = queue.shift();
6705
- if (queue.length === 0) pendingToolUseIds.delete(name);
6706
- break;
6707
- }
6708
- }
6709
- if (!toolUseId) toolUseId = `orphan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6710
- bridgePresence.emitEvent({
6711
- type: "tool_result",
6712
- tool: toolName,
6713
- toolUseId,
6714
- text: typeof step.content === "string" ? step.content.slice(0, 4e3) : void 0
6715
- });
6716
- };
6717
- const tavernFinalAnswerHandler = (step) => {
6718
- const text = typeof step.content === "string" ? step.content : "";
6719
- if (!text) return;
6720
- bridgePresence.emitEvent({
6721
- type: "message",
6722
- role: "assistant",
6723
- text: text.slice(0, 4e3)
6724
- });
6725
- };
6726
- agent.on("action", tavernActionHandler);
6727
- agent.on("observation", tavernObservationHandler);
6728
- agent.on("final_answer", tavernFinalAnswerHandler);
6729
- orchestrator.setBeforeRunCallback((subagent, _subagentType) => {
6730
- subagent.on("thought", stepHandler);
6731
- subagent.on("observation", stepHandler);
6732
- subagent.on("action", stepHandler);
6733
- subagent.on("action", tavernActionHandler);
6734
- subagent.on("observation", tavernObservationHandler);
6735
- subagent.on("final_answer", tavernFinalAnswerHandler);
6736
- });
6737
- orchestrator.setAfterRunCallback((subagent, _subagentType) => {
6738
- subagent.off("thought", stepHandler);
6739
- subagent.off("observation", stepHandler);
6740
- subagent.off("action", stepHandler);
6741
- subagent.off("action", tavernActionHandler);
6742
- subagent.off("observation", tavernObservationHandler);
6743
- subagent.off("final_answer", tavernFinalAnswerHandler);
6926
+ wireAgentEvents({
6927
+ agent,
6928
+ agentContext,
6929
+ orchestrator
6744
6930
  });
6745
6931
  setState((prev) => ({
6746
6932
  ...prev,