@bike4mind/cli 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +4 -1
  2. package/bin/bike4mind-cli.mjs +0 -5
  3. package/dist/AgentHistoryStore-DIMzvf-y.mjs +18200 -0
  4. package/dist/{ApiClient-BOWpVvTq.mjs → ApiClient-BTI0-m0g.mjs} +44 -5
  5. package/dist/{BubblewrapRuntime-CkL9-gnG.mjs → BubblewrapRuntime-5bLPTwEC.mjs} +2 -2
  6. package/dist/{ConfigStore-DdHHCH2t.mjs → ConfigStore-DIs-K9oD.mjs} +3541 -1625
  7. package/dist/{ProxyManager-C1-lgzEU.mjs → ProxyManager-B0-RuR2w.mjs} +21 -4
  8. package/dist/{SandboxOrchestrator-CIegJrCj.mjs → SandboxOrchestrator-BcUv9fQ3.mjs} +46 -12
  9. package/dist/{SandboxRuntimeAdapter-ChGlxSGQ.mjs → SandboxRuntimeAdapter-BgLUVTJL.mjs} +2 -2
  10. package/dist/{SandboxRuntimeAdapter-CKelGICD.mjs → SandboxRuntimeAdapter-BmHELLuM.mjs} +1 -1
  11. package/dist/{SeatbeltRuntime-Qqt19cAN.mjs → SeatbeltRuntime-C_Y8q8Mr.mjs} +9 -1
  12. package/dist/buildAgent-DOvEp92a.mjs +2106 -0
  13. package/dist/commands/acpCommand.mjs +9 -6
  14. package/dist/commands/apiCommand.mjs +1 -1
  15. package/dist/commands/doctorCommand.mjs +1 -1
  16. package/dist/commands/envCommand.mjs +1 -1
  17. package/dist/commands/headlessCommand.mjs +27 -16
  18. package/dist/commands/mcpCommand.mjs +3 -3
  19. package/dist/commands/pluginCommand.mjs +1 -1
  20. package/dist/commands/updateCommand.mjs +1 -1
  21. package/dist/index.mjs +954 -122
  22. package/dist/{package-Bqg2LSnH.mjs → package-Dl_F2Fah.mjs} +1 -1
  23. package/dist/{serve-DO9Edl5S.mjs → serve-Y2zMbfGs.mjs} +237 -73
  24. package/package.json +11 -12
  25. package/dist/AgentHistoryStore-kT9eNMRO.mjs +0 -39471
  26. package/dist/ProxyManager-B1jFWL7b.mjs +0 -3
  27. package/dist/SandboxOrchestrator-BbMDgjzr.mjs +0 -3
  28. package/dist/buildAgent-B_kArQ_Y.mjs +0 -833
package/dist/index.mjs CHANGED
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { $ as CustomCommandStore, A as createAgentDelegateTool, B as ALWAYS_DENIED_FOR_AGENTS, C as createTodoStore, D as createCoordinateTaskTool, E as parseAgentConfig, F as PermissionManager, H as DEFAULT_MAX_ITERATIONS, J as setWebSocketToolExecutor, K as clearFeatureModuleTools, L as wrapTools, M as substituteArguments, O as createResumeAgentTool, P as findIterationBoundary, Q as RemoteSkillSource, R as getProcessHooks, S as createFindDefinitionTool, T as createSkillTool, U as DEFAULT_RETRY_CONFIG, V as DEFAULT_AGENT_MODEL, W as DEFAULT_THOROUGHNESS, X as buildSystemPrompt, Y as getPlanModeFilePath, Z as buildSkillsPromptSection, _ as createDecisionLogTool, at as mergeCommands, b as createGetFileStructureTool, ct as warmFileCache, d as createReviewGateStore, dt as MAX_PASTE_SIZE, et as CheckpointStore, f as createReviewGateTool, g as formatBlockersOutput, h as createBlockerTools, it as searchCommands, j as FallbackLlmBackend, k as createBackgroundAgentTools, l as isTransientNetworkError, lt as COMPACTION_SUMMARY_MARKER, m as createBlockerStore, nt as hasFileReferences, ot as formatFileSize, p as formatReviewGatesOutput, q as registerFeatureModuleTools, rt as processFileReferences, st as searchFiles, tt as SessionStore, v as createDecisionStore, w as createWriteTodosTool, x as createWorkItemTools, y as formatDecisionsOutput } from "./AgentHistoryStore-kT9eNMRO.mjs";
2
+ import { $ as CustomCommandStore, A as createAgentDelegateTool, B as ALWAYS_DENIED_FOR_AGENTS, C as createTodoStore, D as createCoordinateTaskTool, E as parseAgentConfig, F as PermissionManager, H as DEFAULT_MAX_ITERATIONS, J as setWebSocketToolExecutor, K as clearFeatureModuleTools, L as wrapTools, M as substituteArguments, O as createResumeAgentTool, P as findIterationBoundary, Q as RemoteSkillSource, R as getProcessHooks, S as createFindDefinitionTool, T as createSkillTool, U as DEFAULT_RETRY_CONFIG, V as DEFAULT_AGENT_MODEL, W as DEFAULT_THOROUGHNESS, X as buildSystemPrompt, Y as getPlanModeFilePath, Z as buildSkillsPromptSection, _ as createDecisionLogTool, at as searchCommands, b as createGetFileStructureTool, ct as formatFileSize, d as createReviewGateStore, dt as COMPACTION_SUMMARY_MARKER, et as CheckpointStore, f as createReviewGateTool, g as formatBlockersOutput, h as createBlockerTools, j as FallbackLlmBackend, k as createBackgroundAgentTools, lt as searchFiles, m as createBlockerStore, nt as hasFileReferences, ot as mergeCommands, p as formatReviewGatesOutput, pt as MAX_PASTE_SIZE, q as registerFeatureModuleTools, rt as processFileReferences, s as buildSandbox, st as rewireReservedNames, tt as SessionStore, u as isTransientNetworkError, ut as warmFileCache, v as createDecisionStore, w as createWriteTodosTool, x as createWorkItemTools, y as formatDecisionsOutput } from "./AgentHistoryStore-DIMzvf-y.mjs";
3
3
  import { a as selectLiveSubagentTokens, i as selectLiveSubagentCredits, n as selectActiveBackgroundShells, o as useCliStore, r as selectCompletedBackgroundShells, t as selectActiveBackgroundAgents } from "./store-CvjTpQPs.mjs";
4
4
  import { r as isFeatureEnabled, t as PluginStore } from "./PluginStore-DwvOJ-G3.mjs";
