@bike4mind/cli 1.0.0 → 1.2.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 (36) hide show
  1. package/README.md +1 -0
  2. package/bin/bike4mind-cli.mjs +0 -5
  3. package/dist/AgentHistoryStore-B2NEOvSW.mjs +18014 -0
  4. package/dist/{ApiClient-EUTyn5yu.mjs → ApiClient-D0fQ2FT6.mjs} +2 -2
  5. package/dist/{BubblewrapRuntime-CkL9-gnG.mjs → BubblewrapRuntime-5bLPTwEC.mjs} +2 -2
  6. package/dist/{ConfigStore-DHNgFdOu.mjs → ConfigStore-qV7NrCgZ.mjs} +2259 -1283
  7. package/dist/{ProxyManager-C1-lgzEU.mjs → ProxyManager-B0-RuR2w.mjs} +21 -4
  8. package/dist/{SandboxOrchestrator-C8uleDn2.mjs → SandboxOrchestrator-BcUv9fQ3.mjs} +47 -13
  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-C-C-VGff.mjs +2001 -0
  13. package/dist/commands/acpCommand.mjs +36 -13
  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 +92 -42
  18. package/dist/commands/mcpCommand.mjs +8 -13
  19. package/dist/commands/pluginCommand.mjs +9 -15
  20. package/dist/commands/updateCommand.mjs +1 -1
  21. package/dist/{createFile-DPv180yF-BnWFIxey.mjs → createFile-B8bur5Rb-CVzCarEA.mjs} +2 -2
  22. package/dist/{deleteFile-BdjUwUQF-B3XOJmg3.mjs → deleteFile-9B3gW_Nb-DG2sovIl.mjs} +2 -2
  23. package/dist/{globFiles-DjfDGaUK-CNR8pMRC.mjs → globFiles-CwJ8qmYo-BR5b2KvO.mjs} +3 -2
  24. package/dist/{grepSearch-BaYUfIYs-n0XKoGnL.mjs → grepSearch-BgoOOwGe-DtlV8Gn-.mjs} +3 -3
  25. package/dist/index.mjs +638 -129
  26. package/dist/{package-7a45-Svr.mjs → package-CGZIoxcs.mjs} +1 -1
  27. package/dist/{pathValidation-D8tjkQXE-1HwvsuYT.mjs → pathValidation-BRqf4HFX-CHwtwp3O.mjs} +7 -3
  28. package/dist/{serve-CavAHPdQ.mjs → serve-CPXqcEZr.mjs} +2 -2
  29. package/dist/types-CdIKgWWe.mjs +3 -0
  30. package/dist/{types-LyRNHOiS.mjs → types-F61_hxmG.mjs} +2 -0
  31. package/package.json +28 -29
  32. package/dist/AgentHistoryStore-CrRb8cMt.mjs +0 -38586
  33. package/dist/ProxyManager-B1jFWL7b.mjs +0 -3
  34. package/dist/SandboxOrchestrator-BFPVpmB5.mjs +0 -3
  35. package/dist/buildAgent-CJrkEG0M.mjs +0 -824
  36. package/dist/types-CqscS34o.mjs +0 -3
