vexp-cli 2.0.24 → 2.0.25

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.
@@ -490,6 +490,28 @@ function backupConfig(filePath) {
490
490
  }
491
491
  catch { /* non-fatal */ }
492
492
  }
493
+ /**
494
+ * Atomic write: write to a temp file then rename over the target. rename is
495
+ * atomic on POSIX and Windows, so a concurrent reader (e.g. Codex reloading
496
+ * ~/.codex/config.toml while VS Code AND Cursor both rewrite it on activation)
497
+ * never observes a half-written / mixed-transport config. Used for the shared
498
+ * global config files where cross-process races are possible.
499
+ */
500
+ function atomicWriteFileSync(filePath, content) {
501
+ const tmp = `${filePath}.vexp-tmp-${process.pid}`;
502
+ try {
503
+ fs.writeFileSync(tmp, content, "utf-8");
504
+ fs.renameSync(tmp, filePath);
505
+ }
506
+ catch {
507
+ // Cross-device rename or a Windows edge — fall back to a direct write.
508
+ try {
509
+ fs.unlinkSync(tmp);
510
+ }
511
+ catch { /* ignore */ }
512
+ fs.writeFileSync(filePath, content, "utf-8");
513
+ }
514
+ }
493
515
  /** Warn that we refused to touch an unparseable config file. */
494
516
  function warnUnparseable(filePath) {
495
517
  process.stderr.write(` [!] ${filePath} could not be parsed - leaving it untouched. Fix the file or add vexp manually, then re-run setup.\n`);
@@ -698,7 +720,10 @@ function buildCodexSection(opts) {
698
720
  "[mcp_servers.vexp]",
699
721
  `${CODEX_MANAGED_MARKER}: direct transport (set VEXP_CODEX_TRANSPORT=http for the HTTP supervisor)`,
700
722
  `command = ${tomlString(opts.coreBinaryPath)}`,
701
- `args = ["mcp"]`,
723
+ // Pass --workspace explicitly (highest precedence in cmd_mcp) IN ADDITION to
724
+ // the env pin below, so a stale cwd / stale cached env can't drift the child
725
+ // to the wrong repo. JSON.stringify yields a TOML-valid quoted string array.
726
+ `args = ${opts.workspaceRoot ? JSON.stringify(["mcp", "--workspace", opts.workspaceRoot]) : '["mcp"]'}`,
702
727
  ];
703
728
  if (opts.workspaceRoot)
704
729
  lines.push(`cwd = ${tomlString(opts.workspaceRoot)}`);
@@ -734,10 +759,12 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
734
759
  const mcpPort = readMcpPort(workspaceRoot);
735
760
  const token = resolveCodexToken();
736
761
  const requested = (process.env.VEXP_CODEX_TRANSPORT ?? "direct").toLowerCase();
737
- let transport = requested === "http" ? "http" : "direct";
738
- if (transport === "direct" && !(binaryPath && fs.existsSync(binaryPath))) {
739
- transport = "http";
740
- }
762
+ const transport = requested === "http" ? "http" : "direct";
763
+ // Do NOT auto-fall-back to http when the binary is missing — that emits a `url=`
764
+ // stanza Codex can cache → "url is not supported for stdio". Pass the binary only
765
+ // when it exists; buildCodexSection(direct, undefined) returns null → we then
766
+ // PRESERVE the existing managed section instead of clobbering it with a url.
767
+ const coreBinaryPath = binaryPath && fs.existsSync(binaryPath) ? binaryPath : undefined;
741
768
  let content = "";
742
769
  if (fs.existsSync(configPath))
743
770
  content = fs.readFileSync(configPath, "utf-8");
@@ -754,16 +781,18 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
754
781
  if (isUserManagedCodexSection(existingSection)) {
755
782
  if (content !== original) {
756
783
  backupConfig(configPath);
757
- fs.writeFileSync(configPath, content, "utf-8");
784
+ atomicWriteFileSync(configPath, content);
758
785
  return true;
759
786
  }
760
787
  return false;
761
788
  }
762
- const newSection = buildCodexSection({ transport, coreBinaryPath: binaryPath, workspaceRoot, mcpPort, token, home });
789
+ const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, mcpPort, token, home });
763
790
  if (!newSection) {
791
+ // direct requested but binary missing → preserve the existing section (only
792
+ // persist any legacy cleanup we already did); never emit a url fallback.
764
793
  if (content !== original) {
765
794
  backupConfig(configPath);
766
- fs.writeFileSync(configPath, content, "utf-8");
795
+ atomicWriteFileSync(configPath, content);
767
796
  return true;
768
797
  }
769
798
  return false;
@@ -773,7 +802,7 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
773
802
  if (content === original)
774
803
  return false;
775
804
  backupConfig(configPath);
776
- fs.writeFileSync(configPath, content, "utf-8");
805
+ atomicWriteFileSync(configPath, content);
777
806
  return true;
778
807
  }
