@bike4mind/cli 1.2.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.
package/dist/index.mjs CHANGED
@@ -1,13 +1,13 @@
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 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-B2NEOvSW.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 { C as CREDIT_DEDUCT_TRANSACTION_TYPES, F as REVIEW_GATE_STATUS_VALUES, K as isEarlyStop, L as SUBQUEST_STATUS_VALUES, c as SESSION_ID_PATTERN, d as LOCAL_DEV_URL, et as ACTOR_COLOR_SLOTS, f as getCreditsUrl, g as resolveApiEndpoint, h as requireApiUrl, it as selfClaimedActorKindSchema, l as isValidSessionId, m as parseApiUrl, n as logger, nt as actorKindMarker, p as getEnvironmentName, r as extractCompactInstructions, rt as actorKindSchema, s as isReadOnlyTool, t as ConfigStore, tt as actorColorIndex, u as ApiEndpointUnconfiguredError, w as ChatModels } from "./ConfigStore-qV7NrCgZ.mjs";
6
- import { a as deferredToolRegistry, i as createToolSearchTool, n as buildSupportingStores, o as NotifyingLlmBackend, r as buildLlmBackend, t as buildAgent } from "./buildAgent-C-C-VGff.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";
7
7
  import { t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
8
- import { n as OAuthClient, t as ApiClient } from "./ApiClient-D0fQ2FT6.mjs";
8
+ import { i as OAuthClient, t as ApiClient } from "./ApiClient-BTI0-m0g.mjs";
9
+ import { t as version } from "./package-Dl_F2Fah.mjs";
9
10
  import { i as getTokenCounter, n as ConversationContext, r as reconstructTurnBlocks, t as WorkItemsClient } from "./WorkItemsClient-Cow6nXx7.mjs";
10
- import { t as version } from "./package-CGZIoxcs.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";
@@ -1085,12 +1085,15 @@ function CompletedGroupNotification() {
1085
1085
  //#endregion
1086
1086
  //#region src/components/PermissionPrompt.tsx
1087
1087
  /**
1088
- * Escape characters that let model-authored text render differently from what
1089
- * actually runs, into a visible escape form. The preview and arguments are
1090
- * model-authored, so the approved text could otherwise differ from what runs.
1091
- * Two ranges are covered, deliberately and no wider:
1092
- * - C0 controls (minus tab/newline) and DEL -> `\xHH`: a raw `\r` or ESC
1093
- * sequence could rewrite the line the user is approving.
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.
1094
1097
  * - Unicode bidi overrides U+202A..U+202E and isolates U+2066..U+2069
1095
1098
  * -> `\uXXXX`: these reorder displayed glyphs without changing the
1096
1099
  * underlying code points, spoofing the same way.
@@ -1098,7 +1101,7 @@ function CompletedGroupNotification() {
1098
1101
  * Widen the scope here, so both call sites gain it at once.
1099
1102
  */
1100
1103
  function escapeTerminalControlChars(text) {
1101
- return text.replace(/[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, (ch) => {
1104
+ return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, (ch) => {
1102
1105
  const code = ch.charCodeAt(0);
1103
1106
  return code > 255 ? `\\u${code.toString(16).padStart(4, "0")}` : `\\x${code.toString(16).padStart(2, "0")}`;
1104
1107
  });
@@ -1232,7 +1235,7 @@ function PermissionPrompt({ toolName, toolDescription, args, preview, canBeTrust
1232
1235
  }, headerText)), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Tool: "), /* @__PURE__ */ React.createElement(Text, {
1233
1236
  bold: true,
1234
1237
  color: "cyan"
1235
- }, 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, {
1236
1239
  marginTop: 1,
1237
1240
  flexDirection: "column"
1238
1241
  }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Arguments:"), /* @__PURE__ */ React.createElement(Box, {
@@ -1408,7 +1411,7 @@ function UserQuestionPrompt({ payload, onResponse }) {
1408
1411
  }, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, {
1409
1412
  bold: true,
1410
1413
  color: "cyan"
1411
- }, "? 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, {
1412
1415
  marginTop: 1,
1413
1416
  flexDirection: "column"
1414
1417
  }, options.map((opt, idx) => {
@@ -1418,12 +1421,12 @@ function UserQuestionPrompt({ payload, onResponse }) {
1418
1421
  return /* @__PURE__ */ React.createElement(Box, { key: idx }, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, idx + 1, "."), /* @__PURE__ */ React.createElement(Text, {
1419
1422
  color: isHighlighted ? "cyan" : void 0,
1420
1423
  bold: isHighlighted
1421
- }, " ", 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, {
1422
1425
  value: otherText,
1423
1426
  onChange: setOtherText,
1424
1427
  onSubmit: submitOther,
1425
1428
  placeholder: "Type your answer..."
1426
- })) : 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)));
1427
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")));
1428
1431
  }
