vexp-cli 3.3.8 → 3.4.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.
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import { TextDecoder } from "util";
4
4
  import { vexpHome } from "./state-home.js";
5
5
  import { canonicalWorkspaceRoot } from "./socket-path.js";
6
+ import { linkedWorktreeMain, worktreeConsentSource } from "./git-worktree.js";
6
7
  export function agentConsentPath() {
7
8
  return path.join(vexpHome(), ".vexp", "agent-consent.json");
8
9
  }
@@ -153,8 +154,52 @@ export function decisionForKey(consent, key, agent) {
153
154
  return "no";
154
155
  return undefined;
155
156
  }
157
+ /**
158
+ * The answer for `agent` in the project at `root`. A linked git worktree
159
+ * with no answer of its own for the agent takes its main checkout's (the
160
+ * rule vexp-core's worktree_consent_source and both MCP servers apply to a
161
+ * worktree's index: a main checkout set up for vexp). Each `git worktree
162
+ * add` gave a fresh key with no answers, so a worktree asked again about
163
+ * every tool the main checkout had answered for (field report,
164
+ * 2026-11-07). An answer given IN the worktree is keyed by the worktree, as
165
+ * before, and wins. "Never" is machine-wide and needs no inheriting.
166
+ * Lockstep with the extension's decisionFor.
167
+ */
156
168
  export function decisionFor(consent, root, agent, opts) {
157
- return decisionForKey(consent, consentKey(root, opts), agent);
169
+ return decisionsFor(consent, root, opts)(agent);
170
+ }
171
+ /** An answer recorded for a project itself ("yes" or "no"), not through "never". */
172
+ function ownAnswer(consent, key, agent) {
173
+ const here = consent.workspaces[key]?.[agent];
174
+ return here === "yes" || here === "no";
175
+ }
176
+ /**
177
+ * decisionFor for every agent of one project, with the worktree's main
178
+ * checkout looked up once: planAgentSetup asks about every agent.
179
+ */
180
+ export function decisionsFor(consent, root, opts) {
181
+ const key = consentKey(root, opts);
182
+ const main = worktreeConsentSource(root);
183
+ const mainKey = main ? consentKey(main, opts) : undefined;
184
+ return (agent) => mainKey !== undefined && !ownAnswer(consent, key, agent)
185
+ ? decisionForKey(consent, mainKey, agent)
186
+ : decisionForKey(consent, key, agent);
187
+ }
188
+ /**
189
+ * The agents whose answer in the linked worktree at `root` comes from its
190
+ * main checkout (decisionFor): the worktree has none of its own for them
191
+ * and the main checkout has a "yes" or a "no". undefined when `root` takes
192
+ * nothing from another checkout. What `vexp doctor` says the answers come
193
+ * from.
194
+ */
195
+ export function inheritedAnswers(consent, root, opts) {
196
+ const main = worktreeConsentSource(root);
197
+ if (!main)
198
+ return undefined;
199
+ const key = consentKey(root, opts);
200
+ const mainKey = consentKey(main, opts);
201
+ const agents = Object.keys(consent.workspaces[mainKey] ?? {}).filter((agent) => ownAnswer(consent, mainKey, agent) && !ownAnswer(consent, key, agent));
202
+ return agents.length > 0 ? { main, agents } : undefined;
158
203
  }
159
204
  /** Temp file + rename, so a reader never sees half a file. */
160
205
  function writeAtomic(file, content) {
@@ -236,9 +281,74 @@ export function recordAgentConsent(root, agent, decision, opts) {
236
281
  else
237
282
  delete workspaces[key];
238
283
  const out = { ...raw, version: typeof raw.version === "number" ? raw.version : 1, workspaces, never };
284
+ pruneGoneWorktrees(out, workspaces, key, linkedWorktreeMain(root));
239
285
  writeAtomic(file, JSON.stringify(out, null, 2) + "\n");
240
286
  }
241
287
  catch {
242
288
  /* see above */
243
289
  }
244
290
  }