package/dist/index.mjs CHANGED
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { $ as RemoteSkillSource, A as FallbackLlmBackend, C as createWriteTodosTool, D as createResumeAgentTool, E as createCoordinateTaskTool, G as DEFAULT_THOROUGHNESS, H as DEFAULT_AGENT_MODEL, I as PermissionManager, J as setWebSocketToolExecutor, K as clearFeatureModuleTools, N as findIterationBoundary, O as createBackgroundAgentTools, P as extractCompactInstructions, Q as isReadOnlyTool, R as getProcessHooks, S as createTodoStore, T as parseAgentConfig, U as DEFAULT_MAX_ITERATIONS, V as ALWAYS_DENIED_FOR_AGENTS, W as DEFAULT_RETRY_CONFIG, X as buildSystemPrompt, Y as getPlanModeFilePath, Z as buildSkillsPromptSection, _ as createDecisionStore, at as searchCommands, b as createWorkItemTools, c as isTransientNetworkError, ct as searchFiles, d as createReviewGateTool, et as CustomCommandStore, f as formatReviewGatesOutput, ft as MAX_PASTE_SIZE, g as createDecisionLogTool, h as formatBlockersOutput, it as processFileReferences, j as substituteArguments, k as createAgentDelegateTool, lt as warmFileCache, m as createBlockerTools, nt as SessionStore, ot as mergeCommands, p as createBlockerStore, q as registerFeatureModuleTools, rt as hasFileReferences, st as formatFileSize, tt as CheckpointStore, u as createReviewGateStore, ut as COMPACTION_SUMMARY_MARKER, v as formatDecisionsOutput, w as createSkillTool, x as createFindDefinitionTool, y as createGetFileStructureTool } from "./AgentHistoryStore-CrRb8cMt.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-B2NEOvSW.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 { $t as actorKindSchema, Qt as actorKindMarker, X as REVIEW_GATE_STATUS_VALUES, Xt as ACTOR_COLOR_SLOTS, Z as SUBQUEST_STATUS_VALUES, Zt as actorColorIndex, a as getCreditsUrl, b as CREDIT_DEDUCT_TRANSACTION_TYPES, c as requireApiUrl, en as selfClaimedActorKindSchema, i as LOCAL_DEV_URL, l as resolveApiEndpoint, n as logger, o as getEnvironmentName, r as ApiEndpointUnconfiguredError, s as parseApiUrl, t as ConfigStore, x as ChatModels, xt as isEarlyStop } from "./ConfigStore-DHNgFdOu.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";
6
7
  import { t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
7
- import { n as OAuthClient, t as ApiClient } from "./ApiClient-EUTyn5yu.mjs";
8
+ import { n as OAuthClient, t as ApiClient } from "./ApiClient-D0fQ2FT6.mjs";
8
9
  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-CJrkEG0M.mjs";
10
- import { t as version } from "./package-7a45-Svr.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";
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";
@@ -1083,9 +1084,28 @@ function CompletedGroupNotification() {
1083
1084
  }
1084
1085
  //#endregion
1085
1086
  //#region src/components/PermissionPrompt.tsx
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.
1094
+ * - Unicode bidi overrides U+202A..U+202E and isolates U+2066..U+2069
1095
+ * -> `\uXXXX`: these reorder displayed glyphs without changing the
1096
+ * underlying code points, spoofing the same way.
1097
+ * Tab and newline are left intact (newline is already the line delimiter).
1098
+ * Widen the scope here, so both call sites gain it at once.
1099
+ */
1100
+ function escapeTerminalControlChars(text) {
1101
+ return text.replace(/[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, (ch) => {
1102
+ const code = ch.charCodeAt(0);
1103
+ return code > 255 ? `\\u${code.toString(16).padStart(4, "0")}` : `\\x${code.toString(16).padStart(2, "0")}`;
1104
+ });
1105
+ }
1086
1106
  /** Render a diff/preview string, color-coding each line by its prefix (see per-branch comments below). */
1087
1107
  function renderDiffPreview(preview) {
1088
- return preview.split("\n").map((line, index) => {
1108
+ return escapeTerminalControlChars(preview).split("\n").map((line, index) => {
1089
1109
  if (line.startsWith("🛑")) return /* @__PURE__ */ React.createElement(Text, {
1090
1110
  key: index,
1091
1111
  bold: true,
@@ -1218,7 +1238,7 @@ function PermissionPrompt({ toolName, toolDescription, args, preview, canBeTrust
1218
1238
  }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Arguments:"), /* @__PURE__ */ React.createElement(Box, {
1219
1239
  paddingLeft: 2,
1220
1240
  flexDirection: "column"
1221
- }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, argsString))), preview && /* @__PURE__ */ React.createElement(Box, {
1241
+ }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(argsString)))), preview && /* @__PURE__ */ React.createElement(Box, {
1222
1242
  marginTop: 1,
1223
1243
  flexDirection: "column"
1224
1244
  }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Preview:"), /* @__PURE__ */ React.createElement(Box, {
@@ -2656,6 +2676,40 @@ function TrustLocationSelector({ inProject, onSelect, onCancel }) {
2656
2676
  }), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Use ↑↓ arrows to navigate, Enter to select, Ctrl+C to cancel")));
2657
2677
  }
2658
2678
  //#endregion
2679
+ //#region src/components/FolderTrustPrompt.tsx
2680
+ /**
2681
+ * One-time startup prompt shown when the current project root ships repo-committed
2682
+ * b4m files (config, .mcp.json, agents, skills, commands) but has not been
2683
+ * trusted. Until trusted, those files are inert - no config merge, no MCP spawn,
2684
+ * no agent/skill/command load. "Trust this folder" persists the decision; "Not
2685
+ * now" keeps the project untrusted for this session (re-prompts next launch).
2686
+ *
2687
+ * "Not now" is listed first so it is the preselected default: Enter-through on a
2688
+ * freshly cloned repo must not grant trust.
2689
+ */
2690
+ function FolderTrustPrompt({ projectRoot, onSelect }) {
2691
+ return /* @__PURE__ */ React.createElement(Box, {
2692
+ flexDirection: "column",
2693
+ marginY: 1
2694
+ }, /* @__PURE__ */ React.createElement(Box, {
2695
+ marginBottom: 1,
2696
+ flexDirection: "column"
2697
+ }, /* @__PURE__ */ React.createElement(Text, {
2698
+ bold: true,
2699
+ 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, {
2701
+ items: [{
2702
+ label: "Not now - keep repo config inert this session",
2703
+ value: "not-now"
2704
+ }, {
2705
+ label: "Trust this folder - load its config, agents, skills and MCP servers",
2706
+ value: "trust"
2707
+ }],
2708
+ onSelect: (item) => onSelect(item.value),
2709
+ itemComponent: ({ isSelected, label }) => /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: isSelected ? "cyan" : void 0 }, isSelected ? "❯ " : " ", label))
2710
+ }), /* @__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
+ }
2712
+ //#endregion
2659
2713
  //#region src/components/RewindSelector.tsx
2660
2714
  function RewindSelector({ messages, onSelect, onCancel }) {
2661
2715
  const [step, setStep] = useState("selection");
@@ -2882,6 +2936,22 @@ function EnvironmentPicker({ onSelect }) {
2882
2936
  }
2883
2937
  //#endregion
2884
2938
  //#region src/components/LoginFlow.tsx
2939
+ /**
2940
+ * Only https URLs (and http on localhost for dev) may be handed to the OS opener.
2941
+ * The device-flow response is server-controlled, so a hostile/compromised server
2942
+ * could return `file:///...`, `javascript:...` or similar and have the OS launch
2943
+ * it. Reject anything that isn't a plain web URL.
2944
+ */
2945
+ function isBrowserOpenableUrl(raw) {
2946
+ let url;
2947
+ try {
2948
+ url = new URL(raw);
2949
+ } catch {
2950
+ return false;
2951
+ }
2952
+ if (url.protocol === "https:") return true;
2953
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
2954
+ }
2885
2955
  function extractErrorMessage(err) {
2886
2956
  if (axios.isAxiosError(err)) {
2887
2957
  if (err.response?.data) {
@@ -2939,9 +3009,10 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
2939
3009
  onError
2940
3010
  ]);
2941
3011
  useEffect(() => {
2942
- if (deviceFlow && status === "waiting") open(deviceFlow.verification_uri_complete).catch((err) => {
3012
+ if (deviceFlow && status === "waiting") if (isBrowserOpenableUrl(deviceFlow.verification_uri_complete)) open(deviceFlow.verification_uri_complete).catch((err) => {
2943
3013
  console.error("Failed to auto-open browser:", err);
2944
3014
  });
3015
+ else console.error("Refusing to auto-open untrusted verification URL:", deviceFlow.verification_uri_complete);
2945
3016
  }, [deviceFlow, status]);
2946
3017
  if (status === "initiating") return /* @__PURE__ */ React.createElement(Box, {
2947
3018
  flexDirection: "column",
@@ -3087,6 +3158,35 @@ var CommandHistoryStore = class {
3087
3158
  }
3088
3159
  };
3089
3160
  //#endregion
3161
+ //#region src/storage/buildGlobalConfigPatch.ts
3162
+ /**
3163
+ * Compute the minimal global-config patch for a /config editor save: only the
3164
+ * editor-owned fields (defaultModel, preferences, features) that the user
3165
+ * actually changed vs the current effective (merged) config. Unchanged fields
3166
+ * are omitted, so a repo-injected value the user never touched is never
3167
+ * laundered into the global layer by a save() that spreads the merged config.
3168
+ *
3169
+ * defaultModel and preferences diff per key. features stays whole-object when
3170
+ * anything changed, on purpose: ConfigStore.save() feeds config.features to
3171
+ * mergeFeatures as the caller's COMPLETE desired set (absence of a key means
3172
+ * "removed"), so a per-key diff would make save() delete untouched keys. That
3173
+ * whole-object features path is only latently launderable today (no repo layer
3174
+ * populates features); tightening it needs a coordinated change to save()'s
3175
+ * concurrent-writer merge and is tracked separately.
3176
+ */
3177
+ function buildGlobalConfigPatch(prev, next) {
3178
+ const patch = {};
3179
+ if (next.defaultModel !== prev?.defaultModel) patch.defaultModel = next.defaultModel;
3180
+ const changedPreferences = {};
3181
+ for (const [key, value] of Object.entries(next.preferences)) {
3182
+ const prevValue = (prev?.preferences)?.[key];
3183
+ if (JSON.stringify(value) !== JSON.stringify(prevValue)) changedPreferences[key] = value;
3184
+ }
3185
+ if (Object.keys(changedPreferences).length > 0) patch.preferences = changedPreferences;
3186
+ if (JSON.stringify(next.features ?? {}) !== JSON.stringify(prev?.features ?? {})) patch.features = next.features;
3187
+ return patch;
3188
+ }
3189
+ //#endregion
3090
3190
  //#region src/utils/formatStep.ts
3091
3191
  const MAX_INPUT_LENGTH = 500;
3092
3192
  /**
@@ -3476,7 +3576,7 @@ function toStringArray(value) {
3476
3576
  function extractJsonObject(text) {
3477
3577
  const trimmed = text.trim();
3478
3578
  if (!trimmed) return null;
3479
- const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
3579
+ const fenced = trimmed.match(/```(?:json)?([\s\S]*?)```/i);
3480
3580
  const candidate = fenced ? fenced[1].trim() : trimmed;
3481
3581
  const start = candidate.indexOf("{");
3482
3582
  if (start === -1) return null;
@@ -6668,6 +6768,168 @@ function createBuiltinModules(config, apiClient) {
6668
6768
  return modules;
6669
6769
  }
6670
6770
  //#endregion
6771
+ //#region src/features/bridgePresence/peerOwner.ts
6772
+ const execFileAsync = promisify(execFile);
6773
+ const LSOF_TIMEOUT_MS = 2e3;
6774
+ const LSOF_CANDIDATES = ["/usr/sbin/lsof", "/usr/bin/lsof"];
6775
+ /**
6776
+ * Resolve the OS owner of the process holding the LISTEN socket on
6777
+ * `127.0.0.1:<port>`. This is the only pre-transmission trust signal the CLI
6778
+ * has over loopback TCP: the peer is whoever holds the port, so we confirm it
6779
+ * is the same UID as this process BEFORE handing it the bridge secret.
6780
+ *
6781
+ * Pure platform probe: the per-platform helpers classify every EXPECTED failure
6782
+ * into `no-listener`/`unknown` themselves. An UNEXPECTED throw (a bug) is left to
6783
+ * propagate so the CLI-layer caller can log it and fail closed - this module
6784
+ * deliberately does not depend on the CLI logger.
6785
+ */
6786
+ async function resolveLoopbackListenerOwner(port) {
6787
+ if (process.platform === "linux") return resolveViaProcNet(port);
6788
+ if (process.platform === "darwin") return resolveViaLsof(port);
6789
+ return { kind: "unknown" };
6790
+ }
6791
+ /**
6792
+ * Whether this platform + process can resolve loopback listener ownership at all:
6793
+ * there must be a `getuid` to compare against (Windows has none) AND an owner probe
6794
+ * for the platform. When false, the caller latches presence off rather than spinning
6795
+ * a retry loop forever on a check that can never pass (a `getuid`-bearing platform
6796
+ * with no probe - BSD/SunOS - would otherwise retry indefinitely).
6797
+ * MUST stay in sync with `resolveLoopbackListenerOwner`'s platform dispatch above:
6798
+ * a platform gains a probe there and a `true` here in the same change.
6799
+ */
6800
+ function canResolveLoopbackOwner() {
6801
+ if (typeof process.getuid !== "function") return false;
6802
+ return process.platform === "linux" || process.platform === "darwin";
6803
+ }
6804
+ async function resolveViaProcNet(port) {
6805
+ const uids = /* @__PURE__ */ new Set();
6806
+ let readAny = false;
6807
+ for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
6808
+ let text;
6809
+ try {
6810
+ text = await promises.readFile(file, "utf8");
6811
+ } catch {
6812
+ continue;
6813
+ }
6814
+ readAny = true;
6815
+ for (const uid of parseProcNetForUid(text, port)) uids.add(uid);
6816
+ }
6817
+ if (!readAny) return { kind: "unknown" };
6818
+ if (uids.size === 0) return { kind: "no-listener" };
6819
+ return uids.size === 1 ? {
6820
+ kind: "owner",
6821
+ uid: [...uids][0]
6822
+ } : { kind: "unknown" };
6823
+ }
6824
+ /**
6825
+ * `local_address` hex (upper-case) of a socket that receives connections to
6826
+ * `127.0.0.1`: loopback itself, the v4/v6 wildcard binds (a same-UID process
6827
+ * may legitimately hold `0.0.0.0`/`::`), and the v4-mapped-in-v6 forms. A row
6828
+ * on any other IP is a different socket that happens to share the port number
6829
+ * and must NOT be attributed to the loopback peer.
6830
+ *
6831
+ * `/proc/net/tcp6` prints each 32-bit word of the address little-endian (no
6832
+ * `ntohl`), which is why `127.0.0.1` reads `0100007F` and the mapped third word
6833
+ * `00 00 ff ff` reads `FFFF0000`.
6834
+ */
6835
+ const LOOPBACK_OR_WILDCARD_HEX = /* @__PURE__ */ new Set([
6836
+ "00000000",
6837
+ "0100007F",
6838
+ "00000000000000000000000000000000",
6839
+ "00000000000000000000000001000000",
6840
+ "0000000000000000FFFF000000000000",
6841
+ "0000000000000000FFFF00000100007F"
6842
+ ]);
6843
+ /**
6844
+ * Parse a `/proc/net/tcp`(6) dump for the UIDs of LISTEN sockets on the
6845
+ * loopback `port`. Row columns are whitespace-separated: sl(0)
6846
+ * local_address(1) rem_address(2) st(3) tx:rx(4) tr:when(5) retrnsmt(6)
6847
+ * uid(7). `local_address` is `HEXIP:HEXPORT`; `st` `0A` is TCP_LISTEN. The IP
6848
+ * must be loopback or wildcard, else a same-port listener on another interface
6849
+ * would be mis-attributed to the loopback peer. Returns every match so the
6850
+ * caller can reject an ambiguous set (two distinct owners) rather than
6851
+ * silently taking the first.
6852
+ */
6853
+ function parseProcNetForUid(text, port) {
6854
+ const uids = [];
6855
+ for (const line of text.split("\n")) {
6856
+ const cols = line.trim().split(/\s+/);
6857
+ if (cols.length < 8) continue;
6858
+ const local = cols[1];
6859
+ const colon = local.lastIndexOf(":");
6860
+ if (colon < 0) continue;
6861
+ if (parseInt(local.slice(colon + 1), 16) !== port) continue;
6862
+ if (cols[3] !== "0A") continue;
6863
+ if (!LOOPBACK_OR_WILDCARD_HEX.has(local.slice(0, colon).toUpperCase())) continue;
6864
+ const uid = Number(cols[7]);
6865
+ if (Number.isInteger(uid)) uids.push(uid);
6866
+ }
6867
+ return uids;
6868
+ }
6869
+ async function resolveViaLsof(port) {
6870
+ const lsof = await resolveLsofPath();
6871
+ if (!lsof) return { kind: "unknown" };
6872
+ let stdout;
6873
+ try {
6874
+ ({stdout} = await execFileAsync(lsof, [
6875
+ "-nP",
6876
+ `-iTCP:${port}`,
6877
+ "-sTCP:LISTEN",
6878
+ "-Fpun"
6879
+ ], { timeout: LSOF_TIMEOUT_MS }));
6880
+ } catch (err) {
6881
+ const e = err;
6882
+ if (!e.killed && e.code === 1 && !e.stdout) return { kind: "no-listener" };
6883
+ if (e.killed || typeof e.stdout !== "string") return { kind: "unknown" };
6884
+ stdout = e.stdout;
6885
+ }
6886
+ const uids = new Set(parseLsofForUid(stdout));
6887
+ if (uids.size === 0) return { kind: "no-listener" };
6888
+ return uids.size === 1 ? {
6889
+ kind: "owner",
6890
+ uid: [...uids][0]
6891
+ } : { kind: "unknown" };
6892
+ }
6893
+ let lsofPathCache;
6894
+ async function resolveLsofPath() {
6895
+ if (lsofPathCache !== void 0) return lsofPathCache;
6896
+ for (const candidate of LSOF_CANDIDATES) try {
6897
+ await promises.access(candidate, constants.X_OK);
6898
+ lsofPathCache = candidate;
6899
+ return candidate;
6900
+ } catch {}
6901
+ lsofPathCache = null;
6902
+ return null;
6903
+ }
6904
+ /** Does an `lsof -nP` numeric socket name (`n` field, host:port) receive
6905
+ * connections to `127.0.0.1`? Loopback, wildcard, or v4-mapped loopback -
6906
+ * exact, to stay symmetric with the linux matcher (the CLI only dials
6907
+ * `127.0.0.1`). */
6908
+ function isLoopbackLsofName(name) {
6909
+ return name.startsWith("127.0.0.1:") || name.startsWith("*:") || name.startsWith("[::1]:") || name.startsWith("[::]:") || name.startsWith("[::ffff:127.0.0.1]:");
6910
+ }
6911
+ /**
6912
+ * Parse `lsof -Fpun` output for the UIDs of loopback listeners. `-F` prints
6913
+ * one field per line prefixed by its type char; `u<uid>` is a process-level
6914
+ * field, `n<host:port>` a file-level one. `uid` resets on each `p<pid>` block
6915
+ * so a block without a `u` field can't inherit the prior block's owner. We
6916
+ * collect the UID for every loopback/wildcard name so the caller can reject an
6917
+ * ambiguous set rather than taking the first.
6918
+ */
6919
+ function parseLsofForUid(text) {
6920
+ const uids = [];
6921
+ let uid = null;
6922
+ for (const line of text.split("\n")) {
6923
+ const tag = line[0];
6924
+ if (tag === "p") uid = null;
6925
+ else if (tag === "u") {
6926
+ const parsed = Number(line.slice(1));
6927
+ uid = Number.isInteger(parsed) ? parsed : null;
6928
+ } else if (tag === "n" && uid !== null && isLoopbackLsofName(line.slice(1))) uids.push(uid);
6929
+ }
6930
+ return uids;
6931
+ }
6932
+ //#endregion
6671
6933
  //#region src/features/bridgePresence/BridgePresence.ts
6672
6934
  const DEFAULT_PORT = Number(process.env.CC_BRIDGE_PORT ?? 48732);
6673
6935
  const CONFIG_PATH = join(homedir(), ".b4m", "cc-bridge.json");
@@ -6693,11 +6955,15 @@ var BridgePresence = class {
6693
6955
  this.callbacks = {};
6694
6956
  this.started = false;
6695
6957
  this.stopped = false;
6958
+ this.generation = 0;
6959
+ this.trusted = false;
6960
+ this.wsConnectingGen = null;
6696
6961
  this.reconnectTimer = null;
6697
6962
  this.reconnectAttempts = 0;
6698
6963
  this.announceRetryTimer = null;
6699
6964
  this.announceAttempts = 0;
6700
6965
  this.startOpts = null;
6966
+ this.peerWarned = false;
6701
6967
  this.pendingWorkspaceName = null;
6702
6968
  this.pendingCapabilities = null;
6703
6969
  this.pendingSource = null;
@@ -6706,6 +6972,19 @@ var BridgePresence = class {
6706
6972
  setCallbacks(cbs) {
6707
6973
  this.callbacks = cbs;
6708
6974
  }
6975
+ /** True iff `gen` is still the live connection generation - the single spelling
6976
+ * of the ABA guard applied after every await (see `generation`). */
6977
+ isCurrent(gen) {
6978
+ return gen === this.generation;
6979
+ }
6980
+ /** Test-only: the generation currently holding the connectCommandWs in-flight
6981
+ * latch, or null when free. Exposed to pin the `finally` identity guard - a
6982
+ * stale generation's finally must not clear a live generation's latch (which
6983
+ * would let a second overlapping connect build an untracked socket). Not part
6984
+ * of the runtime contract. */
6985
+ get __wsConnectingGenForTests() {
6986
+ return this.wsConnectingGen;
6987
+ }
6709
6988
  /**
6710
6989
  * Probe the local bridge and, if present, announce this CLI session.
6711
6990
  * Returns true iff the announce succeeded (tavern presence is active).
@@ -6719,7 +6998,10 @@ var BridgePresence = class {
6719
6998
  if (this.started) return this.instanceId !== null;
6720
6999
  this.stopped = false;
6721
7000
  this.started = true;
7001
+ this.generation += 1;
7002
+ const gen = this.generation;
6722
7003
  const config = await readBridgeConfig();
7004
+ if (!this.isCurrent(gen)) return false;
6723
7005
  if (!config) {
6724
7006
  logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
6725
7007
  return false;
@@ -6731,34 +7013,119 @@ var BridgePresence = class {
6731
7013
  this.pendingSource = opts.source ?? "b4m-cli";
6732
7014
  return this.attemptAnnounce();
6733
7015
  }
7016
+ /**
7017
+ * Classify the process holding the bridge port relative to this CLI, BEFORE
7018
+ * any secret leaves the process. Over loopback TCP the peer is whoever holds
7019
+ * the port; the secret file is plaintext and per-user, so a same-UID peer is
7020
+ * already trusted (it could read the file directly), and a different-UID (or
7021
+ * sandboxed) peer is the adversary this gate stops.
7022
+ *
7023
+ * Four outcomes, so the caller warns only on a real trust failure (a foreign
7024
+ * owner) and stays quiet on the benign ones:
7025
+ * - `trusted` - same-UID owner; disclosure is safe.
7026
+ * - `absent` - no loopback listener yet (cc-bridge simply not
7027
+ * started); fail-closed but quiet - just retry.
7028
+ * - `foreign` - a different-UID owner holds the port; fail-closed AND
7029
+ * log the security-worded warning (the real adversary).
7030
+ * - `undeterminable` - ownership can't be resolved: lookup tool missing/hung,
7031
+ * a read/spawn error, or an ambiguous owner set.
7032
+ * Fail-closed, but a host that can't run the probe is not
7033
+ * an attacker - log at debug, not as the security warning.
7034
+ *
7035
+ * A platform that can't resolve ownership (see `canResolveLoopbackOwner`) is
7036
+ * latched off earlier, in `attemptAnnounce`, so this method is only reached
7037
+ * where `getuid` exists and the platform has an owner probe.
7038
+ */
7039
+ async checkPeerTrust() {
7040
+ const port = this.config?.port ?? DEFAULT_PORT;
7041
+ let owner;
7042
+ try {
7043
+ owner = await resolveLoopbackListenerOwner(port);
7044
+ } catch (err) {
7045
+ logger.debug(`[tavern] loopback owner lookup threw: ${err.message}`);
7046
+ return "undeterminable";
7047
+ }
7048
+ if (owner.kind === "no-listener") return "absent";
7049
+ if (owner.kind === "unknown") return "undeterminable";
7050
+ if (owner.uid !== process.getuid()) return "foreign";
7051
+ this.peerWarned = false;
7052
+ return "trusted";
7053
+ }
7054
+ /** Log the foreign-owner security warning once per untrusted state. */
7055
+ warnUntrustedPeerOnce() {
7056
+ if (this.peerWarned) return;
7057
+ this.peerWarned = true;
7058
+ logger.warn("[tavern] bridge port is held by a different user; not disclosing secret or handling commands");
7059
+ }
6734
7060
  /** One announce attempt. Schedules a retry on failure; wires up the
6735
7061
  * command WS + initial status on success. Idempotent: re-entering after
6736
7062
  * a successful announce short-circuits at the instanceId guard. */
6737
7063
  async attemptAnnounce() {
6738
7064
  if (this.stopped || !this.config || !this.startOpts) return false;
6739
7065
  if (this.instanceId) return true;
7066
+ if (!canResolveLoopbackOwner()) {
7067
+ logger.debug("[tavern] ownership unresolvable on this platform; tavern presence disabled");
7068
+ return false;
7069
+ }
7070
+ const gen = this.generation;
7071
+ const startOpts = this.startOpts;
7072
+ if (!await this.gateEgress(gen, "announce")) return false;
6740
7073
  const instanceId = v4();
6741
7074
  const workspaceName = this.pendingWorkspaceName;
6742
7075
  const capabilities = this.pendingCapabilities;
6743
7076
  const source = this.pendingSource;
6744
- if (!await this.announce({
7077
+ const announced = await this.announce({
6745
7078
  instanceId,
6746
7079
  source,
6747
7080
  workspaceName,
6748
- workspacePath: this.startOpts.workspacePath,
7081
+ workspacePath: startOpts.workspacePath,
6749
7082
  capabilities
6750
- })) {
7083
+ });
7084
+ if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
7085
+ if (!announced) {
7086
+ this.trusted = false;
6751
7087
  this.scheduleAnnounceRetry();
6752
7088
  return false;
6753
7089
  }
6754
7090
  this.instanceId = instanceId;
6755
7091
  this.announceAttempts = 0;
6756
7092
  logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
6757
- this.connectCommandWs();
7093
+ this.connectCommandWs().catch((err) => logger.debug(`[tavern] connectCommandWs threw: ${err.message}`));
6758
7094
  this.emitEvent({
6759
7095
  type: "status",
6760
7096
  status: "idle"
6761
- });
7097
+ }).catch(() => {});
7098
+ return true;
7099
+ }
7100
+ /** Signal the untrusted condition at the right volume: the security-worded
7101
+ * warning only for a real foreign owner, and a quiet debug line for the
7102
+ * benign cases (bridge not started yet, or ownership undeterminable) so we
7103
+ * don't cry wolf on every backed-off retry. */
7104
+ signalUntrusted(trust, phase) {
7105
+ if (trust === "foreign") this.warnUntrustedPeerOnce();
7106
+ else if (trust === "undeterminable") logger.debug(`[tavern] bridge port owner undeterminable; retrying ${phase}`);
7107
+ else logger.debug(`[tavern] cc-bridge not reachable yet; retrying ${phase}`);
7108
+ }
7109
+ /**
7110
+ * Run the pre-disclosure ownership gate for one egress phase and open or keep
7111
+ * the trust latch closed. The caller passes the connection `generation` it
7112
+ * captured BEFORE this await, so a stop()+start() cycle (or a bare stop())
7113
+ * landing during the probe is detected and this stale continuation bails.
7114
+ * Returns true iff the gate is open (secret disclosure is now permitted for
7115
+ * this generation); false means the caller must return - either the owner is
7116
+ * untrusted (a retry has been scheduled) or the generation moved.
7117
+ */
7118
+ async gateEgress(gen, phase) {
7119
+ const trust = await this.checkPeerTrust();
7120
+ if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
7121
+ if (trust !== "trusted") {
7122
+ this.trusted = false;
7123
+ this.signalUntrusted(trust, phase);
7124
+ if (phase === "announce") this.scheduleAnnounceRetry();
7125
+ else this.scheduleReconnect();
7126
+ return false;
7127
+ }
7128
+ this.trusted = true;
6762
7129
  return true;
6763
7130
  }
6764
7131
  scheduleAnnounceRetry() {
@@ -6767,17 +7134,22 @@ var BridgePresence = class {
6767
7134
  const delay = Math.min(1e3 * 2 ** (this.announceAttempts - 1), 3e4);
6768
7135
  this.announceRetryTimer = setTimeout(() => {
6769
7136
  this.announceRetryTimer = null;
6770
- this.attemptAnnounce();
7137
+ this.attemptAnnounce().catch((err) => logger.debug(`[tavern] announce retry threw: ${err.message}`));
6771
7138
  }, delay);
6772
7139
  }
6773
7140
  /** Emit an event for this session. No-op if the bridge isn't up. Events
6774
7141
  * leave in strict order - see `emitQueue` comment. */
6775
7142
  async emitEvent(event) {
6776
7143
  if (!this.config || !this.instanceId) return;
6777
- const task = () => this.post("/event", {
6778
- instanceId: this.instanceId,
6779
- event
6780
- }).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
7144
+ const instanceId = this.instanceId;
7145
+ const gen = this.generation;
7146
+ const task = () => {
7147
+ if (!this.isCurrent(gen)) return;
7148
+ return this.post("/event", {
7149
+ instanceId,
7150
+ event
7151
+ }).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
7152
+ };
6781
7153
  this.emitQueue = this.emitQueue.then(task, task);
6782
7154
  return this.emitQueue;
6783
7155
  }
@@ -6795,6 +7167,7 @@ var BridgePresence = class {
6795
7167
  async stop(reason = "cli_exit") {
6796
7168
  if (this.stopped || !this.started) return;
6797
7169
  this.stopped = true;
7170
+ this.generation += 1;
6798
7171
  if (this.reconnectTimer) {
6799
7172
  clearTimeout(this.reconnectTimer);
6800
7173
  this.reconnectTimer = null;
@@ -6803,13 +7176,14 @@ var BridgePresence = class {
6803
7176
  clearTimeout(this.announceRetryTimer);
6804
7177
  this.announceRetryTimer = null;
6805
7178
  }
7179
+ const wsWasLive = this.ws !== null;
6806
7180
  if (this.ws) {
6807
7181
  try {
6808
7182
  this.ws.close();
6809
7183
  } catch {}
6810
7184
  this.ws = null;
6811
7185
  }
6812
- if (this.config && this.instanceId) await this.post("/disconnect", {
7186
+ if (this.config && this.instanceId && wsWasLive) await this.post("/disconnect", {
6813
7187
  instanceId: this.instanceId,
6814
7188
  reason
6815
7189
  }).catch(() => {});
@@ -6822,6 +7196,8 @@ var BridgePresence = class {
6822
7196
  this.pendingSource = null;
6823
7197
  this.announceAttempts = 0;
6824
7198
  this.reconnectAttempts = 0;
7199
+ this.peerWarned = false;
7200
+ this.trusted = false;
6825
7201
  this.emitQueue = Promise.resolve();
6826
7202
  }
6827
7203
  async announce(body) {
@@ -6835,6 +7211,7 @@ var BridgePresence = class {
6835
7211
  }
6836
7212
  async post(path, body) {
6837
7213
  if (!this.config) throw new Error("bridge config not loaded");
7214
+ if (!this.trusted) throw new Error("bridge peer not trusted; refusing to disclose secret");
6838
7215
  const url = `http://127.0.0.1:${this.config.port ?? DEFAULT_PORT}${path}?secret=${encodeURIComponent(this.config.hookSecret)}`;
6839
7216
  const res = await fetch(url, {
6840
7217
  method: "POST",
@@ -6844,42 +7221,58 @@ var BridgePresence = class {
6844
7221
  });
6845
7222
  if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
6846
7223
  }
6847
- connectCommandWs() {
7224
+ async connectCommandWs() {
6848
7225
  if (this.stopped || !this.config || !this.instanceId) return;
6849
- const url = `ws://127.0.0.1:${this.config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(this.instanceId)}&secret=${encodeURIComponent(this.config.hookSecret)}`;
6850
- let ws;
7226
+ const gen = this.generation;
7227
+ if (this.ws || this.wsConnectingGen === gen) return;
7228
+ this.wsConnectingGen = gen;
6851
7229
  try {
6852
- ws = new WebSocket(url);
6853
- } catch (err) {
6854
- logger.debug(`[tavern] command WS construct failed: ${err.message}`);
6855
- this.scheduleReconnect();
6856
- return;
6857
- }
6858
- this.ws = ws;
6859
- ws.on("open", () => {
6860
- this.reconnectAttempts = 0;
6861
- logger.debug("[tavern] command WS open");
6862
- });
6863
- ws.on("message", (raw) => {
6864
- let frame = null;
7230
+ if (!await this.gateEgress(gen, "command WS")) return;
7231
+ const config = this.config;
7232
+ const instanceId = this.instanceId;
7233
+ if (!config || !instanceId) return;
7234
+ const url = `ws://127.0.0.1:${config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(instanceId)}&secret=${encodeURIComponent(config.hookSecret)}`;
7235
+ let ws;
6865
7236
  try {
6866
- frame = JSON.parse(raw.toString());
6867
- } catch {
6868
- logger.debug("[tavern] malformed command frame; ignored");
7237
+ ws = new WebSocket(url);
7238
+ } catch (err) {
7239
+ logger.debug(`[tavern] command WS construct failed: ${err.message}`);
7240
+ this.trusted = false;
7241
+ this.scheduleReconnect();
6869
7242
  return;
6870
7243
  }
6871
- if (!frame?.command) return;
6872
- this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
6873
- });
6874
- ws.on("close", () => {
6875
- this.ws = null;
6876
- if (this.stopped) return;
6877
- logger.debug("[tavern] command WS closed; reconnecting");
6878
- this.scheduleReconnect();
6879
- });
6880
- ws.on("error", (err) => {
6881
- logger.debug(`[tavern] command WS error: ${err.message}`);
6882
- });
7244
+ this.ws = ws;
7245
+ ws.on("open", () => {
7246
+ if (this.ws !== ws) return;
7247
+ this.reconnectAttempts = 0;
7248
+ logger.debug("[tavern] command WS open");
7249
+ });
7250
+ ws.on("message", (raw) => {
7251
+ if (this.ws !== ws) return;
7252
+ let frame = null;
7253
+ try {
7254
+ frame = JSON.parse(raw.toString());
7255
+ } catch {
7256
+ logger.debug("[tavern] malformed command frame; ignored");
7257
+ return;
7258
+ }
7259
+ if (!frame?.command) return;
7260
+ this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
7261
+ });
7262
+ ws.on("close", () => {
7263
+ if (this.ws !== ws) return;
7264
+ this.ws = null;
7265
+ this.trusted = false;
7266
+ if (this.stopped) return;
7267
+ logger.debug("[tavern] command WS closed; reconnecting");
7268
+ this.scheduleReconnect();
7269
+ });
7270
+ ws.on("error", (err) => {
7271
+ logger.debug(`[tavern] command WS error: ${err.message}`);
7272
+ });
7273
+ } finally {
7274
+ if (this.wsConnectingGen === gen) this.wsConnectingGen = null;
7275
+ }
6883
7276
  }
6884
7277
  scheduleReconnect() {
6885
7278
  if (this.stopped || this.reconnectTimer) return;
@@ -6887,10 +7280,11 @@ var BridgePresence = class {
6887
7280
  const delay = Math.min(500 * 2 ** (this.reconnectAttempts - 1), 1e4);
6888
7281
  this.reconnectTimer = setTimeout(() => {
6889
7282
  this.reconnectTimer = null;
6890
- this.connectCommandWs();
7283
+ this.connectCommandWs().catch((err) => logger.debug(`[tavern] command WS reconnect threw: ${err.message}`));
6891
7284
  }, delay);
6892
7285
  }
6893
7286
  async dispatchCommand(command) {
7287
+ if (this.stopped) return;
6894
7288
  switch (command.type) {
6895
7289
  case "send_prompt":
6896
7290
  if (this.callbacks.onSendPrompt) await this.callbacks.onSendPrompt(command.text);
@@ -7040,7 +7434,7 @@ const infoCommands = [
7040
7434
  {
7041
7435
  name: "help",
7042
7436
  run: (_args, ctx) => {
7043
- const customCommands = ctx.customCommandStore.getAllCommands();
7437
+ const customCommands = ctx.customCommandStore.getModelReachableCommands();
7044
7438
  const hasCustomCommands = customCommands.length > 0;
7045
7439
  console.log(`
7046
7440
  Available commands:
@@ -7069,7 +7463,9 @@ API Configuration:
7069
7463
  Tool Permissions:
7070
7464
  /trust <tool-name> - Trust a tool (won't ask permission again)
7071
7465
  /untrust <tool-name> - Remove tool from trusted list
7072
- /trusted - List all trusted tools
7466
+ /trust folder - Trust this project (load its repo config, agents, skills, MCP)
7467
+ /untrust folder - Revoke trust for this project (repo config goes inert)
7468
+ /trusted - Show folder-trust status and list all trusted tools
7073
7469
 
7074
7470
  Project Configuration:
7075
7471
  /project-config - Show merged project configuration
@@ -7131,8 +7527,16 @@ Multi-line Input:
7131
7527
  {
7132
7528
  name: "trusted",
7133
7529
  run: (_args, ctx) => {
7530
+ const projectRoot = ctx.configStore.getProjectRealPath();
7531
+ console.log("\n📁 Folder Trust:\n");
7532
+ if (!projectRoot) console.log(" No project root discovered.");
7533
+ else {
7534
+ const isTrusted = ctx.configStore.isProjectTrusted();
7535
+ console.log(` ${isTrusted ? "✅ Trusted" : "⛔ Not trusted"}: ${projectRoot}`);
7536
+ if (!isTrusted) console.log(" Repo config/agents/skills/MCP stay inert. Trust with /trust folder.");
7537
+ }
7134
7538
  if (!ctx.permissionManager) {
7135
- console.log("Permission manager not initialized");
7539
+ console.log("\nPermission manager not initialized\n");
7136
7540
  return;
7137
7541
  }
7138
7542
  const trustedTools = ctx.permissionManager.getTrustedTools();
@@ -7221,6 +7625,48 @@ async function dispatch(name, args, ctx) {
7221
7625
  return true;
7222
7626
  }
7223
7627
  //#endregion
7628
+ //#region src/commands/sandboxNetworkCommand.ts
7629
+ async function handleSandboxNetworkCommand(deps, arg) {
7630
+ const { orchestrator, configStore, permissionManager } = deps;
7631
+ if (!orchestrator) return "Sandbox not initialized";
7632
+ if (arg !== "on" && arg !== "off") return "Usage: /sandbox:network <on|off>";
7633
+ const enable = arg === "on";
7634
+ if (enable && !orchestrator.isAvailable()) return "Sandbox runtime not available on this platform";
7635
+ const active = await orchestrator.setNetworkEnabled(enable);
7636
+ if (enable && !active) {
7637
+ permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
7638
+ await configStore.saveSandboxConfig(orchestrator.getConfig());
7639
+ return "Failed to start the network proxy; network stays disabled (fail-closed).";
7640
+ }
7641
+ permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
7642
+ await configStore.saveSandboxConfig(orchestrator.getConfig());
7643
+ if (!enable) return "Sandbox network disabled (fail-closed)";
7644
+ const port = orchestrator.getProxyManager()?.getPort();
7645
+ return `Sandbox network egress enabled${port ? ` (proxy-aware clients filtered on port ${port})` : ""}`;
7646
+ }
7647
+ //#endregion
7648
+ //#region src/commands/sandboxTrustDomainCommand.ts
7649
+ async function handleSandboxTrustDomainCommand(deps, domains) {
7650
+ const { orchestrator, configStore } = deps;
7651
+ if (!orchestrator) return "Sandbox not initialized";
7652
+ const clean = domains.map((d) => d.trim()).filter(Boolean);
7653
+ if (clean.length === 0) return "Usage: /sandbox:trust-domain <domain> [...]";
7654
+ if (!orchestrator.getProxyManager()) return "Network proxy not initialized";
7655
+ const lines = [];
7656
+ for (const domain of clean) {
7657
+ orchestrator.addAllowedDomain(domain);
7658
+ lines.push(` Added: ${domain}`);
7659
+ }
7660
+ try {
7661
+ await configStore.saveSandboxConfig(orchestrator.getConfig());
7662
+ } catch (err) {
7663
+ const msg = err instanceof Error ? err.message : String(err);
7664
+ return `${lines.join("\n")}\nWarning: failed to persist the trusted domains (${msg}); they apply for this session only.`;
7665
+ }
7666
+ lines.push(`Trusted ${clean.length} domain(s)`);
7667
+ return lines.join("\n");
7668
+ }
7669
+ //#endregion
7224
7670
  //#region src/session/turnController.ts
7225
7671
  /**
7226
7672
  * Turn lifecycle (issue #228, phase 2).
@@ -7265,7 +7711,7 @@ async function runTurn(message, ctx) {
7265
7711
  const systemPrompt = buildSystemPrompt(config?.preferences.promptVariant ?? "current", {
7266
7712
  contextContent,
7267
7713
  agentStore: agentStore || void 0,
7268
- customCommands: customCommandStore.getAllCommands(),
7714
+ customCommands: customCommandStore.getModelReachableCommands(),
7269
7715
  enableSkillTool: config?.preferences.enableSkillTool !== false,
7270
7716
  additionalDirectories,
7271
7717
  featureModulePrompts: featureRegistry?.getSystemPromptSections() || void 0,
@@ -7753,6 +8199,7 @@ function CliApp() {
7753
8199
  permissionManager: null,
7754
8200
  permissionPrompt: null,
7755
8201
  trustLocationSelector: null,
8202
+ folderTrustPrompt: null,
7756
8203
  rewindSelector: null,
7757
8204
  sessionSelector: null,
7758
8205
  orchestrator: null,
@@ -7771,6 +8218,7 @@ function CliApp() {
7771
8218
  const [initError, setInitError] = useState(null);
7772
8219
  const [commandHistory, setCommandHistory] = useState([]);
7773
8220
  const imageStoreInitPromise = useRef(null);
8221
+ const folderTrustResolvedRef = useRef(false);
7774
8222
  const todoStoreRef = useRef(createTodoStore());
7775
8223
  const decisionStoreRef = useRef(createDecisionStore());
7776
8224
  const blockerStoreRef = useRef(createBlockerStore());
@@ -7877,11 +8325,21 @@ function CliApp() {
7877
8325
  try {
7878
8326
  const startupLog = [];
7879
8327
  const config = await state.configStore.load();
8328
+ if (!folderTrustResolvedRef.current && !state.configStore.isProjectTrusted() && state.configStore.projectHasB4mFiles() && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
8329
+ folderTrustResolvedRef.current = true;
8330
+ setState((prev) => ({
8331
+ ...prev,
8332
+ folderTrustPrompt: { projectRoot: state.configStore.getProjectRealPath() ?? process.cwd() },
8333
+ config
8334
+ }));
8335
+ return;
8336
+ }
7880
8337
  const configDirs = await state.configStore.getAdditionalDirectories();
7881
8338
  const flagDirs = process.env.B4M_ADDITIONAL_DIRS ? JSON.parse(process.env.B4M_ADDITIONAL_DIRS) : [];
7882
8339
  const additionalDirectories = [.../* @__PURE__ */ new Set([...configDirs, ...flagDirs])];
7883
8340
  const history = await state.commandHistoryStore.load();
7884
8341
  setCommandHistory(history);
8342
+ state.customCommandStore.setProjectTrusted(state.configStore.isProjectTrusted());
7885
8343
  try {
7886
8344
  await state.customCommandStore.loadCommands();
7887
8345
  } catch (error) {
@@ -7938,6 +8396,14 @@ function CliApp() {
7938
8396
  });
7939
8397
  const pinnedSessionId = process.env.B4M_SESSION_ID;
7940
8398
  const resumeSessionId = process.env.B4M_RESUME_ID;
8399
+ if (pinnedSessionId && !isValidSessionId(pinnedSessionId)) {
8400
+ console.error(`Invalid session id (--session-id / B4M_SESSION_ID): must match ${SESSION_ID_PATTERN.source}`);
8401
+ process.exit(1);
8402
+ }
8403
+ if (resumeSessionId && !isValidSessionId(resumeSessionId)) {
8404
+ console.error(`Invalid session id (--resume / B4M_RESUME_ID): must match ${SESSION_ID_PATTERN.source}`);
8405
+ process.exit(1);
8406
+ }
7941
8407
  let newSession;
7942
8408
  if (resumeSessionId) {
7943
8409
  const resumed = await state.sessionStore.load(resumeSessionId);
@@ -8108,14 +8574,28 @@ function CliApp() {
8108
8574
  blockerStoreRef.current.blockers = [];
8109
8575
  reviewGateStoreRef.current.reviewGates = [];
8110
8576
  }
8577
+ const cliWrapDeps = {
8578
+ permissionManager,
8579
+ showPermissionPrompt: promptFn,
8580
+ agentContext,
8581
+ configStore: state.configStore,
8582
+ apiClient,
8583
+ sandboxOrchestrator: sandboxOrchestrator ?? void 0,
8584
+ allowedDirectories: additionalDirectories
8585
+ };
8111
8586
  const enableSkillTool = config.preferences.enableSkillTool !== false;
8112
- const skillTool = enableSkillTool ? createSkillTool({
8587
+ const skillTool = enableSkillTool ? wrapTools([createSkillTool({
8113
8588
  customCommandStore: state.customCommandStore,
8114
8589
  subagentOrchestrator: orchestrator,
8115
- sessionId: newSession.id
8116
- }) : null;
8117
- const findDefinitionTool = createFindDefinitionTool();
8118
- const getFileStructureTool = createGetFileStructureTool();
8590
+ sessionId: newSession.id,
8591
+ permission: {
8592
+ permissionManager,
8593
+ promptFn
8594
+ },
8595
+ allowedDirectories: additionalDirectories
8596
+ })], cliWrapDeps)[0] : null;
8597
+ const findDefinitionTool = wrapTools([createFindDefinitionTool(additionalDirectories)], cliWrapDeps)[0];
8598
+ const getFileStructureTool = wrapTools([createGetFileStructureTool(additionalDirectories)], cliWrapDeps)[0];
8119
8599
  const workItemTools = config.preferences.enableWorkItemTools ? createWorkItemTools(new WorkItemsClient(apiClient)) : [];
8120
8600
  const cliTools = [
8121
8601
  agentDelegateTool,
@@ -8143,6 +8623,7 @@ function CliApp() {
8143
8623
  logger,
8144
8624
  reservedToolNames: [...loadedB4mTools, ...cliTools].map((t) => t.toolSchema.name).concat("tool_search")
8145
8625
  });
8626
+ rewireReservedNames(state, featureRegistry);
8146
8627
  const featureModuleToolNames = featureRegistry.getAllToolNames();
8147
8628
  if (featureModuleToolNames.length > 0) registerFeatureModuleTools(featureModuleToolNames);
8148
8629
  if (wsManager && featureRegistry.hasModules) featureRegistry.registerAllWsHandlers(wsManager);
@@ -8985,7 +9466,7 @@ function CliApp() {
8985
9466
  console.log(fellBack ? "✅ Session saved with local fallback handoff" : "✅ Session saved with refreshed handoff");
8986
9467
  };
8987
9468
  const handleCommand = async (command, args) => {
8988
- const customCommand = state.customCommandStore.getCommand(command);
9469
+ const customCommand = state.customCommandStore.getModelReachableCommand(command);
8989
9470
  if (customCommand) try {
8990
9471
  const sourceIcon = customCommand.source === "global" ? "🏠" : "📁";
8991
9472
  console.log(`${sourceIcon} Executing custom command: /${command}`);
@@ -9142,6 +9623,18 @@ function CliApp() {
9142
9623
  break;
9143
9624
  }
9144
9625
  case "trust": {
9626
+ if (args[0] === "folder") {
9627
+ const root = state.configStore.getProjectRealPath() ?? process.cwd();
9628
+ if (!await state.configStore.trustProject()) {
9629
+ console.log("\n⚠️ No project folder to trust here (no resolvable project root).\n");
9630
+ return;
9631
+ }
9632
+ state.customCommandStore.setProjectTrusted(true);
9633
+ await state.customCommandStore.reloadCommands().catch(() => {});
9634
+ console.log(`\n✅ Trusted project folder: ${root}`);
9635
+ console.log("Project skills are now loaded. Restart b4m to load repo agents and MCP servers.\n");
9636
+ return;
9637
+ }
9145
9638
  if (!state.permissionManager) {
9146
9639
  console.log("Permission manager not initialized");
9147
9640
  return;
@@ -9177,6 +9670,11 @@ function CliApp() {
9177
9670
  console.log("❌ No project found. Use \"global\" to save to ~/.bike4mind/config.json");
9178
9671
  return;
9179
9672
  }
9673
+ if (!state.configStore.isProjectTrusted()) {
9674
+ console.log("❌ This folder is not trusted, so a project-local trust would not take effect.");
9675
+ console.log(` Run /trust folder first, or choose "global" to trust '${toolToTrust}' everywhere.`);
9676
+ return;
9677
+ }
9180
9678
  try {
9181
9679
  await state.configStore.initProjectConfig();
9182
9680
  const existingLocal = await state.configStore.loadRawProjectLocalConfig() || {};
@@ -9197,6 +9695,11 @@ function CliApp() {
9197
9695
  console.log("❌ No project found. Use \"global\" to save to ~/.bike4mind/config.json");
9198
9696
  return;
9199
9697
  }
9698
+ if (!state.configStore.isProjectTrusted()) {
9699
+ console.log("❌ This folder is not trusted, so a team-project trust would not take effect.");
9700
+ console.log(` Run /trust folder first, or choose "global" to trust '${toolToTrust}' everywhere.`);
9701
+ return;
9702
+ }
9200
9703
  try {
9201
9704
  const existingProject = await state.configStore.loadRawProjectConfig() || {};
9202
9705
  const updatedProject = {
@@ -9235,6 +9738,15 @@ function CliApp() {
9235
9738
  break;
9236
9739
  }
9237
9740
  case "untrust": {
9741
+ if (args[0] === "folder") {
9742
+ const root = state.configStore.getProjectRealPath() ?? process.cwd();
9743
+ await state.configStore.untrustProject();
9744
+ state.customCommandStore.setProjectTrusted(false);
9745
+ await state.customCommandStore.reloadCommands().catch(() => {});
9746
+ console.log(`\n✅ Untrusted project folder: ${root}`);
9747
+ console.log("Repo config stays inert. Restart b4m to fully unload repo agents and MCP servers.\n");
9748
+ return;
9749
+ }
9238
9750
  if (!state.permissionManager) {
9239
9751
  console.log("Permission manager not initialized");
9240
9752
  return;
@@ -9506,7 +10018,7 @@ function CliApp() {
9506
10018
  const variantForCount = state.config?.preferences.promptVariant ?? "current";
9507
10019
  const corePromptTokens = tokenCounter.countTokens(buildSystemPrompt(variantForCount));
9508
10020
  const projectContextTokens = state.contextContent ? tokenCounter.countTokens(state.contextContent) : 0;
9509
- const commands = state.customCommandStore.getAllCommands();
10021
+ const commands = state.customCommandStore.getModelReachableCommands();
9510
10022
  const skillsSection = buildSkillsPromptSection(commands);
9511
10023
  const skillsTokens = skillsSection ? tokenCounter.countTokens(skillsSection) : 0;
9512
10024
  const agentDirectoryTokens = state.agentStore ? tokenCounter.countTokens(state.agentStore.getDirectoryContext()) : 0;
@@ -9568,6 +10080,7 @@ function CliApp() {
9568
10080
  console.log("\n📁 Project Configuration:\n");
9569
10081
  if (projectDir) {
9570
10082
  console.log(`Project Directory: ${projectDir}/.bike4mind/`);
10083
+ if (!state.configStore.isProjectTrusted()) console.log("⚠️ Folder not trusted - team/local config below is NOT applied. Run /trust folder.");
9571
10084
  console.log("");
9572
10085
  const projectConfig = await state.configStore.loadRawProjectConfig();
9573
10086
  const localConfig = await state.configStore.loadRawProjectLocalConfig();
@@ -9597,9 +10110,9 @@ function CliApp() {
9597
10110
  break;
9598
10111
  }
9599
10112
  case "commands": {
9600
- const customCommands = state.customCommandStore.getAllCommands();
9601
- const globalCommands = state.customCommandStore.getCommandsBySource("global");
9602
- const projectCommands = state.customCommandStore.getCommandsBySource("project");
10113
+ const customCommands = state.customCommandStore.getModelReachableCommands();
10114
+ const globalCommands = customCommands.filter((cmd) => cmd.source === "global");
10115
+ const projectCommands = customCommands.filter((cmd) => cmd.source === "project");
9603
10116
  console.log("\n📝 Custom Commands:\n");
9604
10117
  if (customCommands.length === 0) {
9605
10118
  console.log("No custom commands found.");
@@ -9769,7 +10282,7 @@ function CliApp() {
9769
10282
  }
9770
10283
  break;
9771
10284
  }
9772
- case "sandbox:enable": {
10285
+ case "sandbox:enable":
9773
10286
  if (!state.sandboxOrchestrator) {
9774
10287
  console.log("Sandbox not initialized");
9775
10288
  break;
@@ -9781,34 +10294,25 @@ function CliApp() {
9781
10294
  state.sandboxOrchestrator.setMode("auto-allow");
9782
10295
  state.permissionManager?.setSandboxState("auto-allow", state.sandboxOrchestrator.isActive());
9783
10296
  if (state.sandboxOrchestrator.getConfig().network.enabled) {
9784
- await state.sandboxOrchestrator.startProxy();
10297
+ const on = await state.sandboxOrchestrator.setNetworkEnabled(true);
9785
10298
  const pm = state.sandboxOrchestrator.getProxyManager();
9786
- if (pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
10299
+ if (on && pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
10300
+ else console.log("⚠️ Network filtering requested but the proxy failed to start; egress disabled");
9787
10301
  }
9788
- const config = await state.configStore.get();
9789
- await state.configStore.save({
9790
- ...config,
9791
- sandbox: { ...state.sandboxOrchestrator.getConfig() }
9792
- });
10302
+ await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
9793
10303
  console.log("Sandbox enabled (auto-allow mode)");
9794
10304
  break;
9795
- }
9796
- case "sandbox:disable": {
10305
+ case "sandbox:disable":
9797
10306
  if (!state.sandboxOrchestrator) {
9798
10307
  console.log("Sandbox not initialized");
9799
10308
  break;
9800
10309
  }
9801
- await state.sandboxOrchestrator.stopProxy();
10310
+ await state.sandboxOrchestrator.setNetworkEnabled(false);
9802
10311
  state.sandboxOrchestrator.setMode("disabled");
9803
10312
  state.permissionManager?.setSandboxState("disabled", false);
9804
- const disableConfig = await state.configStore.get();
9805
- await state.configStore.save({
9806
- ...disableConfig,
9807
- sandbox: { ...state.sandboxOrchestrator.getConfig() }
9808
- });
10313
+ await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
9809
10314
  console.log("Sandbox disabled");
9810
10315
  break;
9811
- }
9812
10316
  case "sandbox:mode": {
9813
10317
  if (!state.sandboxOrchestrator) {
9814
10318
  console.log("Sandbox not initialized");
@@ -9825,47 +10329,23 @@ function CliApp() {
9825
10329
  }
9826
10330
  state.sandboxOrchestrator.setMode(modeArg);
9827
10331
  state.permissionManager?.setSandboxState(modeArg, state.sandboxOrchestrator.isActive());
9828
- const modeConfig = await state.configStore.get();
9829
- await state.configStore.save({
9830
- ...modeConfig,
9831
- sandbox: { ...state.sandboxOrchestrator.getConfig() }
9832
- });
10332
+ await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
9833
10333
  console.log(`Sandbox mode set to: ${modeArg}`);
9834
10334
  break;
9835
10335
  }
9836
- case "sandbox:trust-domain": {
9837
- if (!state.sandboxOrchestrator) {
9838
- console.log("Sandbox not initialized");
9839
- break;
9840
- }
9841
- if (args.length === 0) {
9842
- console.log("Usage: /sandbox:trust-domain <domain> [...]");
9843
- break;
9844
- }
9845
- const proxyMgr = state.sandboxOrchestrator.getProxyManager();
9846
- if (!proxyMgr) {
9847
- console.log("Network proxy not initialized");
9848
- break;
9849
- }
9850
- for (const domain of args) {
9851
- proxyMgr.addAllowedDomain(domain);
9852
- console.log(` Added: ${domain}`);
9853
- }
9854
- const trustDomainConfig = await state.configStore.get();
9855
- const currentSandboxConfig = state.sandboxOrchestrator.getConfig();
9856
- await state.configStore.save({
9857
- ...trustDomainConfig,
9858
- sandbox: {
9859
- ...currentSandboxConfig,
9860
- network: {
9861
- ...currentSandboxConfig.network,
9862
- allowedDomains: proxyMgr.getAllowedDomains()
9863
- }
9864
- }
9865
- });
9866
- console.log(`Trusted ${args.length} domain(s)`);
10336
+ case "sandbox:network":
10337
+ console.log(await handleSandboxNetworkCommand({
10338
+ orchestrator: state.sandboxOrchestrator,
10339
+ configStore: state.configStore,
10340
+ permissionManager: state.permissionManager
10341
+ }, args[0]));
10342
+ break;
10343
+ case "sandbox:trust-domain":
10344
+ console.log(await handleSandboxTrustDomainCommand({
10345
+ orchestrator: state.sandboxOrchestrator,
10346
+ configStore: state.configStore
10347
+ }, args));
9867
10348
  break;
9868
- }
9869
10349
  case "sandbox:domains": {
9870
10350
  if (!state.sandboxOrchestrator) {
9871
10351
  console.log("Sandbox not initialized");
@@ -10039,7 +10519,8 @@ function CliApp() {
10039
10519
  * Handle saving config from the interactive editor
10040
10520
  */
10041
10521
  const handleSaveConfig = async (updatedConfig, options) => {
10042
- await state.configStore.save(updatedConfig);
10522
+ const patch = buildGlobalConfigPatch(state.config, updatedConfig);
10523
+ await state.configStore.save(patch);
10043
10524
  const modelChanged = state.config?.defaultModel !== updatedConfig.defaultModel;
10044
10525
  const featuresChanged = JSON.stringify(state.config?.features ?? {}) !== JSON.stringify(updatedConfig.features ?? {});
10045
10526
  let newFeatureRegistry = state.featureRegistry;
@@ -10056,7 +10537,9 @@ function CliApp() {
10056
10537
  logger,
10057
10538
  reservedToolNames: baseTools.map((t) => t.toolSchema.name)
10058
10539
  });
10059
- newFeatureRegistry = rebuilt.registry;
10540
+ const rebuiltRegistry = rebuilt.registry;
10541
+ newFeatureRegistry = rebuiltRegistry;
10542
+ rewireReservedNames(state, rebuiltRegistry);
10060
10543
  for (const skippedPlugin of rebuilt.skipped) console.error(`\n\x1b[33m⚠️ Plugin ${skippedPlugin.name} skipped: ${skippedPlugin.reason}\x1b[0m`);
10061
10544
  const newToolNames = newFeatureRegistry.getAllToolNames();
10062
10545
  if (newToolNames.length > 0) registerFeatureModuleTools(newToolNames);
@@ -10070,7 +10553,7 @@ function CliApp() {
10070
10553
  state.agent.setSystemPrompt(buildSystemPrompt(updatedConfig.preferences.promptVariant ?? "current", {
10071
10554
  contextContent: state.contextContent,
10072
10555
  agentStore: state.agentStore || void 0,
10073
- customCommands: state.customCommandStore.getAllCommands(),
10556
+ customCommands: state.customCommandStore.getModelReachableCommands(),
10074
10557
  enableSkillTool: updatedConfig.preferences.enableSkillTool !== false,
10075
10558
  enableDynamicAgentCreation: updatedConfig.preferences.enableDynamicAgentCreation === true,
10076
10559
  additionalDirectories: state.additionalDirectories,
@@ -10127,10 +10610,14 @@ function CliApp() {
10127
10610
  isBusy: () => useCliStore.getState().isThinking,
10128
10611
  saveModel: async (modelId) => {
10129
10612
  if (!state.config) throw new Error("no CLI config is loaded");
10130
- await handleSaveConfig({
10131
- ...state.config,
10132
- defaultModel: modelId
10133
- }, { skipModelApply: true });
10613
+ await state.configStore.save({ defaultModel: modelId });
10614
+ setState((prev) => prev.config ? {
10615
+ ...prev,
10616
+ config: {
10617
+ ...prev.config,
10618
+ defaultModel: modelId
10619
+ }
10620
+ } : prev);
10134
10621
  },
10135
10622
  applyToSession: applyModelToSession,
10136
10623
  log: (message) => console.log(message),
@@ -10155,6 +10642,28 @@ function CliApp() {
10155
10642
  }
10156
10643
  });
10157
10644
  }
10645
+ if (state.folderTrustPrompt) return /* @__PURE__ */ React.createElement(FolderTrustPrompt, {
10646
+ projectRoot: state.folderTrustPrompt.projectRoot,
10647
+ onSelect: (choice) => {
10648
+ (async () => {
10649
+ try {
10650
+ if (choice === "trust") {
10651
+ await state.configStore.trustProject();
10652
+ console.log("\n✅ Folder trusted. Loading its config, agents, skills and MCP servers.\n");
10653
+ } else console.log("\nℹ️ Project config stays inert this session. Use /trust folder to trust it later.\n");
10654
+ } finally {
10655
+ setState((prev) => ({
10656
+ ...prev,
10657
+ folderTrustPrompt: null
10658
+ }));
10659
+ init().catch((err) => {
10660
+ console.error("\n❌ Initialization failed:", err instanceof Error ? err.message : String(err), "\n");
10661
+ exit();
10662
+ });
10663
+ }
10664
+ })();
10665
+ }
10666
+ });
10158
10667
  const currentSession = useCliStore.getState().session;
10159
10668
  if (state.rewindSelector && currentSession) return /* @__PURE__ */ React.createElement(RewindSelector, {
10160
10669
  messages: currentSession.messages,