5
- import { D as ChatModels, E as CREDIT_DEDUCT_TRANSACTION_TYPES, Ot as isEarlyStop, c as ApiEndpointUnconfiguredError, cn as actorColorIndex, d as getEnvironmentName, dn as selfClaimedActorKindSchema, et as REVIEW_GATE_STATUS_VALUES, f as parseApiUrl, l as LOCAL_DEV_URL, ln as actorKindMarker, m as resolveApiEndpoint, n as logger, p as requireApiUrl, r as extractCompactInstructions, s as isReadOnlyTool, sn as ACTOR_COLOR_SLOTS, t as ConfigStore, tt as SUBQUEST_STATUS_VALUES, u as getCreditsUrl, un as actorKindSchema } from "./ConfigStore-DdHHCH2t.mjs";
5
+ import { B as REVIEW_GATE_STATUS_VALUES, D as CREDIT_DEDUCT_TRANSACTION_TYPES, H as SUBQUEST_STATUS_VALUES, O as ChatModels, _ as requireApiUrl, _t as actorKindMarker, d as isReadOnlyTool, et as isEarlyStop, f as ApiEndpointUnconfiguredError, g as parseApiUrl, gt as actorColorIndex, h as getEnvironmentName, ht as ACTOR_COLOR_SLOTS, i as isValidSessionId, m as getCreditsUrl, n as logger, p as LOCAL_DEV_URL, r as SESSION_ID_PATTERN, s as extractCompactInstructions, t as ConfigStore, v as resolveApiEndpoint, vt as actorKindSchema, yt as selfClaimedActorKindSchema } from "./ConfigStore-DIs-K9oD.mjs";
6
+ import { a as deferredToolRegistry, i as createToolSearchTool, n as buildSupportingStores, o as NotifyingLlmBackend, r as buildLlmBackend, t as buildAgent } from "./buildAgent-DOvEp92a.mjs";
6
7
  import { t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
7
- import { n as OAuthClient, t as ApiClient } from "./ApiClient-BOWpVvTq.mjs";
8
+ import { i as OAuthClient, t as ApiClient } from "./ApiClient-BTI0-m0g.mjs";
9
+ import { t as version } from "./package-Dl_F2Fah.mjs";
8
10
  import { i as getTokenCounter, n as ConversationContext, r as reconstructTurnBlocks, t as WorkItemsClient } from "./WorkItemsClient-Cow6nXx7.mjs";
9
- import { a as createToolSearchTool, i as buildLlmBackend, n as buildSupportingStores, o as deferredToolRegistry, r as buildSandbox, s as NotifyingLlmBackend, t as buildAgent } from "./buildAgent-B_kArQ_Y.mjs";
10
- import { t as version } from "./package-Bqg2LSnH.mjs";
11
11
  import { r as checkForUpdate } from "./updateChecker-CQW8bxo6.mjs";
12
12
  import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
13
13
  import { Box, Static, Text, render, useApp, useInput, usePaste, useStdout } from "ink";
14
- import { execSync, spawn } from "child_process";
14
+ import { execFile, execSync, spawn } from "child_process";
15
15
  import { randomBytes } from "crypto";
16
- import { existsSync, promises, readFileSync, statSync } from "fs";
16
+ import { constants, existsSync, promises, readFileSync, statSync } from "fs";
17
17
  import { homedir } from "os";
18
18
  import path, { basename, extname, join } from "path";
19
19
  import { v4 } from "uuid";
@@ -25,6 +25,7 @@ import { z as z$1 } from "zod";
25
25
  import axios, { isAxiosError } from "axios";
26
26
  import { marked } from "marked";
27
27
  import { highlight } from "cli-highlight";
28
+ import { promisify } from "util";
28
29
  import SelectInput from "ink-select-input";
29
30
  import jwt from "jsonwebtoken";
30
31
  import open from "open";
@@ -1084,14 +1085,26 @@ function CompletedGroupNotification() {
1084
1085
  //#endregion
1085
1086
  //#region src/components/PermissionPrompt.tsx
1086
1087
  /**
1087
- * Escape terminal control characters (carriage return, ESC/ANSI, other C0
1088
- * controls, DEL) into a visible `\xHH` form. The preview is model-authored;
1089
- * rendering it raw would let a `\r` or ESC sequence rewrite what the user sees,
1090
- * so the approved text could differ from what actually runs. Tab and newline are
1091
- * left intact (newline is already the line delimiter).
1088
+ * Escape characters that let untrusted text render differently from what
1089
+ * actually runs, into a visible escape form. Shared by every prompt that shows
1090
+ * untrusted text for the user to decide on: tool previews/arguments here,
1091
+ * repo-authored MCP definitions (McpApprovalPrompt), the folder-trust path
1092
+ * (FolderTrustPrompt), and model-authored ReviewGatePrompt / UserQuestionPrompt text. Display only - never escape the
1093
+ * value that is acted on or sent back.
1094
+ * These ranges are covered, deliberately and no wider:
1095
+ * - C0 controls (minus tab/newline), DEL and C1 controls -> `\xHH`: a raw
1096
+ * `\r`, ESC or 8-bit CSI (U+009B) could rewrite the line being approved.
1097
+ * - Unicode bidi overrides U+202A..U+202E and isolates U+2066..U+2069
1098
+ * -> `\uXXXX`: these reorder displayed glyphs without changing the
1099
+ * underlying code points, spoofing the same way.
1100
+ * Tab and newline are left intact (newline is already the line delimiter).
1101
+ * Widen the scope here, so both call sites gain it at once.
1092
1102
  */
1093
1103
  function escapeTerminalControlChars(text) {
1094
- return text.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`);
1104
+ return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, (ch) => {
1105
+ const code = ch.charCodeAt(0);
1106
+ return code > 255 ? `\\u${code.toString(16).padStart(4, "0")}` : `\\x${code.toString(16).padStart(2, "0")}`;
1107
+ });
1095
1108
  }
1096
1109
  /** Render a diff/preview string, color-coding each line by its prefix (see per-branch comments below). */
1097
1110
  function renderDiffPreview(preview) {
@@ -1222,13 +1235,13 @@ function PermissionPrompt({ toolName, toolDescription, args, preview, canBeTrust
1222
1235
  }, headerText)), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Tool: "), /* @__PURE__ */ React.createElement(Text, {
1223
1236
  bold: true,
1224
1237
  color: "cyan"
1225
- }, toolName)), toolDescription && /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Action: "), /* @__PURE__ */ React.createElement(Text, null, toolDescription)), !hideArgs && /* @__PURE__ */ React.createElement(Box, {
1238
+ }, escapeTerminalControlChars(toolName))), toolDescription && /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Action: "), /* @__PURE__ */ React.createElement(Text, null, escapeTerminalControlChars(toolDescription))), !hideArgs && /* @__PURE__ */ React.createElement(Box, {
1226
1239
  marginTop: 1,
1227
1240
  flexDirection: "column"
1228
1241
  }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Arguments:"), /* @__PURE__ */ React.createElement(Box, {
1229
1242
  paddingLeft: 2,
1230
1243
  flexDirection: "column"
1231
- }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, argsString))), preview && /* @__PURE__ */ React.createElement(Box, {
1244
+ }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(argsString)))), preview && /* @__PURE__ */ React.createElement(Box, {
1232
1245
  marginTop: 1,
1233
1246
  flexDirection: "column"
1234
1247
  }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Preview:"), /* @__PURE__ */ React.createElement(Box, {
@@ -1398,7 +1411,7 @@ function UserQuestionPrompt({ payload, onResponse }) {
1398
1411
  }, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, {
1399
1412
  bold: true,
1400
1413
  color: "cyan"
1401
- }, "? Question", totalQuestions > 1 ? ` ${questionIndex + 1}/${totalQuestions}` : "")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, currentQuestion.question)), /* @__PURE__ */ React.createElement(Box, {
1414
+ }, "? Question", totalQuestions > 1 ? ` ${questionIndex + 1}/${totalQuestions}` : "")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, escapeTerminalControlChars(currentQuestion.question))), /* @__PURE__ */ React.createElement(Box, {
1402
1415
  marginTop: 1,
1403
1416
  flexDirection: "column"
1404
1417
  }, options.map((opt, idx) => {
@@ -1408,12 +1421,12 @@ function UserQuestionPrompt({ payload, onResponse }) {
1408
1421
  return /* @__PURE__ */ React.createElement(Box, { key: idx }, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, idx + 1, "."), /* @__PURE__ */ React.createElement(Text, {
1409
1422
  color: isHighlighted ? "cyan" : void 0,
1410
1423
  bold: isHighlighted
1411
- }, " ", prefix, " ", opt.label), idx === otherIndex && isHighlighted ? /* @__PURE__ */ React.createElement(Box, { marginLeft: 1 }, /* @__PURE__ */ React.createElement(TextInput, {
1424
+ }, " ", prefix, " ", escapeTerminalControlChars(opt.label)), idx === otherIndex && isHighlighted ? /* @__PURE__ */ React.createElement(Box, { marginLeft: 1 }, /* @__PURE__ */ React.createElement(TextInput, {
1412
1425
  value: otherText,
1413
1426
  onChange: setOtherText,
1414
1427
  onSubmit: submitOther,
1415
1428
  placeholder: "Type your answer..."
1416
- })) : opt.description && idx !== otherIndex && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " - ", opt.description));
1429
+ })) : opt.description && idx !== otherIndex && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " - ", escapeTerminalControlChars(opt.description)));
1417
1430
  })), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, isOnOther ? "Type your answer, Enter to submit, or arrow keys to go back" : isMulti ? "Press 1-" + options.length + ", Space to toggle, Enter to confirm" : "Press 1-" + options.length + ", or arrow keys + Enter")));
1418
1431
  }
1419
1432
  //#endregion
@@ -1516,10 +1529,10 @@ function ReviewGatePrompt({ description, options, recommendation, onResponse })
1516
1529
  }, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, {
1517
1530
  bold: true,
1518
1531
  color: "magenta"
1519
- }, "🛑 Review Gate")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, null, description)), recommendation && /* @__PURE__ */ React.createElement(Box, {
1532
+ }, "🛑 Review Gate")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, null, escapeTerminalControlChars(description))), recommendation && /* @__PURE__ */ React.createElement(Box, {
1520
1533
  marginTop: 1,
1521
1534
  flexDirection: "column"
1522
- }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Recommendation:"), /* @__PURE__ */ React.createElement(Box, { paddingLeft: 2 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, recommendation))), options && options.length > 0 && /* @__PURE__ */ React.createElement(Box, {
1535
+ }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Recommendation:"), /* @__PURE__ */ React.createElement(Box, { paddingLeft: 2 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(recommendation)))), options && options.length > 0 && /* @__PURE__ */ React.createElement(Box, {
1523
1536
  marginTop: 1,
1524
1537
  flexDirection: "column"
1525
1538
  }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Options:"), /* @__PURE__ */ React.createElement(Box, {
@@ -1528,7 +1541,7 @@ function ReviewGatePrompt({ description, options, recommendation, onResponse })
1528
1541
  }, options.map((opt, idx) => /* @__PURE__ */ React.createElement(Text, {
1529
1542
  key: idx,
1530
1543
  dimColor: true
1531
- }, "• ", opt)))), /* @__PURE__ */ React.createElement(Box, {
1544
+ }, "• ", escapeTerminalControlChars(opt))))), /* @__PURE__ */ React.createElement(Box, {
1532
1545
  marginTop: 1,
1533
1546
  flexDirection: "column"
1534
1547
  }, ITEMS.map((item, index) => {
@@ -1806,6 +1819,18 @@ function buildConfigItems(availableModels, pluginDescriptors = []) {
1806
1819
  enableCoordinatorMode: value
1807
1820
  }
1808
1821
  })
1822
+ }, {
1823
+ key: "postEditDiagnostics",
1824
+ label: "Post-Edit Diagnostics",
1825
+ type: "boolean",
1826
+ getValue: (config) => config.preferences.postEditDiagnostics ?? false,
1827
+ setValue: (config, value) => ({
1828
+ ...config,
1829
+ preferences: {
1830
+ ...config.preferences,
1831
+ postEditDiagnostics: value
1832
+ }
1833
+ })
1809
1834
  }, {
1810
1835
  key: "featuresTavern",
1811
1836
  label: "Tavern",
@@ -2687,7 +2712,7 @@ function FolderTrustPrompt({ projectRoot, onSelect }) {
2687
2712
  }, /* @__PURE__ */ React.createElement(Text, {
2688
2713
  bold: true,
2689
2714
  color: "yellow"
2690
- }, "This project ships Bike4Mind config that can run code."), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, projectRoot), /* @__PURE__ */ React.createElement(Text, null, "Repo-committed config, MCP servers, agents and skills stay inert until you trust this folder. Only trust folders you would run code from.")), /* @__PURE__ */ React.createElement(SelectInput, {
2715
+ }, "This project ships Bike4Mind config that can run code."), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(projectRoot)), /* @__PURE__ */ React.createElement(Text, null, "Repo-committed config, MCP servers, agents and skills stay inert until you trust this folder. Only trust folders you would run code from.")), /* @__PURE__ */ React.createElement(SelectInput, {
2691
2716
  items: [{
2692
2717
  label: "Not now - keep repo config inert this session",
2693
2718
  value: "not-now"
@@ -2700,6 +2725,52 @@ function FolderTrustPrompt({ projectRoot, onSelect }) {
2700
2725
  }), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Use ↑↓ arrows to navigate, Enter to select. You can change this later with /trust folder.")));
2701
2726
  }
2702
2727
  //#endregion
2728
+ //#region src/components/McpApprovalPrompt.tsx
2729
+ const displaySafe = (s) => escapeTerminalControlChars(s).replace(/[\t\n]/g, (c) => c === "\n" ? "\\x0a" : "\\x09");
2730
+ const displayArg = (a) => {
2731
+ const safe = displaySafe(a);
2732
+ return /[\s"']/.test(a) ? `"${safe.replace(/"/g, "\\\"")}"` : safe;
2733
+ };
2734
+ /**
2735
+ * Startup prompt for repo-discovered MCP servers (trusted project) whose exact
2736
+ * definition hasn't been approved. Shows env/header KEY names; values only for
2737
+ * loader-style env keys (PATH, NODE_OPTIONS, LD_*, ...) that change what runs.
2738
+ * "Approve all" persists each definition's fingerprint (a changed definition asks
2739
+ * again); "Skip" persists nothing, so they stay off and re-prompt next launch.
2740
+ *
2741
+ * "Skip" is listed first so Enter-through never starts a server. Sibling of
2742
+ * FolderTrustPrompt.
2743
+ */
2744
+ function McpApprovalPrompt({ projectRoot, servers, onSelect }) {
2745
+ return /* @__PURE__ */ React.createElement(Box, {
2746
+ flexDirection: "column",
2747
+ marginY: 1
2748
+ }, /* @__PURE__ */ React.createElement(Box, {
2749
+ marginBottom: 1,
2750
+ flexDirection: "column"
2751
+ }, /* @__PURE__ */ React.createElement(Text, {
2752
+ bold: true,
2753
+ color: "yellow"
2754
+ }, "This project defines MCP servers that will run code on your machine."), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, displaySafe(projectRoot))), /* @__PURE__ */ React.createElement(Box, {
2755
+ marginBottom: 1,
2756
+ flexDirection: "column"
2757
+ }, servers.map((s) => /* @__PURE__ */ React.createElement(Box, {
2758
+ key: s.name,
2759
+ flexDirection: "column",
2760
+ marginBottom: 1
2761
+ }, /* @__PURE__ */ React.createElement(Text, { bold: true }, displaySafe(s.name)), /* @__PURE__ */ React.createElement(Text, null, s.url !== void 0 ? displaySafe(s.url) : [s.command, ...s.args ?? []].filter((p) => p !== void 0).map(displayArg).join(" ")), s.envKeys.length > 0 && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "env:", " ", s.envKeys.map((k) => k in s.envValues ? displaySafe(`${k}=${s.envValues[k]}`) : displaySafe(k)).join(", ")), s.headerKeys.length > 0 && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "headers: ", s.headerKeys.map(displaySafe).join(", "))))), /* @__PURE__ */ React.createElement(SelectInput, {
2762
+ items: [{
2763
+ label: "Skip - don't start these this session",
2764
+ value: "skip"
2765
+ }, {
2766
+ label: "Approve all - start these servers (asks again if they change)",
2767
+ value: "approve"
2768
+ }],
2769
+ onSelect: (item) => onSelect(item.value),
2770
+ itemComponent: ({ isSelected, label }) => /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: isSelected ? "cyan" : void 0 }, isSelected ? "❯ " : " ", label))
2771
+ }), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Use ↑↓ arrows to navigate, Enter to select.")));
2772
+ }
2773
+ //#endregion
2703
2774
  //#region src/components/RewindSelector.tsx