291
+ /**
292
+ * The keys of linked worktrees that answers were saved for, under
293
+ * `worktrees`, each with its main checkout: a worktree is made and removed
294
+ * by the dozen (an agent harness makes one per task), and the key of each
295
+ * removed one stayed in this file for good. At each save, a worktree that
296
+ * is gone for good loses its answers (worktreeGone). One that is only out
297
+ * of reach keeps them: to fs.existsSync, an unplugged disk, an offline
298
+ * share, a dropped mount or a folder this user may not enter all looked
299
+ * like a removed worktree, and once the volume was back a "Not here" given
300
+ * in the worktree became its main checkout's "yes" (review, 2026-11-07).
301
+ * Only keys saved as a worktree's go. The list is written only while it
302
+ * names one, so a file with no worktree in it keeps its bytes. Lockstep
303
+ * with the extension.
304
+ */
305
+ function pruneGoneWorktrees(out, workspaces, key, main) {
306
+ const listed = {};
307
+ if (isPlainObject(out.worktrees)) {
308
+ for (const [k, m] of Object.entries(out.worktrees))
309
+ if (typeof m === "string")
310
+ listed[k] = m;
311
+ }
312
+ if (main !== null && workspaces[key] !== undefined)
313
+ listed[key] = main;
314
+ for (const [k, m] of Object.entries(listed)) {
315
+ if (workspaces[k] === undefined)
316
+ delete listed[k];
317
+ else if (worktreeGone(k, m)) {
318
+ delete workspaces[k];
319
+ delete listed[k];
320
+ }
321
+ }
322
+ if (Object.keys(listed).length > 0)
323
+ out.worktrees = listed;
324
+ else
325
+ delete out.worktrees;
326
+ }
327
+ /**
328
+ * Removed, not just out of reach: the worktree folder does not exist
329
+ * (ENOENT or ENOTDIR, not EACCES, EIO or ENOTCONN), while the folder that
330
+ * held it and its main checkout's .git do. An unplugged disk, an offline
331
+ * share or an unmounted volume takes the parent folder or the main checkout
332
+ * with it; a worktree beside its main checkout at the root of a Linux mount
333
+ * leaves only the empty mount point as its parent, and the main checkout's
334
+ * .git is gone with the mount. Lockstep with the extension.
335
+ */
336
+ function worktreeGone(k, main) {
337
+ try {
338
+ fs.statSync(k);
339
+ return false;
340
+ }
341
+ catch (e) {
342
+ const code = e.code;
343
+ if (code !== "ENOENT" && code !== "ENOTDIR")
344
+ return false;
345
+ }
346
+ try {
347
+ fs.statSync(path.dirname(k));
348
+ fs.statSync(path.join(main, ".git"));
349
+ return true;
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ }
package/dist/cli.js CHANGED
@@ -8,11 +8,11 @@ import * as fs from "fs";
8
8
  import * as net from "net";
9
9
  import { checkbox, confirm } from "@inquirer/prompts";
10
10
  import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
11
- import { getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, setInterventionMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets, takeCodexHookApprovals, codexHookApprovalStep, planAgentSetup, nonInteractiveAgentChoice, setupAgentsCommand } from "./agent-config.js";
11
+ import { getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, setInterventionMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets, takeCodexHookApprovals, takeSetupNotes, codexHookApprovalStep, planAgentSetup, nonInteractiveAgentChoice, setupAgentsCommand, deadAntigravityEntryFiles } from "./agent-config.js";
12
12
  import { recordAgentConsent } from "./agent-consent.js";
13
13
  import { CLI_VERSION } from "./version.js";
14
14
  import { deactivateLicense, readLicenseLimits, readDeviceBlocked, refreshFromDisk, } from "./license.js";
15
- import { runActivation, planLabel as licensePlanLabel, isLifetimePlan, printCheckInStatus, } from "./license-activate.js";
15
+ import { runActivation, planLabel as licensePlanLabel, isLifetimePlan, printCheckInStatus, allToolsLines, } from "./license-activate.js";
16
16
  import { checkForUpdate } from "./update-check.js";
17
17
  import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
18
18
  import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
@@ -26,6 +26,7 @@ import { parseDaemonRows, daemonsToStop, engineSaid, daemonWasStopped, stopNotes
26
26
  import { replaceOlderDaemon, parseEngineVersion, commandMayReplaceDaemon } from "./daemon-upgrade.js";
27
27
  import { mutableOutput, askSecretOn } from "./secret-prompt.js";
28
28
  import { isTraceEnabled } from "./trace.js";
29
+ import { DEVMIND_REQUIREMENT, LLM_MENU, bannerText, llmBannerLines, llmMenuChoice, parseSetupLlmStatus } from "./devmind.js";
29
30
  import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
30
31
  const program = new Command();
31
32
  program
@@ -46,7 +47,8 @@ Daemons:
46
47
  [daemon]
47
48
  idle_timeout_minutes = 30
48
49
 
49
- VEXP_IDLE_TIMEOUT_MINUTES sets it for one daemon. A daemon an editor window started stops
50
+ VEXP_IDLE_TIMEOUT_MINUTES sets it for one daemon. Unset, a daemon an agent's session started
51
+ stops after 30 minutes without use (0 turns that off). A daemon an editor window started stops
50
52
  with that window instead. vexp daemons lists them; vexp doctor says whether the setting is on.
51
53
  `;
52
54
  program.addHelpText("after", IDLE_STOP_HELP);
@@ -782,18 +784,23 @@ program
782
784
  .command("compress <file>")
783
785
  .description("Compress a markdown/text file using prose compression (backup to <name>.original.md)")
784
786
  .option("--level <lvl>", "Compression level: off | lite | full | ultra", "full")
787
+ .option("--llm", "Use the local LLM (DevMind) for this file: needs the model installed (or an [llm] endpoint), and runs also with LLM compression switched off, for this file only. Without a usable GPU it runs on the CPU as an explicit one-off, slower; falls back to rule-based compression when no model can be used")
785
788
  .action(async (file, opts) => {
786
789
  const binaryPath = ensureBinary();
787
- runBinary(binaryPath, ["compress", file, "--level", opts.level]);
790
+ runBinary(binaryPath, ["compress", file, "--level", opts.level, ...(opts.llm ? ["--llm"] : [])]);
788
791
  });
789
792
  program
790
793
  .command("setup-llm")
791
- .description("Configure optional local LLM compression (vexp-devmind). Runs interactive wizard by default.")
792
- .option("--status", "Print current LLM config and readiness")
793
- .option("--disable", "Disable LLM compression without removing model files")
794
- .option("--install", "Non-interactive install (for CI / first-run)")
794
+ // The requirement is said before anyone downloads, and every flag says
795
+ // what it does (review, 2026-11-08). The flags are vexp-core's, passed
796
+ // through. Lockstep with packages/vexp-core/src/main.rs (SetupLlm).
797
+ .description(`Configure the optional local LLM compression (DevMind, vexp-devmind). ${DEVMIND_REQUIREMENT} Runs the interactive wizard by default.`)
798
+ .option("--status", "Print the LLM config, whether DevMind runs on this machine (and why not), the hardware, and what the workspace daemon runs")
799
+ .option("--disable", "Turn LLM compression off; the model files stay on disk (see --uninstall)")
800
+ .option("--install", "Install without prompts: checks first that DevMind can run here and downloads nothing when it cannot; on NVIDIA fetches the CUDA plugin and runtime (~1 GB) before the model (~3.5 GB)")
795
801
  .option("--check-updates", "Check for model updates without downloading")
796
- .option("--enable", "Enable a previously-installed LLM without re-downloading anything")
802
+ .option("--enable", "Turn a previously installed model back on without downloading anything; refuses where DevMind cannot run")
803
+ .option("--uninstall", "Remove what vexp downloaded for DevMind (the model, the CUDA plugin and runtime, the GPU markers) and turn it off")
797
804
  .action(async (opts) => {
798
805
  const binaryPath = ensureBinary();
799
806
  const args = ["setup-llm"];
@@ -807,6 +814,8 @@ program
807
814
  args.push("--check-updates");
808
815
  if (opts.enable)
809
816
  args.push("--enable");
817
+ if (opts.uninstall)
818
+ args.push("--uninstall");
810
819
  runBinary(binaryPath, args);
811
820
  });
812
821
  program
@@ -891,6 +900,20 @@ function printCodexHookApprovals() {
891
900
  console.log(chalk.yellow(` ! ${codexHookApprovalStep(root)}`));
892
901
  }
893
902
  }
903
+ /**
904
+ * What this run left as it is on purpose (takeSetupNotes): a file committed
905
+ * to the repository that only this checkout's path would have changed, or
906
+ * that a cleanup would have deleted; .vscode/mcp.json left without vexp
907
+ * because .mcp.json defines it. Said in the words the extension logs.
908
+ */
909
+ function printSetupNotes() {
910
+ const notes = takeSetupNotes();
911
+ if (notes.length === 0)
912
+ return;
913
+ console.log("");
914
+ for (const note of notes)
915
+ console.log(chalk.dim(` ${note}`));
916
+ }
894
917
  function agentMark(plan, agent) {
895
918
  const pre = plan.configure.includes(agent);
896
919
  const kind = plan.kinds[agent];
@@ -1067,6 +1090,14 @@ program
1067
1090
  console.log(chalk.dim(` - ${line}`));
1068
1091
  }
1069
1092
  }
1093
+ // Every setup takes vexp's dead Antigravity entries out, whichever
1094
+ // agents it sets up; with Antigravity chosen its lines name them.
1095
+ const dead = selectedNames.includes("Antigravity") ? [] : deadAntigravityEntryFiles();
1096
+ if (dead.length > 0) {
1097
+ console.log(chalk.dim(" machine-wide:"));
1098
+ for (const file of dead)
1099
+ console.log(chalk.dim(` - ${file} (vexp's old unpinned entry removed)`));
1100
+ }
1070
1101
  console.log(chalk.dim("\n Nothing was written (dry run)."));
1071
1102
  }
1072
1103
  else {
@@ -1132,14 +1163,9 @@ program
1132
1163
  }
1133
1164
  // Agents selected whose config target does not exist on this
1134
1165
  // machine yet: rules land, tools do not, and nothing said so.
1135
- const unreachable = takeUnreachableTargets();
1136
- if (unreachable.length > 0) {
1137
- console.log("");
1138
- console.log(chalk.yellow(` ⚠ ${unreachable.length} agent config(s) could not be written yet:`));
1139
- for (const f of unreachable)
1140
- console.log(chalk.yellow(` ${f}`));
1141
- }
1166
+ printUnreachableTargets();
1142
1167
  printCodexHookApprovals();
1168
+ printSetupNotes();
1143
1169
  }
