vexp-cli 2.0.23 → 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.
- package/dist/agent-config.js +99 -13
- package/dist/autostart.js +45 -0
- package/dist/cli.js +21 -5
- package/dist/doctor.js +220 -0
- package/mcp/mcp-server.cjs +41 -40
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
784
|
+
atomicWriteFileSync(configPath, content);
|
|
758
785
|
return true;
|
|
759
786
|
}
|
|
760
787
|
return false;
|
|
761
788
|
}
|
|
762
|
-
const newSection = buildCodexSection({ transport, coreBinaryPath
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
+
}
|