779
808
  /**
@@ -884,11 +913,19 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
884
913
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
885
914
  const desiredCommand = useNode ? "node" : binaryPath;
886
915
  const desiredArgs = useNode ? [mcpServerPath] : ["mcp"];
887
- const desiredEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
916
+ // Multi-session fix: do NOT pin VEXP_WORKSPACE on this user-scope entry.
917
+ // Claude Code applies ~/.claude.json mcpServers to EVERY project, so a pinned
918
+ // workspace forces all parallel sessions onto one daemon (the wrong project for
919
+ // every session but the last one configured). With no env, each session resolves
920
+ // its own project at runtime via CLAUDE_PROJECT_DIR / cwd (see
921
+ // daemon-client.ts resolveWorkspaceRoot). `workspaceRoot` is intentionally unused
922
+ // here now; an existing pinned entry is migrated away by the identical-check
923
+ // (prevEnv !== undefined → not identical → rewritten without env).
924
+ const prevEnv = existing?.env;
888
925
  const identical = existing?.["command"] === desiredCommand &&
889
926
  Array.isArray(existing?.["args"]) &&
890
927
  JSON.stringify(existing["args"]) === JSON.stringify(desiredArgs) &&
891
- JSON.stringify(existing?.env) === JSON.stringify(desiredEnv);
928
+ prevEnv === undefined;
892
929
  const removed = stripLegacyVexpEntries(config, "mcpServers");
893
930
  logRemoval(removed);
894
931
  if (identical && removed.length === 0)
@@ -897,14 +934,63 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
897
934
  servers["vexp"] = {
898
935
  command: desiredCommand,
899
936
  args: desiredArgs,
900
- ...(desiredEnv ? { env: desiredEnv } : {}),
901
937
  };
902
938
  config.mcpServers = servers;
903
939
  if (read.existed)
904
940
  backupConfig(configPath);
905
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
941
+ atomicWriteFileSync(configPath, JSON.stringify(config, null, 2));
906
942
  return true;
907
943
  }
944
+ /**
945
+ * One-shot migration for the multi-session fix: remove the legacy
946
+ * `env.VEXP_WORKSPACE` pin from the vexp entry in user-scope `~/.claude.json`.
947
+ *
948
+ * A CLI-only user who merely upgrades the package keeps the OLD pinned config
949
+ * until they re-run `vexp setup` — and while pinned, every parallel Claude Code
950
+ * session still targets one project (the bug). This de-pins it surgically:
951
+ * only the pin is removed (the entry, its command/args, and every OTHER server
952
+ * are preserved), a backup is written first, and only a vexp-managed entry is
953
+ * ever touched (never a hand-rolled `vexp`). After de-pinning, each session
954
+ * resolves its own project at runtime via CLAUDE_PROJECT_DIR.
955
+ *
956
+ * Returns: `migrated` (pin removed), `noop` (no vexp pin / not vexp-managed),
957
+ * `absent` (no ~/.claude.json), `unreadable` (file present but unparseable —
958
+ * left untouched).
959
+ */
960
+ export function migrateClaudeCodeUnpin() {
961
+ const home = os.homedir();
962
+ if (!home)
963
+ return "absent";
964
+ const configPath = path.join(home, ".claude.json");
965
+ if (!fs.existsSync(configPath))
966
+ return "absent";
967
+ const read = readJsonConfigSafe(configPath);
968
+ if (!read.ok)
969
+ return "unreadable"; // never clobber an unparseable user file
970
+ const config = read.data;
971
+ const servers = config.mcpServers;
972
+ const vexp = servers?.["vexp"];
973
+ if (!vexp)
974
+ return "noop";
975
+ const env = vexp.env;
976
+ if (!env || typeof env !== "object" || env["VEXP_WORKSPACE"] === undefined)
977
+ return "noop";
978
+ // Only migrate an entry that looks vexp-managed (`args: ["mcp"]` for the Rust
979
+ // binary, or a `node …/mcp-server.cjs` bundle), never a hand-rolled `vexp`.
980
+ const args = vexp["args"];
981
+ const looksManaged = Array.isArray(args) &&
982
+ (JSON.stringify(args) === JSON.stringify(["mcp"]) ||
983
+ (typeof args[0] === "string" && /mcp-server\.cjs$/.test(args[0])));
984
+ if (!looksManaged)
985
+ return "noop";
986
+ // Remove only the pin; drop `env` entirely if it becomes empty.
987
+ delete env["VEXP_WORKSPACE"];
988
+ if (Object.keys(env).length === 0)
989
+ delete vexp.env;
990
+ backupConfig(configPath);
991
+ atomicWriteFileSync(configPath, JSON.stringify(config, null, 2));
992
+ return "migrated";
993
+ }
908
994
  // ---------------------------------------------------------------------------