1144
1170
  }
1145
1171
  }
@@ -1275,9 +1301,32 @@ async function runSetupAgents(dir) {
1275
1301
  console.log(chalk.dim(` → ${mcpFile}`));
1276
1302
  }
1277
1303
  }
1304
+ printUnreachableTargets();
1278
1305
  printCodexHookApprovals();
1306
+ printSetupNotes();
1279
1307
  console.log(chalk.green(`\n ✓ ${result.agents.length} agent(s) configured\n`));
1280
1308
  }
1309
+ /**
1310
+ * Agents selected whose config target cannot be written (none on this
1311
+ * machine yet, one setup refuses to write through, such as Antigravity's
1312
+ * .agents/mcp_config.json when it is a link to the machine-wide file, a
1313
+ * pinned project file that leads out of the project through a link or is
1314
+ * a link to nothing (projectWriteLanding), or a file whose mcpServers /
1315
+ * servers / mcp is not an object, left untouched):
1316
+ * rules land, tools do not, and nothing said so. Every setup path prints
1317
+ * it: `vexp setup-agents` and the REPL's setup ended with a check mark
1318
+ * over a refusal that only `vexp setup` showed. Not "yet": a file left
1319
+ * untouched stays so until the user fixes it.
1320
+ */
1321
+ function printUnreachableTargets() {
1322
+ const unreachable = takeUnreachableTargets();
1323
+ if (unreachable.length === 0)
1324
+ return;
1325
+ console.log("");
1326
+ console.log(chalk.yellow(` ⚠ ${unreachable.length} agent config(s) could not be written:`));
1327
+ for (const f of unreachable)
1328
+ console.log(chalk.yellow(` ${f}`));
1329
+ }
1281
1330
  /** Full setup from REPL — reuses the existing readline to avoid stdin conflicts. */