1429
1432
  //#endregion
@@ -1526,10 +1529,10 @@ function ReviewGatePrompt({ description, options, recommendation, onResponse })
1526
1529
  }, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, {
1527
1530
  bold: true,
1528
1531
  color: "magenta"
1529
- }, "🛑 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, {
1530
1533
  marginTop: 1,
1531
1534
  flexDirection: "column"
1532
- }, /* @__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, {
1533
1536
  marginTop: 1,
1534
1537
  flexDirection: "column"
1535
1538
  }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Options:"), /* @__PURE__ */ React.createElement(Box, {
@@ -1538,7 +1541,7 @@ function ReviewGatePrompt({ description, options, recommendation, onResponse })
1538
1541
  }, options.map((opt, idx) => /* @__PURE__ */ React.createElement(Text, {
1539
1542
  key: idx,
1540
1543
  dimColor: true
1541
- }, "• ", opt)))), /* @__PURE__ */ React.createElement(Box, {
1544
+ }, "• ", escapeTerminalControlChars(opt))))), /* @__PURE__ */ React.createElement(Box, {
1542
1545
  marginTop: 1,
1543
1546
  flexDirection: "column"
1544
1547
  }, ITEMS.map((item, index) => {
@@ -1816,6 +1819,18 @@ function buildConfigItems(availableModels, pluginDescriptors = []) {
1816
1819
  enableCoordinatorMode: value
1817
1820
  }
1818
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
+ })
1819
1834
  }, {
1820
1835
  key: "featuresTavern",
1821
1836
  label: "Tavern",
@@ -2697,7 +2712,7 @@ function FolderTrustPrompt({ projectRoot, onSelect }) {
2697
2712
  }, /* @__PURE__ */ React.createElement(Text, {
2698
2713
  bold: true,
2699
2714
  color: "yellow"
2700
- }, "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, {
2701
2716
  items: [{
2702
2717
  label: "Not now - keep repo config inert this session",
2703
2718
  value: "not-now"
@@ -2710,6 +2725,52 @@ function FolderTrustPrompt({ projectRoot, onSelect }) {
2710
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.")));
2711
2726
  }
2712
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
2713
2774
  //#region src/components/RewindSelector.tsx
2714
2775
  function RewindSelector({ messages, onSelect, onCancel }) {
2715
2776
  const [step, setStep] = useState("selection");
@@ -2952,6 +3013,37 @@ function isBrowserOpenableUrl(raw) {
2952
3013
  if (url.protocol === "https:") return true;
2953
3014
  return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
2954
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
+ }
2955
3047
  function extractErrorMessage(err) {
2956
3048
  if (axios.isAxiosError(err)) {
2957
3049
  if (err.response?.data) {
@@ -2973,13 +3065,19 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
2973
3065
  const [deviceFlow, setDeviceFlow] = useState(null);
2974
3066
  const [statusMessage, setStatusMessage] = useState("Initiating device authorization...");
2975
3067
  const [error, setError] = useState(null);
3068
+ const [account, setAccount] = useState(null);
2976
3069
  useEffect(() => {
2977
3070
  const runLoginFlow = async () => {
2978
3071
  const oauth = new OAuthClient(apiUrl);
2979
3072
  try {
2980
3073
  setStatus("initiating");
2981
3074
  const deviceFlowResponse = await oauth.initiateDeviceFlow();
2982
- 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
+ });
2983
3081
  setStatus("waiting");
2984
3082
  setStatusMessage("Waiting for authorization...");
2985
3083
  const tokens = await oauth.waitForAuthorization(deviceFlowResponse.device_code, deviceFlowResponse.interval, (message) => setStatusMessage(message));
@@ -2991,12 +3089,18 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
2991
3089
  expiresAt,
2992
3090
  userId
2993
3091
  });
3092
+ setAccount({ userId });
2994
3093
  setStatus("success");
2995
3094
  setStatusMessage("Successfully authenticated!");
2996
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(() => {});
2997
3101
  } catch (err) {
2998
3102
  setStatus("error");
2999
- const errorMessage = extractErrorMessage(err);
3103
+ const errorMessage = stripControlChars(extractErrorMessage(err));
3000
3104
  setError(errorMessage);
3001
3105
  onError(new Error(errorMessage));
3002
3106
  }
@@ -3008,12 +3112,19 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
3008
3112
  onSuccess,
3009
3113
  onError
3010
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);
3011
3119
  useEffect(() => {
3012
- 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) => {
3013
3121
  console.error("Failed to auto-open browser:", err);
3014
3122
  });
3015
- else console.error("Refusing to auto-open untrusted verification URL:", deviceFlow.verification_uri_complete);
3016
- }, [deviceFlow, status]);
3123
+ }, [
3124
+ deviceFlow,
3125
+ status,
3126
+ trusted
3127
+ ]);
3017
3128
  if (status === "initiating") return /* @__PURE__ */ React.createElement(Box, {
3018
3129
  flexDirection: "column",
3019
3130
  padding: 1
@@ -3031,7 +3142,7 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
3031
3142
  }, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
3032
3143
  color: "green",
3033
3144
  bold: true
3034
- }, "✔ 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))));
3035
3146
  return /* @__PURE__ */ React.createElement(Box, {
3036
3147
  flexDirection: "column",
3037
3148
  padding: 1,
@@ -3040,7 +3151,10 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
3040
3151
  }, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
3041
3152
  color: "cyan",
3042
3153
  bold: true
3043
- }, "🔐 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, {
3044
3158
  marginBottom: 1,
3045
3159
  paddingLeft: 2
3046
3160
  }, /* @__PURE__ */ React.createElement(Text, {
@@ -4517,6 +4631,314 @@ Unlike agent_delegate (which uses pre-defined agents), this tool lets you compos
4517
4631
  };
4518
4632
  }
4519
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
4520
4942
  //#region src/features/FeatureModuleRegistry.ts
4521
4943
  /**
4522
4944
  * Manages the lifecycle of opt-in CLI feature modules.
@@ -7685,7 +8107,7 @@ async function handleSandboxTrustDomainCommand(deps, domains) {
7685
8107
  * recurses with the same context for messages submitted mid-turn.
7686
8108
  */
7687
8109
  async function runTurn(message, ctx) {
7688
- 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;
7689
8111
  const storeSession = useCliStore.getState().session;
7690
8112
  if (!agent || !storeSession) {
7691
8113
  console.error("❌ CLI failed to initialize. Try restarting b4m.\n");
@@ -7790,6 +8212,8 @@ async function runTurn(message, ctx) {
7790
8212
  if (elided > 0) logger.debug(`[workflowReminder] elided ${elided} item(s) to fit the token cap`);
7791
8213
  return text;
7792
8214
  } : void 0;
8215
+ const diagnostics = cliConfig.preferences.postEditDiagnostics === true ? postEditDiagnostics : null;
8216
+ diagnostics?.beginTurn();
7793
8217
  let result;
7794
8218
  try {
7795
8219
  result = await agent.run(messageContent, {
@@ -7798,9 +8222,11 @@ async function runTurn(message, ctx) {
7798
8222
  parallelExecution: cliConfig.preferences.enableParallelToolExecution === true,
7799
8223
  isReadOnlyTool,
7800
8224
  workflowReminder,
8225
+ drainFeedback: diagnostics ? (phase) => diagnostics.drain(DRAIN_BUDGET_MS[phase], abortController.signal) : void 0,
7801
8226
  onContextLimit: createReactiveCompactionHandler(agent, activeSession, 1 + previousMessages.length + 1)
7802
8227
  });
7803
8228
  } finally {
8229
+ diagnostics?.endTurn();
7804
8230
  backgroundManager?.setCurrentTurn(null);
7805
8231
  }
7806
8232
  const permissionDenied = result.finalAnswer.startsWith("Permission denied for tool");
@@ -8101,6 +8527,22 @@ async function rewindSession(ctx, messageIndex) {
8101
8527
  return { prefill: prefillContent };
8102
8528
  }
8103
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
8104
8546
  //#region src/index.tsx
8105
8547
  process.removeAllListeners("warning");
8106
8548
  process.on("warning", (warning) => {
@@ -8200,6 +8642,7 @@ function CliApp() {
8200
8642
  permissionPrompt: null,
8201
8643
  trustLocationSelector: null,
8202
8644
  folderTrustPrompt: null,
8645
+ mcpApprovalPrompt: null,
8203
8646
  rewindSelector: null,
8204
8647
  sessionSelector: null,
8205
8648
  orchestrator: null,
@@ -8219,10 +8662,12 @@ function CliApp() {
8219
8662
  const [commandHistory, setCommandHistory] = useState([]);
8220
8663
  const imageStoreInitPromise = useRef(null);
8221
8664
  const folderTrustResolvedRef = useRef(false);
8665
+ const mcpApprovalResolvedRef = useRef(false);
8222
8666
  const todoStoreRef = useRef(createTodoStore());
8223
8667
  const decisionStoreRef = useRef(createDecisionStore());
8224
8668
  const blockerStoreRef = useRef(createBlockerStore());
8225
8669
  const reviewGateStoreRef = useRef(createReviewGateStore());
8670
+ const postEditDiagnosticsRef = useRef(new PostEditDiagnostics({ workspaceRoot: process.cwd() }));
8226
8671
  const setStoreSession = useCliStore((state) => state.setSession);
8227
8672
  const enqueuePermissionPrompt = useCliStore((state) => state.enqueuePermissionPrompt);
8228
8673
  const enqueueUserQuestionPrompt = useCliStore((state) => state.enqueueUserQuestionPrompt);
@@ -8334,6 +8779,20 @@ function CliApp() {
8334
8779
  }));
8335
8780
  return;
8336
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();
8337
8796
  const configDirs = await state.configStore.getAdditionalDirectories();
8338
8797
  const flagDirs = process.env.B4M_ADDITIONAL_DIRS ? JSON.parse(process.env.B4M_ADDITIONAL_DIRS) : [];
8339
8798
  const additionalDirectories = [.../* @__PURE__ */ new Set([...configDirs, ...flagDirs])];
@@ -8518,7 +8977,8 @@ function CliApp() {
8518
8977
  };
8519
8978
  const agentContext = {
8520
8979
  currentAgent: null,
8521
- observationQueue: []
8980
+ observationQueue: [],
8981
+ onFileChanged: (filePath) => postEditDiagnosticsRef.current.enqueue(filePath)
8522
8982
  };
8523
8983
  const { mcpManager, agentStore, contextResult, mcpTools, loadedB4mTools, deferredB4mTools, orchestrator, backgroundManager, historyStore } = await buildSupportingStores({
8524
8984
  config,
@@ -9028,6 +9488,7 @@ function CliApp() {
9028
9488
  todoStore: todoStoreRef.current,
9029
9489
  decisionStore: decisionStoreRef.current,
9030
9490
  blockerStore: blockerStoreRef.current,
9491
+ postEditDiagnostics: postEditDiagnosticsRef.current,
9031
9492
  workflowStores: {
9032
9493
  decisionStore: decisionStoreRef.current,
9033
9494
  blockerStore: blockerStoreRef.current,
@@ -9497,11 +9958,10 @@ function CliApp() {
9497
9958
  }
9498
9959
  if (customCommand.model && state.agent) {
9499
9960
  console.log(`🔄 Using model override: ${customCommand.model}`);
9500
- const overrideSession = useCliStore.getState().session;
9501
- const originalModel = overrideSession?.model;
9502
- if (overrideSession) overrideSession.model = customCommand.model;
9503
- await handleCustomCommandMessage(substitutedBody, displayMessage);
9504
- if (overrideSession && originalModel) overrideSession.model = originalModel;
9961
+ await runWithModelOverride({
9962
+ applyModel: applyModelToSession,
9963
+ sessionStore: state.sessionStore
9964
+ }, customCommand.model, () => handleCustomCommandMessage(substitutedBody, displayMessage));
9505
9965
  } else {
9506
9966
  console.log("🤖 Sending to agent...\n");
9507
9967
  await handleCustomCommandMessage(substitutedBody, displayMessage);
@@ -10664,6 +11124,34 @@ function CliApp() {
10664
11124
  })();
10665
11125
  }
10666
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
+ }
10667
11155
  const currentSession = useCliStore.getState().session;
10668
11156
  if (state.rewindSelector && currentSession) return /* @__PURE__ */ React.createElement(RewindSelector, {
10669
11157
  messages: currentSession.messages,