2704
2775
  function RewindSelector({ messages, onSelect, onCancel }) {
2705
2776
  const [step, setStep] = useState("selection");
@@ -2942,6 +3013,37 @@ function isBrowserOpenableUrl(raw) {
2942
3013
  if (url.protocol === "https:") return true;
2943
3014
  return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
2944
3015
  }
3016
+ /**
3017
+ * The device-flow response is server-controlled, so a compromised server could point the
3018
+ * verification page at a phishing origin. Only auto-open URLs on the configured API origin.
3019
+ * Strict compare: no subdomain aliasing and no localhost/127.0.0.1 equivalence.
3020
+ */
3021
+ function isOnApiOrigin(raw, apiUrl) {
3022
+ try {
3023
+ return new URL(raw).origin === new URL(apiUrl).origin;
3024
+ } catch {
3025
+ return false;
3026
+ }
3027
+ }
3028
+ function originOf(raw) {
3029
+ try {
3030
+ const { origin } = new URL(raw);
3031
+ return origin === "null" ? raw : origin;
3032
+ } catch {
3033
+ return raw;
3034
+ }
3035
+ }
3036
+ /** Strips C0/C1 control chars so a server-supplied string can't inject terminal escape sequences. */
3037
+ function stripControlChars(raw) {
3038
+ return raw === void 0 ? raw : raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
3039
+ }
3040
+ function hostOf(raw) {
3041
+ try {
3042
+ return new URL(raw).host;
3043
+ } catch {
3044
+ return raw;
3045
+ }
3046
+ }
2945
3047
  function extractErrorMessage(err) {
2946
3048
  if (axios.isAxiosError(err)) {
2947
3049
  if (err.response?.data) {
@@ -2963,13 +3065,19 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
2963
3065
  const [deviceFlow, setDeviceFlow] = useState(null);
2964
3066
  const [statusMessage, setStatusMessage] = useState("Initiating device authorization...");
2965
3067
  const [error, setError] = useState(null);
3068
+ const [account, setAccount] = useState(null);
2966
3069
  useEffect(() => {
2967
3070
  const runLoginFlow = async () => {
2968
3071
  const oauth = new OAuthClient(apiUrl);
2969
3072
  try {
2970
3073
  setStatus("initiating");
2971
3074
  const deviceFlowResponse = await oauth.initiateDeviceFlow();
2972
- setDeviceFlow(deviceFlowResponse);
3075
+ setDeviceFlow({
3076
+ ...deviceFlowResponse,
3077
+ verification_uri: stripControlChars(deviceFlowResponse.verification_uri),
3078
+ verification_uri_complete: stripControlChars(deviceFlowResponse.verification_uri_complete),
3079
+ user_code: stripControlChars(deviceFlowResponse.user_code)
3080
+ });
2973
3081
  setStatus("waiting");
2974
3082
  setStatusMessage("Waiting for authorization...");
2975
3083
  const tokens = await oauth.waitForAuthorization(deviceFlowResponse.device_code, deviceFlowResponse.interval, (message) => setStatusMessage(message));
@@ -2981,12 +3089,18 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
2981
3089
  expiresAt,
2982
3090
  userId
2983
3091
  });
3092
+ setAccount({ userId });
2984
3093
  setStatus("success");
2985
3094
  setStatusMessage("Successfully authenticated!");
2986
3095
  setTimeout(() => onSuccess(), 1500);
3096
+ new ApiClient(apiUrl, configStore).get("/api/identify").then((res) => setAccount({
3097
+ userId,
3098
+ email: stripControlChars(res?.user?.email),
3099
+ username: stripControlChars(res?.user?.username)
3100
+ })).catch(() => {});
2987
3101
  } catch (err) {
2988
3102
  setStatus("error");
2989
- const errorMessage = extractErrorMessage(err);
3103
+ const errorMessage = stripControlChars(extractErrorMessage(err));
2990
3104
  setError(errorMessage);
2991
3105
  onError(new Error(errorMessage));
2992
3106
  }
@@ -2998,12 +3112,19 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
2998
3112
  onSuccess,
2999
3113
  onError
3000
3114
  ]);
3115
+ const uris = deviceFlow ? [deviceFlow.verification_uri, deviceFlow.verification_uri_complete] : [];
3116
+ const isTrustedUri = (u) => isBrowserOpenableUrl(u) && isOnApiOrigin(u, apiUrl);
3117
+ const untrustedUri = uris.find((u) => !isTrustedUri(u));
3118
+ const trusted = uris.length > 0 && uris.every(isTrustedUri);
3001
3119
  useEffect(() => {
3002
- if (deviceFlow && status === "waiting") if (isBrowserOpenableUrl(deviceFlow.verification_uri_complete)) open(deviceFlow.verification_uri_complete).catch((err) => {
3120
+ if (deviceFlow && status === "waiting" && trusted) open(deviceFlow.verification_uri_complete).catch((err) => {
3003
3121
  console.error("Failed to auto-open browser:", err);
3004
3122
  });
3005
- else console.error("Refusing to auto-open untrusted verification URL:", deviceFlow.verification_uri_complete);
3006
- }, [deviceFlow, status]);
3123
+ }, [
3124
+ deviceFlow,
3125
+ status,
3126
+ trusted
3127
+ ]);
3007
3128
  if (status === "initiating") return /* @__PURE__ */ React.createElement(Box, {
3008
3129
  flexDirection: "column",
3009
3130
  padding: 1
@@ -3021,7 +3142,7 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
3021
3142
  }, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
3022
3143
  color: "green",
3023
3144
  bold: true
3024
- }, "✔ Successfully authenticated!")), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "You can now use B4M CLI with your account.")));
3145
+ }, "✔ Successfully authenticated!")), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, null, "Logged in as ", account?.email || account?.username || `user ${account?.userId}`, " on ", hostOf(apiUrl))));
3025
3146
  return /* @__PURE__ */ React.createElement(Box, {
3026
3147
  flexDirection: "column",
3027
3148
  padding: 1,
@@ -3030,7 +3151,10 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
3030
3151
  }, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
3031
3152
  color: "cyan",
3032
3153
  bold: true
3033
- }, "🔐 Device Authorization")), /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Opening browser automatically... If it doesn't open, please visit:")), /* @__PURE__ */ React.createElement(Box, {
3154
+ }, "🔐 Device Authorization")), trusted ? /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Opening browser automatically... If it doesn't open, please visit:")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
3155
+ color: "red",
3156
+ bold: true
3157
+ }, !untrustedUri ? "Not opening browser: the server did not return a verification URL." : isOnApiOrigin(untrustedUri, apiUrl) ? `Not opening browser: verification URL ${originOf(untrustedUri)} is not https (plain http is only auto-opened on localhost). Only continue if you trust it.` : `Not opening browser: verification URL origin ${originOf(untrustedUri)} does not match the configured server ${originOf(apiUrl)}. Only continue if you trust it.`)), /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Please visit:"))), /* @__PURE__ */ React.createElement(Box, {
3034
3158
  marginBottom: 1,
3035
3159
  paddingLeft: 2