909
995
  // Claude Code PreToolUse hook - blocks Grep/Glob when daemon is available
910
996
  // ---------------------------------------------------------------------------
package/dist/autostart.js CHANGED
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
4
  import { execFileSync, spawnSync } from "child_process";
5
+ import { migrateClaudeCodeUnpin } from "./agent-config.js";
5
6
  const LABEL = "io.vexp.serve";
6
7
  const BEGIN_MARKER = "# vexp-autostart-begin";
7
8
  const END_MARKER = "# vexp-autostart-end";
@@ -364,6 +365,50 @@ export function installAutostartIfNeeded() {
364
365
  }
365
366
  catch { /* non-fatal — opt-in best effort */ }
366
367
  }
368
+ const CLAUDE_UNPIN_MARKER_FILE = "claude-unpin-migrated";
369
+ function claudeUnpinMarkerPath() {
370
+ return path.join(homedir(), ".vexp", CLAUDE_UNPIN_MARKER_FILE);
371
+ }
372
+ /**
373
+ * One-shot, idempotent migration run from `vexp`'s preAction on every invocation.
374
+ *
375
+ * The multi-session fix dropped the `VEXP_WORKSPACE` pin from Claude Code's
376
+ * user-scope `~/.claude.json` entry, but a CLI-only user who merely upgrades the
377
+ * package keeps the OLD pinned config until they re-run `vexp setup`. This
378
+ * de-pins it automatically on the FIRST `vexp` command after upgrade.
379
+ *
380
+ * Marker-gated (`~/.vexp/claude-unpin-migrated`) so it runs at most once and we
381
+ * never re-parse the possibly-large `~/.claude.json` on subsequent invocations.
382
+ * The actual edit is surgical + backed up (see `migrateClaudeCodeUnpin`). Opt out
383
+ * with `VEXP_NO_AUTOSTART_INSTALL=1` (same switch as autostart install). A
384
+ * transient `unreadable` result does NOT write the marker, so it retries later.
385
+ */
386
+ export function migrateClaudeUnpinIfNeeded() {
387
+ if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1")
388
+ return;
389
+ const marker = claudeUnpinMarkerPath();
390
+ if (fs.existsSync(marker))
391
+ return;
392
+ let result;
393
+ try {
394
+ result = migrateClaudeCodeUnpin();
395
+ }
396
+ catch {
397
+ return; // non-fatal; retry on a later invocation
398
+ }
399
+ if (result === "unreadable")
400
+ return; // don't mark done — give it another chance
401
+ if (result === "migrated") {
402
+ console.error("[vexp] Updated ~/.claude.json: removed the global VEXP_WORKSPACE pin so " +
403
+ "parallel Claude Code sessions each target their own project " +
404
+ "(backup at ~/.claude.json.vexp-bak).");
405
+ }
406
+ try {
407
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
408
+ fs.writeFileSync(marker, new Date().toISOString());
409
+ }
410
+ catch { /* non-fatal */ }
411
+ }
367
412
  export function autostartStatus() {
368
413
  if (process.platform === "darwin") {
369
414
  const p = macPlistPath();
package/dist/cli.js CHANGED
@@ -13,8 +13,9 @@ import { CLI_VERSION } from "./version.js";
13
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
15
15
  import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
16
- import { installAutostart, installAutostartIfNeeded, uninstallAutostart, autostartStatus } from "./autostart.js";
16
+ import { installAutostart, installAutostartIfNeeded, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
17
17
  import { runServe } from "./serve.js";
18
+ import { runDoctor } from "./doctor.js";
18
19
  import { isTraceEnabled } from "./trace.js";
19
20
  const program = new Command();
20
21
  program
@@ -37,9 +38,17 @@ if (isTraceEnabled()) {
37
38
  // workspace was already configured (marker: .vexp/manifest.json).
38
39
  const AUTOSTART_SKIP = new Set([
39
40
  "setup", "daemon-cmd", "activate", "deactivate", "license", "version",
40
- "serve", "autostart", "use",
41
+ "serve", "autostart", "use", "doctor",
41
42
  ]);
42
43
  program.hook("preAction", async (_thisCmd, actionCmd) => {
44
+ // One-shot config migration for the multi-session fix — de-pins a legacy
45
+ // VEXP_WORKSPACE from ~/.claude.json on the first `vexp` command after upgrade.
46
+ // Marker-gated (near-free after first run) and independent of workspace/daemon,
47
+ // so it also runs for AUTOSTART_SKIP commands and VEXP_NO_AUTOSTART users.
48
+ try {
49
+ migrateClaudeUnpinIfNeeded();
50
+ }
51
+ catch { /* non-fatal */ }
43
52
  if (process.env.VEXP_NO_AUTOSTART === "1")
44
53
  return;
45
54
  if (AUTOSTART_SKIP.has(actionCmd.name()))
@@ -340,8 +349,9 @@ program
340
349
  const args = ["capsule", query, "--max-tokens", opts.maxTokens];
341
350
  if (opts.repos)
342
351
  args.push("--repos", opts.repos);
343
- if (opts.format === "json")
344
- args.push("--json");
352
+ // The Rust binary's clap arg is `--format <json|markdown>` (there is no
353
+ // `--json` flag). Map the CLI's "text"/"json" to the binary's vocabulary.
354
+ args.push("--format", opts.format === "json" ? "json" : "markdown");
345
355
  runBinary(binaryPath, args);
346
356
  });
347
357
  program
@@ -464,7 +474,7 @@ program
464
474
  });
465
475
  program
466
476
  .command("daemon-cmd <action>")
467
- .description("Manage the vexp daemon (start, stop, status, logs)")
477
+ .description("Manage the vexp daemon (start, stop, restart, status, logs)")
468
478
  .option("--follow", "Follow log output (with 'logs' action)")
469
479
  .action(async (action, opts) => {
470
480
  const binaryPath = ensureBinary();
@@ -473,6 +483,12 @@ program
473
483
  args.push("--follow");
474
484
  runBinary(binaryPath, args);
475
485
  });
486
+ program
487
+ .command("doctor")
488
+ .description("Diagnose vexp MCP/daemon state — workspace drift, stale daemons, Codex transport, license (no daemon needed)")
489
+ .action(async () => {
490
+ await runDoctor();
491
+ });
476
492
  program
477
493
  .command("setup [dir]")
478
494
  .description("Complete one-command setup: index, detect agents, configure MCP")
package/dist/doctor.js ADDED
@@ -0,0 +1,220 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import * as net from "net";
5
+ import chalk from "chalk";
6
+ // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
7
+ // Surfaces the failure modes behind the Codex drift report: stale daemons.json
8
+ // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
9
+ // expired rolling jwt, and a stuck HTTP supervisor. Read-only.
10
+ const OK = chalk.green("OK");
11
+ const WARN = chalk.yellow("WARN");
12
+ const BAD = chalk.red("FAIL");
13
+ function vexpHome() {
14
+ const h = process.env.VEXP_HOME;
15
+ if (h && path.isAbsolute(h))
16
+ return h;
17
+ return os.homedir();
18
+ }
19
+ /** Walk up for the nearest INITIALIZED .vexp (manifest/index), then bare .vexp,
20
+ * then .git — mirrors discover_workspace_root / discoverWorkspaceRoot. */
21
+ function discoverWorkspaceRoot(start) {
22
+ for (const test of [
23
+ (d) => fs.existsSync(path.join(d, ".vexp", "manifest.json")) || fs.existsSync(path.join(d, ".vexp", "index.db")),
24
+ (d) => fs.existsSync(path.join(d, ".vexp")),
25
+ (d) => fs.existsSync(path.join(d, ".git")),
26
+ ]) {
27
+ let cur = start;
28
+ while (true) {
29
+ if (test(cur))
30
+ return cur;
31
+ const parent = path.dirname(cur);
32
+ if (parent === cur)
33
+ break;
34
+ cur = parent;
35
+ }
36
+ }
37
+ return start;
38
+ }
39
+ function resolveWorkspace() {
40
+ if (process.env.VEXP_WORKSPACE)
41
+ return { root: process.env.VEXP_WORKSPACE, source: "VEXP_WORKSPACE" };
42
+ if (process.env.CLAUDE_PROJECT_DIR)
43
+ return { root: process.env.CLAUDE_PROJECT_DIR, source: "CLAUDE_PROJECT_DIR" };
44
+ return { root: discoverWorkspaceRoot(process.cwd()), source: "cwd-discovery" };
45
+ }
46
+ function socketForWorkspace(root) {
47
+ if (process.platform === "win32") {
48
+ // FNV-1a, lowercase — mirrors get_socket_path / fnvHash.
49
+ let hash = BigInt("0xcbf29ce484222325");
50
+ const prime = BigInt("0x100000001b3");
51
+ const mask = BigInt("0xffffffffffffffff");
52
+ for (const b of Buffer.from(root.toLowerCase(), "utf-8")) {
53
+ hash ^= BigInt(b);
54
+ hash = (hash * prime) & mask;
55
+ }
56
+ return `\\\\.\\pipe\\vexp-${hash.toString(16).padStart(16, "0").slice(0, 8)}`;
57
+ }
58
+ return path.join(root, ".vexp", "daemon.sock");
59
+ }
60
+ function reachable(sock, timeoutMs = 600) {
61
+ return new Promise((resolve) => {
62
+ const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
63
+ const t = setTimeout(() => { c.destroy(); resolve(false); }, timeoutMs);
64
+ c.on("connect", () => { clearTimeout(t); c.destroy(); resolve(true); });
65
+ c.on("error", () => { clearTimeout(t); resolve(false); });
66
+ });
67
+ }
68
+ /** Decode a JWT's `exp` (seconds) without verifying the signature. */
69
+ function jwtExp(file) {
70
+ try {
71
+ const tok = fs.readFileSync(file, "utf-8").trim();
72
+ const payload = JSON.parse(Buffer.from(tok.split(".")[1], "base64").toString("utf-8"));
73
+ return typeof payload.exp === "number" ? payload.exp : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ function isAlive(pid) {
80
+ try {
81
+ process.kill(pid, 0);
82
+ return true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ export async function runDoctor() {
89
+ const home = vexpHome();
90
+ let warns = 0;
91
+ let fails = 0;
92
+ const line = (status, msg) => {
93
+ if (status === WARN)
94
+ warns++;
95
+ if (status === BAD)
96
+ fails++;
97
+ console.log(` [${status}] ${msg}`);
98
+ };
99
+ console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
100
+ // 1) Workspace resolution for the current directory.
101
+ console.log(chalk.bold("Workspace targeting"));
102
+ const ws = resolveWorkspace();
103
+ console.log(` cwd: ${process.cwd()}`);
104
+ line(OK, `resolves to: ${ws.root} (via ${ws.source})`);
105
+ const sock = socketForWorkspace(ws.root);
106
+ const live = await reachable(sock);
107
+ line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
108
+ if (!live)
109
+ console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
110
+ // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
111
+ console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
112
+ const regPath = path.join(home, ".vexp", "daemons.json");
113
+ let registry = {};
114
+ try {
115
+ registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
116
+ }
117
+ catch { /* absent */ }
118
+ const entries = Object.entries(registry);
119
+ if (entries.length === 0) {
120
+ line(OK, "no registered daemons (empty/absent registry)");
121
+ }
122
+ else {
123
+ for (const [root, s] of entries) {
124
+ const stale = process.platform === "win32" ? false : !(() => { try {
125
+ return fs.statSync(s).isSocket();
126
+ }
127
+ catch {
128
+ return false;
129
+ } })();
130
+ line(stale ? WARN : OK, `${root} → ${s}${stale ? " (STALE: socket file gone)" : ""}`);
131
+ }
132
+ const inReg = entries.some(([r]) => r.toLowerCase() === ws.root.toLowerCase());
133
+ if (!inReg)
134
+ line(WARN, `current workspace (${ws.root}) is NOT registered — a child here could mis-route`);
135
+ }
136
+ // 3) License (fresh.jwt rolling token vs license.jwt).
137
+ console.log(chalk.bold("\nLicense tokens (~/.vexp)"));
138
+ const now = Math.floor(Date.now() / 1000);
139
+ for (const name of ["fresh.jwt", "license.jwt"]) {
140
+ const p = path.join(home, ".vexp", name);
141
+ if (!fs.existsSync(p)) {
142
+ line(name === "license.jwt" ? WARN : OK, `${name}: absent`);
143
+ continue;
144
+ }
145
+ const exp = jwtExp(p);
146
+ if (exp == null) {
147
+ line(WARN, `${name}: present but unparseable`);
148
+ continue;
149
+ }
150
+ const days = Math.round((exp - now) / 86400);
151
+ if (exp < now) {
152
+ line(name === "fresh.jwt" ? OK : WARN, `${name}: expired ${-days}d ago${name === "fresh.jwt" ? " (benign — rolling token, falls back to license.jwt)" : ""}`);
153
+ }
154
+ else {
155
+ line(OK, `${name}: valid, ~${days}d remaining`);
156
+ }
157
+ }
158
+ // 4) Codex MCP transport stanza.
159
+ console.log(chalk.bold("\nCodex (~/.codex/config.toml)"));
160
+ const codexPath = path.join(os.homedir(), ".codex", "config.toml");
161
+ if (!fs.existsSync(codexPath)) {
162
+ line(OK, "no ~/.codex/config.toml (Codex not configured)");
163
+ }
164
+ else {
165
+ const toml = fs.readFileSync(codexPath, "utf-8");
166
+ const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
167
+ const section = m ? m[0] : "";
168
+ if (!section)
169
+ line(OK, "no [mcp_servers.vexp] stanza");
170
+ else {
171
+ const hasUrl = /^\s*url\s*=/m.test(section);
172
+ const hasCmd = /^\s*command\s*=/m.test(section);
173
+ if (hasUrl && hasCmd)
174
+ line(BAD, "stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.");
175
+ else if (hasUrl)
176
+ line(OK, "transport: http (url)");
177
+ else if (hasCmd) {
178
+ const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
179
+ line(OK, `transport: stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
180
+ }
181
+ else
182
+ line(WARN, "stanza present but neither url nor command found");
183
+ }
184
+ }
185
+ // 5) Claude Code entry (should be UNPINNED after the multi-session fix).
186
+ console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
187
+ const claudePath = path.join(os.homedir(), ".claude.json");
188
+ try {
189
+ const cfg = JSON.parse(fs.readFileSync(claudePath, "utf-8"));
190
+ const v = cfg?.mcpServers?.vexp;
191
+ if (!v)
192
+ line(OK, "no user-scope vexp entry");
193
+ else if (v.env?.VEXP_WORKSPACE)
194
+ line(WARN, `vexp entry still pins VEXP_WORKSPACE=${v.env.VEXP_WORKSPACE} → run 'vexp setup' to de-pin (multi-session fix)`);
195
+ else
196
+ line(OK, "vexp entry present, no VEXP_WORKSPACE pin (per-session targeting OK)");
197
+ }
198
+ catch {
199
+ line(OK, "no ~/.claude.json");
200
+ }
201
+ // 6) HTTP MCP supervisor.
202
+ console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
203
+ try {
204
+ const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
205
+ line(isAlive(rec.pid) ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${isAlive(rec.pid) ? "alive" : "DEAD (stale pid file)"}`);
206
+ }
207
+ catch {
208
+ line(OK, "not running (no mcp.pid)");
209
+ }
210
+ // Summary.
211
+ console.log(chalk.bold("\nSummary"));
212
+ if (fails === 0 && warns === 0)
213
+ console.log(` ${OK} no issues detected`);
214
+ else
215
+ console.log(` ${fails > 0 ? BAD : WARN} ${fails} failure(s), ${warns} warning(s)`);
216
+ if (warns > 0 || fails > 0) {
217
+ console.log(chalk.dim(" Tips: 'vexp daemon-cmd restart' restarts the workspace daemon; 'vexp setup' rewrites agent configs; restart Codex to drop a cached config."));
218
+ }
219
+ console.log("");
220
+ }