1282
1331
  async function runSetupInteractive(rl) {
1283
1332
  const workspaceRoot = process.cwd();
@@ -1340,7 +1389,9 @@ async function runSetupInteractive(rl) {
1340
1389
  if (result.mcpConfigs.length > 0) {
1341
1390
  console.log(chalk.dim(`\n MCP configs: ${result.mcpConfigs.join(", ")}`));
1342
1391
  }
1392
+ printUnreachableTargets();
1343
1393
  printCodexHookApprovals();
1394
+ printSetupNotes();
1344
1395
  console.log(chalk.green(`\n ✓ Setup complete — ${result.agents.length} agent(s) configured\n`));
1345
1396
  }
1346
1397
  // Login autostart — opt-in only. The daemon already cold-starts on demand,
@@ -1425,10 +1476,7 @@ program
1425
1476
  console.log(` Email: ${limits.email}`);
1426
1477
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1427
1478
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1428
- console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1429
- if (limits.allTools) {
1430
- console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
1431
- }
1479
+ allToolsLines(limits.allTools).forEach((l, n) => console.log(` ${n === 0 ? "" : " ".repeat(11)}${l}`));
1432
1480
  if (limits.renewsAt) {
1433
1481
  console.log(ltd
1434
1482
  ? ` Renewal: none — lifetime licence (local token auto-refreshes, ` +
@@ -1573,6 +1621,7 @@ program
1573
1621
  console.log(chalk.dim(` Codex starts vexp in each session's own folder; 'vexp use ${JSON.stringify(resolved)} --pin' would make every session use this project instead`));
1574
1622
  }
1575
1623
  printCodexHookApprovals();
1624
+ printSetupNotes();
1576
1625
  });