3036
3160
  }, /* @__PURE__ */ React.createElement(Text, {
@@ -3566,7 +3690,7 @@ function toStringArray(value) {
3566
3690
  function extractJsonObject(text) {
3567
3691
  const trimmed = text.trim();
3568
3692
  if (!trimmed) return null;
3569
- const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
3693
+ const fenced = trimmed.match(/```(?:json)?([\s\S]*?)```/i);
3570
3694
  const candidate = fenced ? fenced[1].trim() : trimmed;
3571
3695
  const start = candidate.indexOf("{");
3572
3696
  if (start === -1) return null;
@@ -4507,6 +4631,314 @@ Unlike agent_delegate (which uses pre-defined agents), this tool lets you compos
4507
4631
  };
4508
4632
  }
4509
4633
  //#endregion
4634
+ //#region src/diagnostics/checkers.ts
4635
+ const CHECK_TIMEOUT_MS = 12e4;
4636
+ const MAX_OUTPUT_BYTES = 16777216;
4637
+ const TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([
4638
+ ".ts",
4639
+ ".tsx",
4640
+ ".mts",
4641
+ ".cts"
4642
+ ]);
4643
+ const ESLINT_EXTENSIONS = /* @__PURE__ */ new Set([
4644
+ ...TYPESCRIPT_EXTENSIONS,
4645
+ ".js",
4646
+ ".jsx",
4647
+ ".mjs",
4648
+ ".cjs"
4649
+ ]);
4650
+ const ESLINT_ERROR_SEVERITY = 2;
4651
+ const TSC_ENTRY = path.join("node_modules", "typescript", "bin", "tsc");
4652
+ const ESLINT_ENTRY = path.join("node_modules", "eslint", "bin", "eslint.js");
4653
+ const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
4654
+ /**
4655
+ * Runs a binary without a shell (paths are passed as argv, never interpolated).
4656
+ * A non-zero exit is a normal outcome - tsc/eslint exit non-zero when they find errors.
4657
+ */
4658
+ function runProcess(file, args, cwd) {
4659
+ return new Promise((resolve) => {
4660
+ execFile(file, args, {
4661
+ cwd,
4662
+ timeout: CHECK_TIMEOUT_MS,
4663
+ maxBuffer: MAX_OUTPUT_BYTES,
4664
+ killSignal: "SIGKILL",
4665
+ windowsHide: true
4666
+ }, (error, stdout, stderr) => {
4667
+ if (!error) {
4668
+ resolve({
4669
+ kind: "exited",
4670
+ stdout
4671
+ });
4672
+ return;
4673
+ }
4674
+ if (error.killed) {
4675
+ resolve({
4676
+ kind: "failed",
4677
+ reason: `timed out after ${CHECK_TIMEOUT_MS}ms`
4678
+ });
4679
+ return;
4680
+ }
4681
+ if (typeof error.code !== "number") {
4682
+ resolve({
4683
+ kind: "failed",
4684
+ reason: error.message
4685
+ });
4686
+ return;
4687
+ }
4688
+ if (!stdout.trim()) {
4689
+ resolve({
4690
+ kind: "failed",
4691
+ reason: `exit ${error.code}: ${stderr.trim().slice(0, 300)}`
4692
+ });
4693
+ return;
4694
+ }
4695
+ resolve({
4696
+ kind: "exited",
4697
+ stdout
4698
+ });
4699
+ });
4700
+ });
4701
+ }
4702
+ /**
4703
+ * Runs a package's JS entry under the current Node. `node_modules/.bin/*` are sh
4704
+ * shims (`.cmd` on Windows) that `execFile` cannot launch without a shell.
4705
+ */
4706
+ function runNodeScript(script, args, cwd) {
4707
+ return runProcess(process.execPath, [script, ...args], cwd);
4708
+ }
4709
+ /** Nearest `relativePath` at or above `startDir`, or null. */
4710
+ function findUp(startDir, relativePath) {
4711
+ let dir = path.resolve(startDir);
4712
+ for (;;) {
4713
+ const candidate = path.join(dir, relativePath);
4714
+ if (existsSync(candidate)) return candidate;
4715
+ const parent = path.dirname(dir);
4716
+ if (parent === dir) return null;
4717
+ dir = parent;
4718
+ }
4719
+ }
4720
+ /** Groups files by a key derived from each; files whose key is null are dropped. */
4721
+ function groupBy(filePaths, keyOf) {
4722
+ const groups = /* @__PURE__ */ new Map();
4723
+ for (const filePath of filePaths) {
4724
+ const key = keyOf(filePath);
4725
+ if (!key) continue;
4726
+ groups.set(key, [...groups.get(key) ?? [], filePath]);
4727
+ }
4728
+ return groups;
4729
+ }
4730
+ const hasExtension = (extensions) => (filePath) => extensions.has(path.extname(filePath));
4731
+ function parseTscOutput(stdout, cwd) {
4732
+ return stdout.split("\n").flatMap((line) => {
4733
+ const match = TSC_ERROR_LINE.exec(line.trimEnd());
4734
+ if (!match) return [];
4735
+ const [, file, lineNumber, column, code, message] = match;
4736
+ return [{
4737
+ filePath: path.resolve(cwd, file),
4738
+ line: Number(lineNumber),
4739
+ column: Number(column),
4740
+ code,
4741
+ message
4742
+ }];
4743
+ });
4744
+ }
4745
+ function isEslintReport(value) {
4746
+ return Array.isArray(value) && value.every((entry) => typeof entry === "object" && entry !== null && typeof entry.filePath === "string" && Array.isArray(entry.messages));
4747
+ }
4748
+ function parseEslintOutput(stdout) {
4749
+ let report;
4750
+ try {
4751
+ report = JSON.parse(stdout);
4752
+ } catch {
4753
+ logger.debug(`[diagnostics] eslint produced non-JSON output: ${stdout.slice(0, 200)}`);
4754
+ return [];
4755
+ }
4756
+ if (!isEslintReport(report)) return [];
4757
+ return report.flatMap((fileResult) => fileResult.messages.filter((message) => message.severity === ESLINT_ERROR_SEVERITY).map((message) => ({
4758
+ filePath: path.resolve(fileResult.filePath),
4759
+ line: message.line ?? 1,
4760
+ column: message.column ?? 1,
4761
+ code: message.ruleId ?? "eslint",
4762
+ message: message.message
4763
+ })));
4764
+ }
4765
+ /**
4766
+ * Type-checks each changed file's nearest tsconfig project with the project's
4767
+ * own `tsc` (never a global one). Project-wide so path aliases and compiler
4768
+ * options are honored; output is filtered to the changed files by the caller.
4769
+ */
4770
+ const checkTypeScript = async (filePaths) => {
4771
+ const projects = groupBy(filePaths.filter(hasExtension(TYPESCRIPT_EXTENSIONS)), (filePath) => findUp(path.dirname(filePath), "tsconfig.json"));
4772
+ const diagnostics = [];
4773
+ for (const tsconfigPath of projects.keys()) {
4774
+ const projectDir = path.dirname(tsconfigPath);
4775
+ const tsc = findUp(projectDir, TSC_ENTRY);
4776
+ if (!tsc) {
4777
+ logger.debug(`[diagnostics] no local tsc found for ${tsconfigPath}; skipping type check`);
4778
+ continue;
4779
+ }
4780
+ const outcome = await runNodeScript(tsc, [
4781
+ "--noEmit",
4782
+ "--pretty",
4783
+ "false",
4784
+ "-p",
4785
+ tsconfigPath
4786
+ ], projectDir);
4787
+ if (outcome.kind === "failed") {
4788
+ logger.debug(`[diagnostics] tsc skipped for ${tsconfigPath}: ${outcome.reason}`);
4789
+ continue;
4790
+ }
4791
+ diagnostics.push(...parseTscOutput(outcome.stdout, projectDir));
4792
+ }
4793
+ return diagnostics;
4794
+ };
4795
+ /** Lints changed files with the eslint installed nearest to each file, run from that package root. */
4796
+ const checkEslint = async (filePaths) => {
4797
+ const groups = groupBy(filePaths.filter(hasExtension(ESLINT_EXTENSIONS)), (filePath) => findUp(path.dirname(filePath), ESLINT_ENTRY));
4798
+ const diagnostics = [];
4799
+ for (const [eslint, files] of groups) {
4800
+ const packageRoot = path.resolve(path.dirname(eslint), "..", "..", "..");
4801
+ const outcome = await runNodeScript(eslint, [
4802
+ "--format",
4803
+ "json",
4804
+ ...files
4805
+ ], packageRoot);
4806
+ if (outcome.kind === "failed") {
4807
+ logger.debug(`[diagnostics] eslint skipped under ${packageRoot}: ${outcome.reason}`);
4808
+ continue;
4809
+ }
4810
+ diagnostics.push(...parseEslintOutput(outcome.stdout));
4811
+ }
4812
+ return diagnostics;
4813
+ };
4814
+ /** The default checker: tsc and eslint side by side. */
4815
+ const checkTypeScriptAndEslint = async (filePaths) => {
4816
+ const [typeErrors, lintErrors] = await Promise.all([checkTypeScript(filePaths), checkEslint(filePaths)]);
4817
+ return [...typeErrors, ...lintErrors];
4818
+ };
4819
+ //#endregion
4820
+ //#region src/diagnostics/PostEditDiagnostics.ts
4821
+ /**
4822
+ * How long a drain waits for in-flight checks (ms). Between turns the model
4823
+ * will take another turn anyway, so a busy worker just defers to the next
4824
+ * one; at finalization there is no later turn, so it waits longer.
4825
+ */
4826
+ const DRAIN_BUDGET_MS = {
4827
+ turn: 250,
4828
+ final: 3e4
4829
+ };
4830
+ const REPORT_HEADER = "[Post-edit diagnostics] Errors in files you changed. Fix them before you finish:";
4831
+ const MAX_REPORTED_ERRORS = 20;
4832
+ const MAX_MESSAGE_LENGTH = 300;
4833
+ /**
4834
+ * Background type/lint checking of files the agent edits, drained into the
4835
+ * agent loop via AgentRunOptions.drainFeedback. Edits never wait on a check.
4836
+ *
4837
+ * Queue semantics are re-edit-wins: a file edited again before its check runs
4838
+ * is checked once in its final state, and a file edited again after a check
4839
+ * started (in flight or already completed but not yet reported) has that stale
4840
+ * result discarded in favor of the queued re-check. Each completed result is
4841
+ * reported at most once.
4842
+ *
4843
+ * Only collects between beginTurn() and endTurn(); a no-op otherwise, which is
4844
+ * how the per-turn config gate in turnController switches it off.
4845
+ */
4846
+ var PostEditDiagnostics = class {
4847
+ constructor({ workspaceRoot, check = checkTypeScriptAndEslint }) {
4848
+ this.pending = /* @__PURE__ */ new Set();
4849
+ this.results = /* @__PURE__ */ new Map();
4850
+ this.worker = null;
4851
+ this.active = false;
4852
+ this.generation = 0;
4853
+ this.workspaceRoot = workspaceRoot;
4854
+ this.check = check;
4855
+ }
4856
+ beginTurn() {
4857
+ this.reset();
4858
+ this.active = true;
4859
+ }
4860
+ endTurn() {
4861
+ this.reset();
4862
+ this.active = false;
4863
+ }
4864
+ enqueue(filePath) {
4865
+ if (!this.active) return;
4866
+ const resolved = path.resolve(this.workspaceRoot, filePath);
4867
+ this.results.delete(resolved);
4868
+ this.pending.add(resolved);
4869
+ this.worker ??= this.processQueue();
4870
+ }
4871
+ /**
4872
+ * Waits up to `budgetMs` (or until `signal` aborts) for queued checks, then
4873
+ * returns the errors found since the last drain, or null when there are none.
4874
+ */
4875
+ async drain(budgetMs, signal) {
4876
+ if (this.worker) await settleWithin(this.worker, budgetMs, signal);
4877
+ return this.takeReport();
4878
+ }
4879
+ reset() {
4880
+ this.generation++;
4881
+ this.pending.clear();
4882
+ this.results.clear();
4883
+ }
4884
+ async processQueue() {
4885
+ try {
4886
+ while (this.pending.size > 0) {
4887
+ const batch = [...this.pending];
4888
+ this.pending.clear();
4889
+ const generation = this.generation;
4890
+ const diagnostics = await this.runCheck(batch);
4891
+ if (!diagnostics || generation !== this.generation) continue;
4892
+ for (const filePath of batch) {
4893
+ if (this.pending.has(filePath)) continue;
4894
+ this.results.set(filePath, diagnostics.filter((diagnostic) => diagnostic.filePath === filePath));
4895
+ }
4896
+ }
4897
+ } finally {
4898
+ this.worker = null;
4899
+ }
4900
+ }
4901
+ async runCheck(batch) {
4902
+ try {
4903
+ return await this.check(batch);
4904
+ } catch (error) {
4905
+ logger.debug(`[diagnostics] check failed; skipping ${batch.length} file(s): ${String(error)}`);
4906
+ return null;
4907
+ }
4908
+ }
4909
+ takeReport() {
4910
+ const errors = [...this.results.values()].flat();
4911
+ this.results.clear();
4912
+ if (errors.length === 0) return null;
4913
+ const lines = errors.slice(0, MAX_REPORTED_ERRORS).map((error) => this.formatError(error));
4914
+ const omitted = errors.length - lines.length;
4915
+ if (omitted > 0) lines.push(`(+${omitted} more)`);
4916
+ return [REPORT_HEADER, ...lines].join("\n");
4917
+ }
4918
+ formatError({ filePath, line, column, code, message }) {
4919
+ const relativePath = path.relative(this.workspaceRoot, filePath).split(path.sep).join("/");
4920
+ const firstLine = message.split("\n")[0];
4921
+ return `${relativePath}:${line}:${column} - ${code}: ${firstLine.length > MAX_MESSAGE_LENGTH ? `${firstLine.slice(0, MAX_MESSAGE_LENGTH)}...` : firstLine}`;
4922
+ }
4923
+ };
4924
+ /** Resolves when `work` settles, `budgetMs` elapses, or `signal` aborts - whichever is first. */
4925
+ async function settleWithin(work, budgetMs, signal) {
4926
+ if (signal?.aborted) return;
4927
+ let timer;
4928
+ let onAbort;
4929
+ const cutoff = new Promise((resolve) => {
4930
+ timer = setTimeout(resolve, budgetMs);
4931
+ onAbort = resolve;
4932
+ signal?.addEventListener("abort", onAbort, { once: true });
4933
+ });
4934
+ try {
4935
+ await Promise.race([work, cutoff]);
4936
+ } finally {
4937
+ clearTimeout(timer);
4938
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
4939
+ }
4940
+ }
4941
+ //#endregion
4510
4942
  //#region src/features/FeatureModuleRegistry.ts
4511
4943
  /**
4512
4944
  * Manages the lifecycle of opt-in CLI feature modules.
@@ -6758,6 +7190,168 @@ function createBuiltinModules(config, apiClient) {
6758
7190
  return modules;
6759
7191
  }
6760
7192
  //#endregion
7193
+ //#region src/features/bridgePresence/peerOwner.ts
7194
+ const execFileAsync = promisify(execFile);
7195
+ const LSOF_TIMEOUT_MS = 2e3;
7196
+ const LSOF_CANDIDATES = ["/usr/sbin/lsof", "/usr/bin/lsof"];
7197
+ /**
7198
+ * Resolve the OS owner of the process holding the LISTEN socket on
7199
+ * `127.0.0.1:<port>`. This is the only pre-transmission trust signal the CLI
7200
+ * has over loopback TCP: the peer is whoever holds the port, so we confirm it
7201
+ * is the same UID as this process BEFORE handing it the bridge secret.
7202
+ *
7203
+ * Pure platform probe: the per-platform helpers classify every EXPECTED failure
7204
+ * into `no-listener`/`unknown` themselves. An UNEXPECTED throw (a bug) is left to
7205
+ * propagate so the CLI-layer caller can log it and fail closed - this module
7206
+ * deliberately does not depend on the CLI logger.
7207
+ */
7208
+ async function resolveLoopbackListenerOwner(port) {
7209
+ if (process.platform === "linux") return resolveViaProcNet(port);
7210
+ if (process.platform === "darwin") return resolveViaLsof(port);
7211
+ return { kind: "unknown" };
7212
+ }
7213
+ /**
7214
+ * Whether this platform + process can resolve loopback listener ownership at all:
7215
+ * there must be a `getuid` to compare against (Windows has none) AND an owner probe
7216
+ * for the platform. When false, the caller latches presence off rather than spinning
7217
+ * a retry loop forever on a check that can never pass (a `getuid`-bearing platform
7218
+ * with no probe - BSD/SunOS - would otherwise retry indefinitely).
7219
+ * MUST stay in sync with `resolveLoopbackListenerOwner`'s platform dispatch above:
7220
+ * a platform gains a probe there and a `true` here in the same change.
7221
+ */
7222
+ function canResolveLoopbackOwner() {
7223
+ if (typeof process.getuid !== "function") return false;
7224
+ return process.platform === "linux" || process.platform === "darwin";
7225
+ }
7226
+ async function resolveViaProcNet(port) {
7227
+ const uids = /* @__PURE__ */ new Set();
7228
+ let readAny = false;
7229
+ for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
7230
+ let text;
7231
+ try {
7232
+ text = await promises.readFile(file, "utf8");
7233
+ } catch {
7234
+ continue;
7235
+ }
7236
+ readAny = true;
7237
+ for (const uid of parseProcNetForUid(text, port)) uids.add(uid);
7238
+ }
7239
+ if (!readAny) return { kind: "unknown" };
7240
+ if (uids.size === 0) return { kind: "no-listener" };
7241
+ return uids.size === 1 ? {
7242
+ kind: "owner",
7243
+ uid: [...uids][0]
7244
+ } : { kind: "unknown" };
7245
+ }
7246
+ /**
7247
+ * `local_address` hex (upper-case) of a socket that receives connections to
7248
+ * `127.0.0.1`: loopback itself, the v4/v6 wildcard binds (a same-UID process
7249
+ * may legitimately hold `0.0.0.0`/`::`), and the v4-mapped-in-v6 forms. A row
7250
+ * on any other IP is a different socket that happens to share the port number
7251
+ * and must NOT be attributed to the loopback peer.
7252
+ *
7253
+ * `/proc/net/tcp6` prints each 32-bit word of the address little-endian (no
7254
+ * `ntohl`), which is why `127.0.0.1` reads `0100007F` and the mapped third word
7255
+ * `00 00 ff ff` reads `FFFF0000`.
7256
+ */
7257
+ const LOOPBACK_OR_WILDCARD_HEX = /* @__PURE__ */ new Set([
7258
+ "00000000",
7259
+ "0100007F",
7260
+ "00000000000000000000000000000000",
7261
+ "00000000000000000000000001000000",
7262
+ "0000000000000000FFFF000000000000",
7263
+ "0000000000000000FFFF00000100007F"
7264
+ ]);
7265
+ /**
7266
+ * Parse a `/proc/net/tcp`(6) dump for the UIDs of LISTEN sockets on the
7267
+ * loopback `port`. Row columns are whitespace-separated: sl(0)
7268
+ * local_address(1) rem_address(2) st(3) tx:rx(4) tr:when(5) retrnsmt(6)
7269
+ * uid(7). `local_address` is `HEXIP:HEXPORT`; `st` `0A` is TCP_LISTEN. The IP
7270
+ * must be loopback or wildcard, else a same-port listener on another interface
7271
+ * would be mis-attributed to the loopback peer. Returns every match so the
7272
+ * caller can reject an ambiguous set (two distinct owners) rather than
7273
+ * silently taking the first.
7274
+ */
7275
+ function parseProcNetForUid(text, port) {
7276
+ const uids = [];
7277
+ for (const line of text.split("\n")) {
7278
+ const cols = line.trim().split(/\s+/);
7279
+ if (cols.length < 8) continue;
7280
+ const local = cols[1];
7281
+ const colon = local.lastIndexOf(":");
7282
+ if (colon < 0) continue;
7283
+ if (parseInt(local.slice(colon + 1), 16) !== port) continue;
7284
+ if (cols[3] !== "0A") continue;
7285
+ if (!LOOPBACK_OR_WILDCARD_HEX.has(local.slice(0, colon).toUpperCase())) continue;
7286
+ const uid = Number(cols[7]);
7287
+ if (Number.isInteger(uid)) uids.push(uid);
7288
+ }
7289
+ return uids;
7290
+ }
7291
+ async function resolveViaLsof(port) {
7292
+ const lsof = await resolveLsofPath();
7293
+ if (!lsof) return { kind: "unknown" };
7294
+ let stdout;
7295
+ try {
7296
+ ({stdout} = await execFileAsync(lsof, [
7297
+ "-nP",
7298
+ `-iTCP:${port}`,
7299
+ "-sTCP:LISTEN",
7300
+ "-Fpun"
7301
+ ], { timeout: LSOF_TIMEOUT_MS }));
7302
+ } catch (err) {
7303
+ const e = err;
7304
+ if (!e.killed && e.code === 1 && !e.stdout) return { kind: "no-listener" };
7305
+ if (e.killed || typeof e.stdout !== "string") return { kind: "unknown" };
7306
+ stdout = e.stdout;
7307
+ }
7308
+ const uids = new Set(parseLsofForUid(stdout));
7309
+ if (uids.size === 0) return { kind: "no-listener" };
7310
+ return uids.size === 1 ? {
7311
+ kind: "owner",
7312
+ uid: [...uids][0]
7313
+ } : { kind: "unknown" };
7314
+ }
7315
+ let lsofPathCache;
7316
+ async function resolveLsofPath() {
7317
+ if (lsofPathCache !== void 0) return lsofPathCache;
7318
+ for (const candidate of LSOF_CANDIDATES) try {
7319
+ await promises.access(candidate, constants.X_OK);
7320
+ lsofPathCache = candidate;
7321
+ return candidate;
7322
+ } catch {}
7323
+ lsofPathCache = null;
7324
+ return null;
7325
+ }
7326
+ /** Does an `lsof -nP` numeric socket name (`n` field, host:port) receive
7327
+ * connections to `127.0.0.1`? Loopback, wildcard, or v4-mapped loopback -
7328
+ * exact, to stay symmetric with the linux matcher (the CLI only dials
7329
+ * `127.0.0.1`). */
7330
+ function isLoopbackLsofName(name) {
7331
+ return name.startsWith("127.0.0.1:") || name.startsWith("*:") || name.startsWith("[::1]:") || name.startsWith("[::]:") || name.startsWith("[::ffff:127.0.0.1]:");
7332
+ }
7333
+ /**
7334
+ * Parse `lsof -Fpun` output for the UIDs of loopback listeners. `-F` prints
7335
+ * one field per line prefixed by its type char; `u<uid>` is a process-level
7336
+ * field, `n<host:port>` a file-level one. `uid` resets on each `p<pid>` block
7337
+ * so a block without a `u` field can't inherit the prior block's owner. We
7338
+ * collect the UID for every loopback/wildcard name so the caller can reject an
7339
+ * ambiguous set rather than taking the first.
7340
+ */
7341
+ function parseLsofForUid(text) {
7342
+ const uids = [];
7343
+ let uid = null;
7344
+ for (const line of text.split("\n")) {
7345
+ const tag = line[0];
7346
+ if (tag === "p") uid = null;
7347
+ else if (tag === "u") {
7348
+ const parsed = Number(line.slice(1));
7349
+ uid = Number.isInteger(parsed) ? parsed : null;
7350
+ } else if (tag === "n" && uid !== null && isLoopbackLsofName(line.slice(1))) uids.push(uid);
7351
+ }
7352
+ return uids;
7353
+ }
7354
+ //#endregion
6761
7355
  //#region src/features/bridgePresence/BridgePresence.ts
6762
7356
  const DEFAULT_PORT = Number(process.env.CC_BRIDGE_PORT ?? 48732);
6763
7357
  const CONFIG_PATH = join(homedir(), ".b4m", "cc-bridge.json");
@@ -6783,11 +7377,15 @@ var BridgePresence = class {
6783
7377
  this.callbacks = {};
6784
7378
  this.started = false;
6785
7379
  this.stopped = false;
7380
+ this.generation = 0;
7381
+ this.trusted = false;
7382
+ this.wsConnectingGen = null;
6786
7383
  this.reconnectTimer = null;
6787
7384
  this.reconnectAttempts = 0;
6788
7385
  this.announceRetryTimer = null;
6789
7386
  this.announceAttempts = 0;
6790
7387
  this.startOpts = null;
7388
+ this.peerWarned = false;
6791
7389
  this.pendingWorkspaceName = null;
6792
7390
  this.pendingCapabilities = null;
6793
7391
  this.pendingSource = null;
@@ -6796,6 +7394,19 @@ var BridgePresence = class {
6796
7394
  setCallbacks(cbs) {
6797
7395
  this.callbacks = cbs;
6798
7396
  }
7397
+ /** True iff `gen` is still the live connection generation - the single spelling
7398
+ * of the ABA guard applied after every await (see `generation`). */
7399
+ isCurrent(gen) {
7400
+ return gen === this.generation;
7401
+ }
7402
+ /** Test-only: the generation currently holding the connectCommandWs in-flight
7403
+ * latch, or null when free. Exposed to pin the `finally` identity guard - a
7404
+ * stale generation's finally must not clear a live generation's latch (which
7405
+ * would let a second overlapping connect build an untracked socket). Not part
7406
+ * of the runtime contract. */
7407
+ get __wsConnectingGenForTests() {
7408
+ return this.wsConnectingGen;
7409
+ }
6799
7410
  /**
6800
7411
  * Probe the local bridge and, if present, announce this CLI session.
6801
7412
  * Returns true iff the announce succeeded (tavern presence is active).
@@ -6809,7 +7420,10 @@ var BridgePresence = class {
6809
7420
  if (this.started) return this.instanceId !== null;
6810
7421
  this.stopped = false;
6811
7422
  this.started = true;
7423
+ this.generation += 1;
7424
+ const gen = this.generation;
6812
7425
  const config = await readBridgeConfig();
7426
+ if (!this.isCurrent(gen)) return false;
6813
7427
  if (!config) {
6814
7428
  logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
6815
7429
  return false;
@@ -6821,34 +7435,119 @@ var BridgePresence = class {
6821
7435
  this.pendingSource = opts.source ?? "b4m-cli";
6822
7436
  return this.attemptAnnounce();
6823
7437
  }
7438
+ /**
7439
+ * Classify the process holding the bridge port relative to this CLI, BEFORE
7440
+ * any secret leaves the process. Over loopback TCP the peer is whoever holds
7441
+ * the port; the secret file is plaintext and per-user, so a same-UID peer is
7442
+ * already trusted (it could read the file directly), and a different-UID (or
7443
+ * sandboxed) peer is the adversary this gate stops.
7444
+ *
7445
+ * Four outcomes, so the caller warns only on a real trust failure (a foreign
7446
+ * owner) and stays quiet on the benign ones:
7447
+ * - `trusted` - same-UID owner; disclosure is safe.
7448
+ * - `absent` - no loopback listener yet (cc-bridge simply not
7449
+ * started); fail-closed but quiet - just retry.
7450
+ * - `foreign` - a different-UID owner holds the port; fail-closed AND
7451
+ * log the security-worded warning (the real adversary).
7452
+ * - `undeterminable` - ownership can't be resolved: lookup tool missing/hung,
7453
+ * a read/spawn error, or an ambiguous owner set.
7454
+ * Fail-closed, but a host that can't run the probe is not
7455
+ * an attacker - log at debug, not as the security warning.
7456
+ *
7457
+ * A platform that can't resolve ownership (see `canResolveLoopbackOwner`) is
7458
+ * latched off earlier, in `attemptAnnounce`, so this method is only reached
7459
+ * where `getuid` exists and the platform has an owner probe.
7460
+ */
7461
+ async checkPeerTrust() {
7462
+ const port = this.config?.port ?? DEFAULT_PORT;
7463
+ let owner;
7464
+ try {
7465
+ owner = await resolveLoopbackListenerOwner(port);
7466
+ } catch (err) {
7467
+ logger.debug(`[tavern] loopback owner lookup threw: ${err.message}`);
7468
+ return "undeterminable";
7469
+ }
7470
+ if (owner.kind === "no-listener") return "absent";
7471
+ if (owner.kind === "unknown") return "undeterminable";
7472
+ if (owner.uid !== process.getuid()) return "foreign";
7473
+ this.peerWarned = false;
7474
+ return "trusted";
7475
+ }
7476
+ /** Log the foreign-owner security warning once per untrusted state. */
7477
+ warnUntrustedPeerOnce() {
7478
+ if (this.peerWarned) return;
7479
+ this.peerWarned = true;
7480
+ logger.warn("[tavern] bridge port is held by a different user; not disclosing secret or handling commands");
7481
+ }
6824
7482
  /** One announce attempt. Schedules a retry on failure; wires up the
6825
7483
  * command WS + initial status on success. Idempotent: re-entering after
6826
7484
  * a successful announce short-circuits at the instanceId guard. */
6827
7485
  async attemptAnnounce() {
6828
7486
  if (this.stopped || !this.config || !this.startOpts) return false;
6829
7487
  if (this.instanceId) return true;
7488
+ if (!canResolveLoopbackOwner()) {
7489
+ logger.debug("[tavern] ownership unresolvable on this platform; tavern presence disabled");
7490
+ return false;
7491
+ }
7492
+ const gen = this.generation;
7493
+ const startOpts = this.startOpts;
7494
+ if (!await this.gateEgress(gen, "announce")) return false;
6830
7495
  const instanceId = v4();
6831
7496
  const workspaceName = this.pendingWorkspaceName;
6832
7497
  const capabilities = this.pendingCapabilities;
6833
7498
  const source = this.pendingSource;
6834
- if (!await this.announce({
7499
+ const announced = await this.announce({
6835
7500
  instanceId,
6836
7501
  source,
6837
7502
  workspaceName,
6838
- workspacePath: this.startOpts.workspacePath,
7503
+ workspacePath: startOpts.workspacePath,
6839
7504
  capabilities
6840
- })) {
7505
+ });
7506
+ if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
7507
+ if (!announced) {
7508
+ this.trusted = false;
6841
7509
  this.scheduleAnnounceRetry();
6842
7510
  return false;
6843
7511
  }
6844
7512
  this.instanceId = instanceId;
6845
7513
  this.announceAttempts = 0;
6846
7514
  logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
6847
- this.connectCommandWs();
7515
+ this.connectCommandWs().catch((err) => logger.debug(`[tavern] connectCommandWs threw: ${err.message}`));
6848
7516
  this.emitEvent({
6849
7517
  type: "status",
6850
7518
  status: "idle"
6851
- });
7519
+ }).catch(() => {});
7520
+ return true;
7521
+ }
7522
+ /** Signal the untrusted condition at the right volume: the security-worded
7523
+ * warning only for a real foreign owner, and a quiet debug line for the
7524
+ * benign cases (bridge not started yet, or ownership undeterminable) so we
7525
+ * don't cry wolf on every backed-off retry. */
7526
+ signalUntrusted(trust, phase) {
7527
+ if (trust === "foreign") this.warnUntrustedPeerOnce();
7528
+ else if (trust === "undeterminable") logger.debug(`[tavern] bridge port owner undeterminable; retrying ${phase}`);
7529
+ else logger.debug(`[tavern] cc-bridge not reachable yet; retrying ${phase}`);
7530
+ }
7531
+ /**
7532
+ * Run the pre-disclosure ownership gate for one egress phase and open or keep
7533
+ * the trust latch closed. The caller passes the connection `generation` it
7534
+ * captured BEFORE this await, so a stop()+start() cycle (or a bare stop())
7535
+ * landing during the probe is detected and this stale continuation bails.
7536
+ * Returns true iff the gate is open (secret disclosure is now permitted for
7537
+ * this generation); false means the caller must return - either the owner is
7538
+ * untrusted (a retry has been scheduled) or the generation moved.
7539
+ */
7540
+ async gateEgress(gen, phase) {
7541
+ const trust = await this.checkPeerTrust();
7542
+ if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
7543
+ if (trust !== "trusted") {
7544
+ this.trusted = false;
7545
+ this.signalUntrusted(trust, phase);
7546
+ if (phase === "announce") this.scheduleAnnounceRetry();
7547
+ else this.scheduleReconnect();
7548
+ return false;
7549
+ }
7550
+ this.trusted = true;
6852
7551
  return true;
6853
7552
  }
6854
7553
  scheduleAnnounceRetry() {
@@ -6857,17 +7556,22 @@ var BridgePresence = class {
6857
7556
  const delay = Math.min(1e3 * 2 ** (this.announceAttempts - 1), 3e4);
6858
7557
  this.announceRetryTimer = setTimeout(() => {
6859
7558
  this.announceRetryTimer = null;
6860
- this.attemptAnnounce();
7559
+ this.attemptAnnounce().catch((err) => logger.debug(`[tavern] announce retry threw: ${err.message}`));
6861
7560
  }, delay);
6862
7561
  }
6863
7562
  /** Emit an event for this session. No-op if the bridge isn't up. Events
6864
7563
  * leave in strict order - see `emitQueue` comment. */
6865
7564
  async emitEvent(event) {
6866
7565
  if (!this.config || !this.instanceId) return;
6867
- const task = () => this.post("/event", {
6868
- instanceId: this.instanceId,
6869
- event
6870
- }).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
7566
+ const instanceId = this.instanceId;
7567
+ const gen = this.generation;
7568
+ const task = () => {
7569
+ if (!this.isCurrent(gen)) return;
7570
+ return this.post("/event", {
7571
+ instanceId,
7572
+ event
7573
+ }).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
7574
+ };
6871
7575
  this.emitQueue = this.emitQueue.then(task, task);
6872
7576
  return this.emitQueue;
6873
7577
  }
@@ -6885,6 +7589,7 @@ var BridgePresence = class {
6885
7589
  async stop(reason = "cli_exit") {
6886
7590
  if (this.stopped || !this.started) return;
6887
7591
  this.stopped = true;
7592
+ this.generation += 1;
6888
7593
  if (this.reconnectTimer) {
6889
7594
  clearTimeout(this.reconnectTimer);
6890
7595
  this.reconnectTimer = null;
@@ -6893,13 +7598,14 @@ var BridgePresence = class {
6893
7598
  clearTimeout(this.announceRetryTimer);
6894
7599
  this.announceRetryTimer = null;
6895
7600
  }
7601
+ const wsWasLive = this.ws !== null;
6896
7602
  if (this.ws) {
6897
7603
  try {
6898
7604
  this.ws.close();
6899
7605
  } catch {}
6900
7606
  this.ws = null;
6901
7607
  }
6902
- if (this.config && this.instanceId) await this.post("/disconnect", {
7608
+ if (this.config && this.instanceId && wsWasLive) await this.post("/disconnect", {
6903
7609
  instanceId: this.instanceId,
6904
7610
  reason
6905
7611
  }).catch(() => {});
@@ -6912,6 +7618,8 @@ var BridgePresence = class {
6912
7618
  this.pendingSource = null;
6913
7619
  this.announceAttempts = 0;
6914
7620
  this.reconnectAttempts = 0;
7621
+ this.peerWarned = false;
7622
+ this.trusted = false;
6915
7623
  this.emitQueue = Promise.resolve();
6916
7624
  }
6917
7625
  async announce(body) {
@@ -6925,6 +7633,7 @@ var BridgePresence = class {
6925
7633
  }
6926
7634
  async post(path, body) {
6927
7635
  if (!this.config) throw new Error("bridge config not loaded");
7636
+ if (!this.trusted) throw new Error("bridge peer not trusted; refusing to disclose secret");
6928
7637
  const url = `http://127.0.0.1:${this.config.port ?? DEFAULT_PORT}${path}?secret=${encodeURIComponent(this.config.hookSecret)}`;
6929
7638
  const res = await fetch(url, {
6930
7639
  method: "POST",
@@ -6934,42 +7643,58 @@ var BridgePresence = class {
6934
7643
  });
6935
7644
  if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
6936
7645
  }
6937
- connectCommandWs() {
7646
+ async connectCommandWs() {
6938
7647
  if (this.stopped || !this.config || !this.instanceId) return;
6939
- const url = `ws://127.0.0.1:${this.config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(this.instanceId)}&secret=${encodeURIComponent(this.config.hookSecret)}`;
6940
- let ws;
7648
+ const gen = this.generation;
7649
+ if (this.ws || this.wsConnectingGen === gen) return;
7650
+ this.wsConnectingGen = gen;
6941
7651
  try {
6942
- ws = new WebSocket(url);
6943
- } catch (err) {
6944
- logger.debug(`[tavern] command WS construct failed: ${err.message}`);
6945
- this.scheduleReconnect();
6946
- return;
6947
- }
6948
- this.ws = ws;
6949
- ws.on("open", () => {
6950
- this.reconnectAttempts = 0;
6951
- logger.debug("[tavern] command WS open");
6952
- });
6953
- ws.on("message", (raw) => {
6954
- let frame = null;
7652
+ if (!await this.gateEgress(gen, "command WS")) return;
7653
+ const config = this.config;
7654
+ const instanceId = this.instanceId;
7655
+ if (!config || !instanceId) return;
7656
+ const url = `ws://127.0.0.1:${config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(instanceId)}&secret=${encodeURIComponent(config.hookSecret)}`;
7657
+ let ws;
6955
7658
  try {
6956
- frame = JSON.parse(raw.toString());
6957
- } catch {
6958
- logger.debug("[tavern] malformed command frame; ignored");
7659
+ ws = new WebSocket(url);
7660
+ } catch (err) {
7661
+ logger.debug(`[tavern] command WS construct failed: ${err.message}`);
7662
+ this.trusted = false;
7663
+ this.scheduleReconnect();
6959
7664
  return;
6960
7665
  }
6961
- if (!frame?.command) return;
6962
- this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
6963
- });
6964
- ws.on("close", () => {
6965
- this.ws = null;
6966
- if (this.stopped) return;
6967
- logger.debug("[tavern] command WS closed; reconnecting");
6968
- this.scheduleReconnect();
6969
- });
6970
- ws.on("error", (err) => {
6971
- logger.debug(`[tavern] command WS error: ${err.message}`);
6972
- });
7666
+ this.ws = ws;
7667
+ ws.on("open", () => {
7668
+ if (this.ws !== ws) return;
7669
+ this.reconnectAttempts = 0;
7670
+ logger.debug("[tavern] command WS open");
7671
+ });
7672
+ ws.on("message", (raw) => {
7673
+ if (this.ws !== ws) return;
7674
+ let frame = null;
7675
+ try {
7676
+ frame = JSON.parse(raw.toString());
7677
+ } catch {
7678
+ logger.debug("[tavern] malformed command frame; ignored");
7679
+ return;
7680
+ }
7681
+ if (!frame?.command) return;
7682
+ this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
7683
+ });
7684
+ ws.on("close", () => {
7685
+ if (this.ws !== ws) return;
7686
+ this.ws = null;
7687
+ this.trusted = false;
7688
+ if (this.stopped) return;
7689
+ logger.debug("[tavern] command WS closed; reconnecting");
7690
+ this.scheduleReconnect();
7691
+ });
7692
+ ws.on("error", (err) => {
7693
+ logger.debug(`[tavern] command WS error: ${err.message}`);
7694
+ });
7695
+ } finally {
7696
+ if (this.wsConnectingGen === gen) this.wsConnectingGen = null;
7697
+ }
6973
7698
  }
6974
7699
  scheduleReconnect() {
6975
7700
  if (this.stopped || this.reconnectTimer) return;
@@ -6977,10 +7702,11 @@ var BridgePresence = class {
6977
7702
  const delay = Math.min(500 * 2 ** (this.reconnectAttempts - 1), 1e4);
6978
7703
  this.reconnectTimer = setTimeout(() => {
6979
7704
  this.reconnectTimer = null;
6980
- this.connectCommandWs();
7705
+ this.connectCommandWs().catch((err) => logger.debug(`[tavern] command WS reconnect threw: ${err.message}`));
6981
7706
  }, delay);
6982
7707
  }
6983
7708
  async dispatchCommand(command) {
7709
+ if (this.stopped) return;
6984
7710
  switch (command.type) {
6985
7711
  case "send_prompt":
6986
7712
  if (this.callbacks.onSendPrompt) await this.callbacks.onSendPrompt(command.text);
@@ -7130,7 +7856,7 @@ const infoCommands = [
7130
7856
  {
7131
7857
  name: "help",
7132
7858
  run: (_args, ctx) => {
7133
- const customCommands = ctx.customCommandStore.getAllCommands();
7859
+ const customCommands = ctx.customCommandStore.getModelReachableCommands();
7134
7860
  const hasCustomCommands = customCommands.length > 0;
7135
7861
  console.log(`
7136
7862
  Available commands:
@@ -7321,6 +8047,48 @@ async function dispatch(name, args, ctx) {
7321
8047
  return true;
7322
8048
  }
7323
8049
  //#endregion
8050
+ //#region src/commands/sandboxNetworkCommand.ts
8051
+ async function handleSandboxNetworkCommand(deps, arg) {
8052
+ const { orchestrator, configStore, permissionManager } = deps;
8053
+ if (!orchestrator) return "Sandbox not initialized";
8054
+ if (arg !== "on" && arg !== "off") return "Usage: /sandbox:network <on|off>";
8055
+ const enable = arg === "on";
8056
+ if (enable && !orchestrator.isAvailable()) return "Sandbox runtime not available on this platform";
8057
+ const active = await orchestrator.setNetworkEnabled(enable);
8058
+ if (enable && !active) {
8059
+ permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
8060
+ await configStore.saveSandboxConfig(orchestrator.getConfig());
8061
+ return "Failed to start the network proxy; network stays disabled (fail-closed).";
8062
+ }
8063
+ permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
8064
+ await configStore.saveSandboxConfig(orchestrator.getConfig());
8065
+ if (!enable) return "Sandbox network disabled (fail-closed)";
8066
+ const port = orchestrator.getProxyManager()?.getPort();
8067
+ return `Sandbox network egress enabled${port ? ` (proxy-aware clients filtered on port ${port})` : ""}`;
8068
+ }
8069
+ //#endregion
8070
+ //#region src/commands/sandboxTrustDomainCommand.ts
8071
+ async function handleSandboxTrustDomainCommand(deps, domains) {
8072
+ const { orchestrator, configStore } = deps;
8073
+ if (!orchestrator) return "Sandbox not initialized";
8074
+ const clean = domains.map((d) => d.trim()).filter(Boolean);
8075
+ if (clean.length === 0) return "Usage: /sandbox:trust-domain <domain> [...]";
8076
+ if (!orchestrator.getProxyManager()) return "Network proxy not initialized";
8077
+ const lines = [];
8078
+ for (const domain of clean) {
8079
+ orchestrator.addAllowedDomain(domain);
8080
+ lines.push(` Added: ${domain}`);
8081
+ }
8082
+ try {
8083
+ await configStore.saveSandboxConfig(orchestrator.getConfig());
8084
+ } catch (err) {
8085
+ const msg = err instanceof Error ? err.message : String(err);
8086
+ return `${lines.join("\n")}\nWarning: failed to persist the trusted domains (${msg}); they apply for this session only.`;
8087
+ }
8088
+ lines.push(`Trusted ${clean.length} domain(s)`);
8089
+ return lines.join("\n");
8090
+ }
8091
+ //#endregion
7324
8092
  //#region src/session/turnController.ts
7325
8093
  /**
7326
8094
  * Turn lifecycle (issue #228, phase 2).
@@ -7339,7 +8107,7 @@ async function dispatch(name, args, ctx) {
7339
8107
  * recurses with the same context for messages submitted mid-turn.
7340
8108
  */
7341
8109
  async function runTurn(message, ctx) {
7342
- const { agent, sessionStore, configStore, commandHistoryStore, customCommandStore, messageBuilder, config, availableModels, agentStore, contextContent, additionalDirectories, featureRegistry, backgroundManager, todoStore, decisionStore, blockerStore, workflowStores, setCommandHistory, setAbortController } = ctx;
8110
+ const { agent, sessionStore, configStore, commandHistoryStore, customCommandStore, messageBuilder, config, availableModels, agentStore, contextContent, additionalDirectories, featureRegistry, backgroundManager, todoStore, decisionStore, blockerStore, postEditDiagnostics, workflowStores, setCommandHistory, setAbortController } = ctx;
7343
8111
  const storeSession = useCliStore.getState().session;
7344
8112
  if (!agent || !storeSession) {
7345
8113
  console.error("❌ CLI failed to initialize. Try restarting b4m.\n");
@@ -7365,7 +8133,7 @@ async function runTurn(message, ctx) {
7365
8133
  const systemPrompt = buildSystemPrompt(config?.preferences.promptVariant ?? "current", {
7366
8134
  contextContent,
7367
8135
  agentStore: agentStore || void 0,
7368
- customCommands: customCommandStore.getAllCommands(),
8136
+ customCommands: customCommandStore.getModelReachableCommands(),
7369
8137
  enableSkillTool: config?.preferences.enableSkillTool !== false,
7370
8138
  additionalDirectories,
7371
8139
  featureModulePrompts: featureRegistry?.getSystemPromptSections() || void 0,
@@ -7444,6 +8212,8 @@ async function runTurn(message, ctx) {
7444
8212
  if (elided > 0) logger.debug(`[workflowReminder] elided ${elided} item(s) to fit the token cap`);
7445
8213
  return text;
7446
8214
  } : void 0;
8215
+ const diagnostics = cliConfig.preferences.postEditDiagnostics === true ? postEditDiagnostics : null;
8216
+ diagnostics?.beginTurn();
7447
8217
  let result;
7448
8218
  try {
7449
8219
  result = await agent.run(messageContent, {
@@ -7452,9 +8222,11 @@ async function runTurn(message, ctx) {
7452
8222
  parallelExecution: cliConfig.preferences.enableParallelToolExecution === true,
7453
8223
  isReadOnlyTool,
7454
8224
  workflowReminder,
8225
+ drainFeedback: diagnostics ? (phase) => diagnostics.drain(DRAIN_BUDGET_MS[phase], abortController.signal) : void 0,
7455
8226
  onContextLimit: createReactiveCompactionHandler(agent, activeSession, 1 + previousMessages.length + 1)
7456
8227
  });
7457
8228
  } finally {
8229
+ diagnostics?.endTurn();
7458
8230
  backgroundManager?.setCurrentTurn(null);
7459
8231
  }
7460
8232
  const permissionDenied = result.finalAnswer.startsWith("Permission denied for tool");
@@ -7755,6 +8527,22 @@ async function rewindSession(ctx, messageIndex) {
7755
8527
  return { prefill: prefillContent };
7756
8528
  }
7757
8529
  //#endregion
8530
+ //#region src/session/modelOverride.ts
8531
+ async function runWithModelOverride(ctx, overrideModel, run) {
8532
+ const originalModel = useCliStore.getState().session?.model;
8533
+ ctx.applyModel(overrideModel);
8534
+ try {
8535
+ return await run();
8536
+ } finally {
8537
+ if (originalModel) await restoreModel(ctx, originalModel);
8538
+ }
8539
+ }
8540
+ async function restoreModel(ctx, originalModel) {
8541
+ ctx.applyModel(originalModel);
8542
+ const restoredSession = useCliStore.getState().session;
8543
+ if (restoredSession) await ctx.sessionStore.save(restoredSession);
8544
+ }
8545
+ //#endregion
7758
8546
  //#region src/index.tsx
7759
8547
  process.removeAllListeners("warning");
7760
8548
  process.on("warning", (warning) => {
@@ -7854,6 +8642,7 @@ function CliApp() {
7854
8642
  permissionPrompt: null,
7855
8643
  trustLocationSelector: null,
7856
8644
  folderTrustPrompt: null,
8645
+ mcpApprovalPrompt: null,
7857
8646
  rewindSelector: null,
7858
8647
  sessionSelector: null,
7859
8648
  orchestrator: null,
@@ -7873,10 +8662,12 @@ function CliApp() {
7873
8662
  const [commandHistory, setCommandHistory] = useState([]);
7874
8663
  const imageStoreInitPromise = useRef(null);
7875
8664
  const folderTrustResolvedRef = useRef(false);
8665
+ const mcpApprovalResolvedRef = useRef(false);
7876
8666
  const todoStoreRef = useRef(createTodoStore());
7877
8667
  const decisionStoreRef = useRef(createDecisionStore());
7878
8668
  const blockerStoreRef = useRef(createBlockerStore());
7879
8669
  const reviewGateStoreRef = useRef(createReviewGateStore());
8670
+ const postEditDiagnosticsRef = useRef(new PostEditDiagnostics({ workspaceRoot: process.cwd() }));
7880
8671
  const setStoreSession = useCliStore((state) => state.setSession);
7881
8672
  const enqueuePermissionPrompt = useCliStore((state) => state.enqueuePermissionPrompt);
7882
8673
  const enqueueUserQuestionPrompt = useCliStore((state) => state.enqueueUserQuestionPrompt);
@@ -7988,6 +8779,20 @@ function CliApp() {
7988
8779
  }));
7989
8780
  return;
7990
8781
  }
8782
+ const pendingMcp = state.configStore.getPendingMcpApprovals();
8783
+ if (pendingMcp.length > 0 && !mcpApprovalResolvedRef.current && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
8784
+ mcpApprovalResolvedRef.current = true;
8785
+ setState((prev) => ({
8786
+ ...prev,
8787
+ mcpApprovalPrompt: {
8788
+ projectRoot: state.configStore.getProjectRealPath() ?? process.cwd(),
8789
+ servers: pendingMcp
8790
+ },
8791
+ config
8792
+ }));
8793
+ return;
8794
+ }
8795
+ if (!mcpApprovalResolvedRef.current) state.configStore.warnPendingMcpApprovals();
7991
8796
  const configDirs = await state.configStore.getAdditionalDirectories();
7992
8797
  const flagDirs = process.env.B4M_ADDITIONAL_DIRS ? JSON.parse(process.env.B4M_ADDITIONAL_DIRS) : [];
7993
8798
  const additionalDirectories = [.../* @__PURE__ */ new Set([...configDirs, ...flagDirs])];
@@ -8050,6 +8855,14 @@ function CliApp() {
8050
8855
  });
8051
8856
  const pinnedSessionId = process.env.B4M_SESSION_ID;
8052
8857
  const resumeSessionId = process.env.B4M_RESUME_ID;
8858
+ if (pinnedSessionId && !isValidSessionId(pinnedSessionId)) {
8859
+ console.error(`Invalid session id (--session-id / B4M_SESSION_ID): must match ${SESSION_ID_PATTERN.source}`);
8860
+ process.exit(1);
8861
+ }
8862
+ if (resumeSessionId && !isValidSessionId(resumeSessionId)) {
8863
+ console.error(`Invalid session id (--resume / B4M_RESUME_ID): must match ${SESSION_ID_PATTERN.source}`);
8864
+ process.exit(1);
8865
+ }
8053
8866
  let newSession;
8054
8867
  if (resumeSessionId) {
8055
8868
  const resumed = await state.sessionStore.load(resumeSessionId);
@@ -8164,7 +8977,8 @@ function CliApp() {
8164
8977
  };
8165
8978
  const agentContext = {
8166
8979
  currentAgent: null,
8167
- observationQueue: []
8980
+ observationQueue: [],
8981
+ onFileChanged: (filePath) => postEditDiagnosticsRef.current.enqueue(filePath)
8168
8982
  };
8169
8983
  const { mcpManager, agentStore, contextResult, mcpTools, loadedB4mTools, deferredB4mTools, orchestrator, backgroundManager, historyStore } = await buildSupportingStores({
8170
8984
  config,
@@ -8234,8 +9048,10 @@ function CliApp() {
8234
9048
  customCommandStore: state.customCommandStore,
8235
9049
  subagentOrchestrator: orchestrator,
8236
9050
  sessionId: newSession.id,
8237
- permissionManager,
8238
- promptFn,
9051
+ permission: {
9052
+ permissionManager,
9053
+ promptFn
9054
+ },
8239
9055
  allowedDirectories: additionalDirectories
8240
9056
  })], cliWrapDeps)[0] : null;
8241
9057
  const findDefinitionTool = wrapTools([createFindDefinitionTool(additionalDirectories)], cliWrapDeps)[0];
@@ -8267,6 +9083,7 @@ function CliApp() {
8267
9083
  logger,
8268
9084
  reservedToolNames: [...loadedB4mTools, ...cliTools].map((t) => t.toolSchema.name).concat("tool_search")
8269
9085
  });
9086
+ rewireReservedNames(state, featureRegistry);
8270
9087
  const featureModuleToolNames = featureRegistry.getAllToolNames();
8271
9088
  if (featureModuleToolNames.length > 0) registerFeatureModuleTools(featureModuleToolNames);
8272
9089
  if (wsManager && featureRegistry.hasModules) featureRegistry.registerAllWsHandlers(wsManager);
@@ -8671,6 +9488,7 @@ function CliApp() {
8671
9488
  todoStore: todoStoreRef.current,
8672
9489
  decisionStore: decisionStoreRef.current,
8673
9490
  blockerStore: blockerStoreRef.current,
9491
+ postEditDiagnostics: postEditDiagnosticsRef.current,
8674
9492
  workflowStores: {
8675
9493
  decisionStore: decisionStoreRef.current,
8676
9494
  blockerStore: blockerStoreRef.current,
@@ -9109,7 +9927,7 @@ function CliApp() {
9109
9927
  console.log(fellBack ? "✅ Session saved with local fallback handoff" : "✅ Session saved with refreshed handoff");
9110
9928
  };
9111
9929
  const handleCommand = async (command, args) => {
9112
- const customCommand = state.customCommandStore.getCommand(command);
9930
+ const customCommand = state.customCommandStore.getModelReachableCommand(command);
9113
9931
  if (customCommand) try {
9114
9932
  const sourceIcon = customCommand.source === "global" ? "🏠" : "📁";
9115
9933
  console.log(`${sourceIcon} Executing custom command: /${command}`);
@@ -9140,11 +9958,10 @@ function CliApp() {
9140
9958
  }
9141
9959
  if (customCommand.model && state.agent) {
9142
9960
  console.log(`🔄 Using model override: ${customCommand.model}`);
9143
- const overrideSession = useCliStore.getState().session;
9144
- const originalModel = overrideSession?.model;
9145
- if (overrideSession) overrideSession.model = customCommand.model;
9146
- await handleCustomCommandMessage(substitutedBody, displayMessage);
9147
- if (overrideSession && originalModel) overrideSession.model = originalModel;
9961
+ await runWithModelOverride({
9962
+ applyModel: applyModelToSession,
9963
+ sessionStore: state.sessionStore
9964
+ }, customCommand.model, () => handleCustomCommandMessage(substitutedBody, displayMessage));
9148
9965
  } else {
9149
9966
  console.log("🤖 Sending to agent...\n");
9150
9967
  await handleCustomCommandMessage(substitutedBody, displayMessage);
@@ -9661,7 +10478,7 @@ function CliApp() {
9661
10478
  const variantForCount = state.config?.preferences.promptVariant ?? "current";
9662
10479
  const corePromptTokens = tokenCounter.countTokens(buildSystemPrompt(variantForCount));
9663
10480
  const projectContextTokens = state.contextContent ? tokenCounter.countTokens(state.contextContent) : 0;
9664
- const commands = state.customCommandStore.getAllCommands();
10481
+ const commands = state.customCommandStore.getModelReachableCommands();
9665
10482
  const skillsSection = buildSkillsPromptSection(commands);
9666
10483
  const skillsTokens = skillsSection ? tokenCounter.countTokens(skillsSection) : 0;
9667
10484
  const agentDirectoryTokens = state.agentStore ? tokenCounter.countTokens(state.agentStore.getDirectoryContext()) : 0;
@@ -9753,9 +10570,9 @@ function CliApp() {
9753
10570
  break;
9754
10571
  }
9755
10572
  case "commands": {
9756
- const customCommands = state.customCommandStore.getAllCommands();
9757
- const globalCommands = state.customCommandStore.getCommandsBySource("global");
9758
- const projectCommands = state.customCommandStore.getCommandsBySource("project");
10573
+ const customCommands = state.customCommandStore.getModelReachableCommands();
10574
+ const globalCommands = customCommands.filter((cmd) => cmd.source === "global");
10575
+ const projectCommands = customCommands.filter((cmd) => cmd.source === "project");
9759
10576
  console.log("\n📝 Custom Commands:\n");
9760
10577
  if (customCommands.length === 0) {
9761
10578
  console.log("No custom commands found.");
@@ -9937,9 +10754,10 @@ function CliApp() {
9937
10754
  state.sandboxOrchestrator.setMode("auto-allow");
9938
10755
  state.permissionManager?.setSandboxState("auto-allow", state.sandboxOrchestrator.isActive());
9939
10756
  if (state.sandboxOrchestrator.getConfig().network.enabled) {
9940
- await state.sandboxOrchestrator.startProxy();
10757
+ const on = await state.sandboxOrchestrator.setNetworkEnabled(true);
9941
10758
  const pm = state.sandboxOrchestrator.getProxyManager();
9942
- if (pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
10759
+ if (on && pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
10760
+ else console.log("⚠️ Network filtering requested but the proxy failed to start; egress disabled");
9943
10761
  }
9944
10762
  await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
9945
10763
  console.log("Sandbox enabled (auto-allow mode)");
@@ -9949,7 +10767,7 @@ function CliApp() {
9949
10767
  console.log("Sandbox not initialized");
9950
10768
  break;
9951
10769
  }
9952
- await state.sandboxOrchestrator.stopProxy();
10770
+ await state.sandboxOrchestrator.setNetworkEnabled(false);
9953
10771
  state.sandboxOrchestrator.setMode("disabled");
9954
10772
  state.permissionManager?.setSandboxState("disabled", false);
9955
10773
  await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
@@ -9975,35 +10793,19 @@ function CliApp() {
9975
10793
  console.log(`Sandbox mode set to: ${modeArg}`);
9976
10794
  break;
9977
10795
  }
9978
- case "sandbox:trust-domain": {
9979
- if (!state.sandboxOrchestrator) {
9980
- console.log("Sandbox not initialized");
9981
- break;
9982
- }
9983
- if (args.length === 0) {
9984
- console.log("Usage: /sandbox:trust-domain <domain> [...]");
9985
- break;
9986
- }
9987
- const proxyMgr = state.sandboxOrchestrator.getProxyManager();
9988
- if (!proxyMgr) {
9989
- console.log("Network proxy not initialized");
9990
- break;
9991
- }
9992
- for (const domain of args) {
9993
- proxyMgr.addAllowedDomain(domain);
9994
- console.log(` Added: ${domain}`);
9995
- }
9996
- const currentSandboxConfig = state.sandboxOrchestrator.getConfig();
9997
- await state.configStore.saveSandboxConfig({
9998
- ...currentSandboxConfig,
9999
- network: {
10000
- ...currentSandboxConfig.network,
10001
- allowedDomains: proxyMgr.getAllowedDomains()
10002
- }
10003
- });
10004
- console.log(`Trusted ${args.length} domain(s)`);
10796
+ case "sandbox:network":
10797
+ console.log(await handleSandboxNetworkCommand({
10798
+ orchestrator: state.sandboxOrchestrator,
10799
+ configStore: state.configStore,
10800
+ permissionManager: state.permissionManager
10801
+ }, args[0]));
10802
+ break;
10803
+ case "sandbox:trust-domain":
10804
+ console.log(await handleSandboxTrustDomainCommand({
10805
+ orchestrator: state.sandboxOrchestrator,
10806
+ configStore: state.configStore
10807
+ }, args));
10005
10808
  break;
10006
- }
10007
10809
  case "sandbox:domains": {
10008
10810
  if (!state.sandboxOrchestrator) {
10009
10811
  console.log("Sandbox not initialized");
@@ -10195,7 +10997,9 @@ function CliApp() {
10195
10997
  logger,
10196
10998
  reservedToolNames: baseTools.map((t) => t.toolSchema.name)
10197
10999
  });
10198
- newFeatureRegistry = rebuilt.registry;
11000
+ const rebuiltRegistry = rebuilt.registry;
11001
+ newFeatureRegistry = rebuiltRegistry;
11002
+ rewireReservedNames(state, rebuiltRegistry);
10199
11003
  for (const skippedPlugin of rebuilt.skipped) console.error(`\n\x1b[33m⚠️ Plugin ${skippedPlugin.name} skipped: ${skippedPlugin.reason}\x1b[0m`);
10200
11004
  const newToolNames = newFeatureRegistry.getAllToolNames();
10201
11005
  if (newToolNames.length > 0) registerFeatureModuleTools(newToolNames);
@@ -10209,7 +11013,7 @@ function CliApp() {
10209
11013
  state.agent.setSystemPrompt(buildSystemPrompt(updatedConfig.preferences.promptVariant ?? "current", {
10210
11014
  contextContent: state.contextContent,
10211
11015
  agentStore: state.agentStore || void 0,
10212
- customCommands: state.customCommandStore.getAllCommands(),
11016
+ customCommands: state.customCommandStore.getModelReachableCommands(),
10213
11017
  enableSkillTool: updatedConfig.preferences.enableSkillTool !== false,
10214
11018
  enableDynamicAgentCreation: updatedConfig.preferences.enableDynamicAgentCreation === true,
10215
11019
  additionalDirectories: state.additionalDirectories,
@@ -10320,6 +11124,34 @@ function CliApp() {
10320
11124
  })();
10321
11125
  }
10322
11126
  });
11127
+ if (state.mcpApprovalPrompt) {
11128
+ const { projectRoot, servers } = state.mcpApprovalPrompt;
11129
+ return /* @__PURE__ */ React.createElement(McpApprovalPrompt, {
11130
+ projectRoot,
11131
+ servers,
11132
+ onSelect: (choice) => {
11133
+ (async () => {
11134
+ try {
11135
+ if (choice === "approve") {
11136
+ await state.configStore.approveMcpServers(servers);
11137
+ console.log("\nApproved. Starting these MCP servers; a changed definition will ask again.\n");
11138
+ } else console.log("\nThese MCP servers stay off this session. You will be asked again next launch.\n");
11139
+ } catch (err) {
11140
+ console.error("\nCould not save MCP approval:", err instanceof Error ? err.message : String(err), "\n");
11141
+ } finally {
11142
+ setState((prev) => ({
11143
+ ...prev,
11144
+ mcpApprovalPrompt: null
11145
+ }));
11146
+ init().catch((err) => {
11147
+ console.error("\nInitialization failed:", err instanceof Error ? err.message : String(err), "\n");
11148
+ exit();
11149
+ });
11150
+ }
11151
+ })();
11152
+ }
11153
+ });
11154
+ }
10323
11155
  const currentSession = useCliStore.getState().session;
10324
11156
  if (state.rewindSelector && currentSession) return /* @__PURE__ */ React.createElement(RewindSelector, {
10325
11157
  messages: currentSession.messages,