1577
1626
  // ────────────────────────────────────────────────────
1578
1627
  // Version
@@ -1593,7 +1642,7 @@ const MENU_CONFIG = [
1593
1642
  { key: "3", label: "setup", hint: "Full setup (index + agents + hooks)", description: "One-command bootstrap: indexes the codebase, detects AI agents,\n writes MCP configs, installs git hooks." },
1594
1643
  { key: "4", label: "setup-agents", hint: "Choose which AI agents to configure", description: "Select agents (Claude, Cursor, Windsurf, Copilot, …) to configure.\n Creates config directories and writes MCP settings for each." },
1595
1644
  { key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/restart/status/logs)", description: "Controls the background daemon:\n start → launches the daemon (indexes + watches for changes)\n stop → stops the running daemon\n restart → stop + start (reloads workspace.json, indexes new repos)\n status → shows node/edge/file counts\n logs → prints .vexp/vexp.log" },
1596
- { key: "6", label: "setup-llm", hint: "Configure local LLM compression (vexp-devmind)", description: "Install, update, or disable the optional local LLM (vexp-devmind).\n Runs entirely on your machine (~3.5 GB download).\n Enables smart prompt preprocessing and higher-quality memory compression." },
1645
+ { key: "6", label: "setup-llm", hint: "Configure local LLM compression (vexp-devmind)", description: "Install, update, disable or remove the optional local LLM (vexp-devmind).\n Runs entirely on your machine (~3.5 GB download).\n Enables smart prompt preprocessing and higher-quality memory compression.\n DevMind needs an NVIDIA GPU (Linux, Windows) or an Apple Silicon Mac;\n without one it stays off and vexp works the same." },
1597
1646
  ];
1598
1647
  // ── Sub-menu: Explore & Analyze ──
1599
1648
  const MENU_EXPLORE = [
@@ -1683,7 +1732,10 @@ async function printBanner() {
1683
1732
  console.log(chalk.dim(" → type '1 > 5 > start' to launch it, or `vexp daemon-cmd start`"));
1684
1733
  }
1685
1734
  }
1686
- // ── LLM status line (3 states: active / installed-not-active / not installed) ──
1735
+ // ── LLM status line ──
1736
+ // From the engine's --status: "(active)" only when the workspace daemon
1737
+ // runs the model, and DevMind off on this machine said calmly, with no
1738
+ // download offered (review, 2026-11-08; see llmBannerLines).
1687
1739
  try {
1688
1740
  const llmBin = ensureBinary();
1689
1741
  const stdout = execFileSync(llmBin, ["setup-llm", "--status"], {
@@ -1691,20 +1743,13 @@ async function printBanner() {
1691
1743
  encoding: "utf-8",
1692
1744
  stdio: ["pipe", "pipe", "pipe"],
1693
1745
  env: binaryEnv(llmBin),
1746
+ // Its "Daemon now" line names the daemon of the folder it runs in:
1747
+ // the one the daemon line above reports.
1748
+ cwd: eff?.root,
1694
1749
  });
1695
- const operational = /Operational:\s*true/i.test(stdout);
1696
- const installed = /Installed:\s*true/i.test(stdout);
1697
- const model = stdout.match(/Model:\s*(.+)/)?.[1]?.trim() ?? "";
1698
- if (operational) {
1699
- console.log(chalk.green(` ● LLM: ${model || "local"} (active)`));
1700
- }
1701
- else if (installed) {
1702
- console.log(chalk.yellow(" ○ LLM: installed but not active"));
1703
- console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --enable`"));
1704
- }
1705
- else {
1706
- console.log(chalk.dim(" ○ LLM: not installed"));
1707
- console.log(chalk.dim(" → type '1 > 6 > install' to download (~3.5 GB), or `vexp setup-llm --install`"));
1750
+ for (const l of llmBannerLines(parseSetupLlmStatus(stdout))) {
1751
+ const text = bannerText(l);
1752
+ console.log(l.tone === "green" ? chalk.green(text) : l.tone === "yellow" ? chalk.yellow(text) : chalk.dim(text));
1708
1753
  }
1709
1754
  }
1710
1755
  catch {
@@ -1929,16 +1974,17 @@ async function executeCommand(label, rl, outCtl) {
1929
1974
  case "setup-llm": {
1930
1975
  // Sticky sub-sub-menu.
1931
1976
  const llmBinary = ensureBinary();
1977
+ // One table prints the menu, reads the answer and names the keys
1978
+ // in the banner's hints: those said '1 > 6 > start', which this
1979
+ // prompt refused (review, 2026-11-08).
1932
1980
  const printLlmMenu = () => {
1933
1981
  console.log("");
1934
1982
  console.log(chalk.cyan(" ── Local LLM (vexp-devmind) ──"));
1935
1983
  console.log(chalk.dim(" All processing runs locally — no data leaves your machine."));
1984
+ console.log(chalk.dim(` ${DEVMIND_REQUIREMENT}`));
1936
1985
  console.log("");
1937
- console.log(` ${chalk.bold("1")} Install / Reinstall ${chalk.dim("(~3.5 GB download)")}`);
1938
- console.log(` ${chalk.bold("2")} Start ${chalk.dim("(enable without re-downloading)")}`);
1939
- console.log(` ${chalk.bold("3")} Check for updates ${chalk.dim("(no download if up to date)")}`);
1940
- console.log(` ${chalk.bold("4")} Status ${chalk.dim("(current config + hardware)")}`);
1941
- console.log(` ${chalk.bold("5")} Disable ${chalk.dim("(keep files, stop using LLM)")}`);
1986
+ for (const e of LLM_MENU)
1987
+ console.log(` ${chalk.bold(e.key)} ${e.label.padEnd(20)} ${chalk.dim(e.note)}`);
1942
1988
  console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
1943
1989
  console.log("");
1944
1990
  };
@@ -1949,27 +1995,22 @@ async function executeCommand(label, rl, outCtl) {
1949
1995
  continue;
1950
1996
  if (c === "b" || c === "back")
1951
1997
  break;
1952
- switch (c) {
1953
- case "1":
1954
- console.log(chalk.dim(" Starting LLM setup (this may take several minutes)…\n"));
1955
- runBinary(llmBinary, ["setup-llm", "--install"]);
1956
- break;
1957
- case "2":
1958
- runBinary(llmBinary, ["setup-llm", "--enable"]);
1959
- break;
1960
- case "3":
1961
- runBinary(llmBinary, ["setup-llm", "--check-updates"]);
1962
- break;
1963
- case "4":
1964
- runBinary(llmBinary, ["setup-llm", "--status"]);
1965
- break;
1966
- case "5":
1967
- runBinary(llmBinary, ["setup-llm", "--disable"]);
1968
- break;
1969
- default:
1970
- console.log(chalk.dim(" Unknown option. Use 1-5 or 'b' to go back."));
1998
+ const choice = llmMenuChoice(c);
1999
+ if (!choice) {
2000
+ console.log(chalk.dim(` Unknown option. Use 1-${LLM_MENU.length} or 'b' to go back.`));
2001
+ continue;
2002
+ }
2003
+ if (choice.flag === "--install") {
2004
+ console.log(chalk.dim(" Starting LLM setup (this may take several minutes)…\n"));
2005
+ }
2006
+ if (choice.flag === "--uninstall") {
2007
+ const yes = (await ask(rl, chalk.cyan(" Remove the DevMind model and the CUDA files vexp downloaded? (y/N) "))).trim().toLowerCase();
2008
+ if (yes !== "y" && yes !== "yes") {
2009
+ printLlmMenu();
1971
2010
  continue;
2011
+ }
1972
2012
  }
2013
+ runBinary(llmBinary, ["setup-llm", choice.flag]);
1973
2014
  printLlmMenu();
1974
2015
  }
1975
2016
  break;
@@ -2128,10 +2169,7 @@ async function executeCommand(label, rl, outCtl) {
2128
2169
  console.log(` Email: ${limits.email}`);
2129
2170
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
2130
2171
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
2131
- console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
2132
- if (limits.allTools) {
2133
- console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
2134
- }
2172
+ allToolsLines(limits.allTools).forEach((l, n) => console.log(` ${n === 0 ? "" : " ".repeat(11)}${l}`));
2135
2173
  if (limits.renewsAt) {
2136
2174
  console.log(ltd
2137
2175
  ? ` Renewal: none — lifetime licence (token auto-refreshes, valid to ${limits.renewsAt.toLocaleDateString()})`
@@ -187,8 +187,13 @@ export function parseEngineVersion(stdout) {
187
187
  return last ? last : undefined;
188
188
  }
189
189
  /**
190
- * The folder `daemon-cmd` works on when run in `dir`: the nearest one with a
191
- * `.git` at or above it, else `dir` itself (find_repo_root in vexp-core).
190
+ * The folder `daemon-cmd` works on when run in `dir` (daemon_cmd_root in
191
+ * vexp-core): the nearest one with a `.git` at or above it, unless that one
192
+ * was never set up for vexp and a folder between it and `dir` was, which
193
+ * is then the nearest such folder; with no `.git` above, `dir` itself. Set
194
+ * up is the engine's is_workspace_marker: one of its consent files in the
195
+ * folder's .vexp/, and neither the home directory nor a filesystem root
196
+ * (forbidden_workspace_reason refuses both).
192
197
  */
193
198
  export function daemonCmdRoot(dir) {
194
199
  let start = path.resolve(dir);
@@ -198,10 +203,16 @@ export function daemonCmdRoot(dir) {
198
203
  catch {
199
204
  /* not there: the spelling given */
200
205
  }
206
+ const setUp = (d) => (process.env.VEXP_ALLOW_HOME_WORKSPACE === "1" ||
207
+ (upgradeAttemptKey(d) !== upgradeAttemptKey(vexpHome()) && path.dirname(d) !== d)) &&
208
+ ["manifest.json", "vexp.toml", "workspace.json", "index.db"].some((f) => fs.existsSync(path.join(d, ".vexp", f)));
209
+ const below = [];
201
210
  let current = start;
202
211
  for (;;) {
203
- if (fs.existsSync(path.join(current, ".git")))
204
- return current;
212
+ if (fs.existsSync(path.join(current, ".git"))) {
213
+ return setUp(current) ? current : (below.find(setUp) ?? current);
214
+ }
215
+ below.push(current);
205
216
  const parent = path.dirname(current);
206
217
  if (parent === current)
207
218
  return start;
@@ -215,8 +226,10 @@ export function daemonCmdRoot(dir) {
215
226
  *
216
227
  * - the stop half reads `<dir>/.vexp/daemon.pid`; with none there it stops
217
228
  * nothing, and the start half then races the daemon that is still up;
218
- * - `daemon-cmd` works on the git root at or above its cwd, so for a
219
- * workspace below one it would start a daemon on the folder above;
229
+ * - `daemon-cmd` works on the git root at or above its cwd unless a folder
230
+ * below an unset-up root was set up on its own (daemonCmdRoot), so for
231
+ * any other workspace below one it would start a daemon on the folder
232
+ * above;
220
233
  * - the engine refuses to start on the home directory, so a daemon an older
221
234
  * build left there would be stopped and not come back.
222
235
  */