vexp-cli 3.2.5 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/forget.js CHANGED
@@ -16,8 +16,10 @@ import { canonicalWorkspaceRoot } from "./socket-path.js";
16
16
  */
17
17
  /** Everything a daemon or an index run leaves in `<workspace>/.vexp`.
18
18
  * Config is absent on purpose (`vexp.toml`, `workspace.json`,
19
- * `parent_workspace.json`, `.gitattributes`, `.gitignore`): forgetting a
20
- * workspace removes what brings its daemon back, not what the user wrote.
19
+ * `parent_workspace.json`, `.gitattributes`, `.gitignore`, and
20
+ * `git-hooks.declined`, the user's "no" to vexp's git hooks): forgetting a
21
+ * workspace removes what brings its daemon back, not what the user wrote
22
+ * or answered.
21
23
  * At the home directory the same `.vexp` is vexp's state dir, and this list
22
24
  * is exactly what may go there — `config.toml`, the licence tokens,
23
25
  * `daemons.json`, `mcp.pid`/`mcp.token`, `gpu-unusable`, models and plugins
@@ -384,13 +384,19 @@ process.stdin.on("end", () => {
384
384
  * FAIL-OPEN CONTRACT: every failure path (binary missing, daemon down,
385
385
  * timeout) exits 0 with no output = vanilla behavior, never a broken
386
386
  * prompt. Keep in lockstep with the VS Code extension copy.
387
- * The __VEXP_BIN__ placeholder is baked at install time.
387
+ * The __VEXP_BIN__ and __VEXP_AGENT__ placeholders are baked at install time.
388
+ *
389
+ * VEXP_HOOK_AGENT names the agent whose hook this is. The script is shared by
390
+ * Claude Code and Codex, and the daemon used to label every row of its
391
+ * activity ledger "claude-code". It lives here, never on the hook's command
392
+ * line: Codex hashes that line for its trust record, and a change there would
393
+ * make every user approve the hook again. Binaries that predate it ignore it.
388
394
  */
389
395
  export const VEXP_HINT_HOOK = `#!/bin/bash
390
396
  # vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.
391
397
  VEXP_BIN="__VEXP_BIN__"
392
398
  [ -x "$VEXP_BIN" ] || exit 0
393
- "$VEXP_BIN" prompt-hint 2>/dev/null
399
+ VEXP_HOOK_AGENT="__VEXP_AGENT__" "$VEXP_BIN" prompt-hint 2>/dev/null
394
400
  exit 0
395
401
  `;
396
402
  /**
@@ -510,33 +516,45 @@ export const VexpCompress = async () => ({
510
516
  },
511
517
  });
512
518
  `;
519
+ /**
520
+ * `c:\...` -> `C:\...`. On Windows one folder reaches the two installers
521
+ * spelled two ways: VS Code hands the extension `x:\proj` (its URIs
522
+ * lower-case the drive letter), a terminal hands the CLI `X:\proj`. Every
523
+ * path baked into a hook registration or a hook script goes through this, in
524
+ * both twins, or `vexp setup` and the extension rewrite each other's
525
+ * .codex/hooks.json at every run (review of the 2026-09-24 fix, a project on
526
+ * a share mapped as X:). Twin: agent-auto-config.ts upperDriveLetter.
527
+ */
528
+ export function upperDriveLetter(p) {
529
+ return p.replace(/^([a-z])(?=:[\\/])/, (d) => d.toUpperCase());
530
+ }
513
531
  /** Bake the binary path into the opencode/Kilo compression plugin. */
514
532
  export function vexpOpencodeCompressPlugin(binaryPath) {
515
- return VEXP_OPENCODE_COMPRESS.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
533
+ return VEXP_OPENCODE_COMPRESS.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/"));
516
534
  }
517
535
  /** Bake the binary path into the read-hint hook script. */
518
536
  export function bakeReadHintHook(binaryPath) {
519
- return VEXP_READ_HINT_HOOK.replace("__VEXP_BIN__", binaryPath);
537
+ return VEXP_READ_HINT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath));
520
538
  }
521
539
  /** Bake the binary path into the bash-cap hook script. */
522
540
  export function bakeBashCapHook(binaryPath) {
523
- return VEXP_BASH_CAP_HOOK.replace("__VEXP_BIN__", binaryPath);
541
+ return VEXP_BASH_CAP_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath));
524
542
  }
525
543
  /** Bake the binary path into the edit-hint hook script. */
526
544
  export function bakeEditHintHook(binaryPath) {
527
- return VEXP_EDIT_HINT_HOOK.replace("__VEXP_BIN__", binaryPath);
545
+ return VEXP_EDIT_HINT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath));
528
546
  }
529
- /** Bake the binary path into the hint hook script. */
530
- export function vexpHintHookScript(binaryPath) {
531
- return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
547
+ /** Bake the binary path and the agent into the hint hook script. */
548
+ export function vexpHintHookScript(binaryPath, agent = "claude-code") {
549
+ return VEXP_HINT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/")).replace("__VEXP_AGENT__", agent);
532
550
  }
533
551
  /**
534
- * Windows twin of the hint hook. Codex runs a hook command through
535
- * `cmd.exe /C` (COMSPEC) on Windows, and cmd has no `bash`: a Windows user
536
- * with neither Git Bash nor WSL got "bash not found" on every prompt, so
537
- * orientation was inert and nothing said so. The logic is four lines and
552
+ * Windows twin of the hint hook, for Codex. A Windows user with neither Git
553
+ * Bash nor WSL has no `bash`, so `bash "..."` failed on every prompt and
554
+ * orientation was inert with nothing saying so. The logic is four lines and
538
555
  * none of it is shell-specific, so it translates exactly — and `exit /b 0`
539
- * on the last line keeps the same fail-open contract as the bash twin.
556
+ * on the last line keeps the same fail-open contract as the bash twin. Codex
557
+ * is the only agent that runs it, hence the agent name written in.
540
558
  *
541
559
  * CRLF on purpose: a batch file with bare LF endings is parsed
542
560
  * inconsistently by cmd, and this one has to run on the machines least
@@ -547,13 +565,38 @@ export const VEXP_HINT_HOOK_CMD = [
547
565
  "REM vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.",
548
566
  'set "VEXP_BIN=__VEXP_BIN__"',
549
567
  'if not exist "%VEXP_BIN%" exit /b 0',
568
+ 'set "VEXP_HOOK_AGENT=codex"',
550
569
  '"%VEXP_BIN%" prompt-hint 2>nul',
551
570
  "exit /b 0",
552
571
  "",
553
572
  ].join("\r\n");
554
573
  /** Bake the binary path into the Windows hint hook. Backslashes stay. */
555
574
  export function vexpHintHookCmdScript(binaryPath) {
556
- return VEXP_HINT_HOOK_CMD.replace("__VEXP_BIN__", binaryPath.replace(/\//g, "\\"));
575
+ return VEXP_HINT_HOOK_CMD.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\//g, "\\"));
576
+ }
577
+ /**
578
+ * The line Codex runs on Windows (`commandWindows`), naming the batch twin.
579
+ *
580
+ * Wrapped in `cmd /d /c call` because Codex does NOT run hooks through cmd.exe by
581
+ * default: it runs them through the session shell, PowerShell
582
+ * (`powershell -NoProfile -Command <line>`, codex-rs core/src/session/mod.rs
583
+ * build_hooks_config + shell-command shell_detect default_user_shell), and
584
+ * falls back to `%COMSPEC% /C "<line>"` only when there is no local shell.
585
+ * A bare quoted path is a string literal to PowerShell: it printed the path,
586
+ * exited 0, and Codex injected that path as the context of every prompt while
587
+ * the script never ran. `cmd /d /c call "<path>"` runs the script under
588
+ * both shells, and the prompt JSON still reaches it on stdin (checked on
589
+ * PowerShell 5.1 and cmd with a replica of Codex's build_command). /d skips
590
+ * cmd's AutoRun, as a hook should.
591
+ *
592
+ * `call` is what keeps a project path with a cmd special character working:
593
+ * without it cmd /c strips the outer quotes of `"<path>"` and splits a path
594
+ * such as `...\R&D (x)\...` or `OneDrive - Smith & Co` at the `&`, so the hook
595
+ * failed on every prompt. With it, only an `&` with no space around it,
596
+ * under PowerShell, still fails (doctor's probe reports that as a failed run).
597
+ */
598
+ export function codexWindowsHookCommand(cmdPath) {
599
+ return `cmd /d /c call "${cmdPath}"`;
557
600
  }
558
601
  /**
559
602
  * v4 M1: the search answer (PreToolUse on Bash).
@@ -577,7 +620,7 @@ exit 0
577
620
  `;
578
621
  /** Bake the binary path into the search hook script. */
579
622
  export function vexpSearchHookScript(binaryPath) {
580
- return VEXP_SEARCH_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
623
+ return VEXP_SEARCH_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/"));
581
624
  }
582
625
  /**
583
626
  * Horizon F2a: Stop-hook verification gate (Claude Code). All logic lives
@@ -595,7 +638,7 @@ exit 0
595
638
  `;
596
639
  /** Bake the binary path into the stop-gate hook script. */
597
640
  export function vexpStopGateHookScript(binaryPath) {
598
- return VEXP_STOP_GATE_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
641
+ return VEXP_STOP_GATE_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/"));
599
642
  }
600
643
  /**
601
644
  * v3 context lifecycle: SessionStart hook (Claude Code). Fires on every
@@ -613,7 +656,7 @@ exit 0
613
656
  `;
614
657
  /** Bake the binary path into the session-context hook script. */
615
658
  export function vexpSessionContextHookScript(binaryPath) {
616
- return VEXP_SESSION_CONTEXT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
659
+ return VEXP_SESSION_CONTEXT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/"));
617
660
  }
618
661
  /**
619
662
  * opencode/Kilo per-prompt hint plugin (2.4.0). The plugin API's
@@ -627,6 +670,7 @@ export function vexpSessionContextHookScript(binaryPath) {
627
670
  */
628
671
  export const VEXP_OPENCODE_HINT = `// vexp-hint: per-prompt orientation + idle verification (fail-open). Managed by vexp.
629
672
  const VEXP_BIN = "__VEXP_BIN__";
673
+ const VEXP_AGENT = "__VEXP_AGENT__";
630
674
  export const VexpHint = async ({ directory, client }) => {
631
675
  const fs = await import("node:fs");
632
676
  const path = await import("node:path");
@@ -697,10 +741,13 @@ export const VexpHint = async ({ directory, client }) => {
697
741
  fs.writeFileSync(tf, text);
698
742
  }
699
743
  } catch (e) { /* fail open */ }
744
+ // hook_event_name: prompt-hint counts a call without one as a manual
745
+ // probe and keeps it out of the activity ledger, which is where every
746
+ // opencode and Kilo prompt went. VEXP_HOOK_AGENT labels the rows.
700
747
  const out = await runVexp(["prompt-hint"], {
701
- input: JSON.stringify({ prompt: text, session_id: sid }),
748
+ input: JSON.stringify({ prompt: text, session_id: sid, hook_event_name: "UserPromptSubmit" }),
702
749
  timeout: 4000,
703
- env: { ...process.env, CLAUDE_PROJECT_DIR: directory },
750
+ env: { ...process.env, CLAUDE_PROJECT_DIR: directory, VEXP_HOOK_AGENT: VEXP_AGENT },
704
751
  });
705
752
  if (!out || !out.trim()) return;
706
753
  const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
@@ -732,9 +779,13 @@ export const VexpHint = async ({ directory, client }) => {
732
779
  if (fs.existsSync(marker)) return;
733
780
  const tf = taskFileFor(sid);
734
781
  if (!fs.existsSync(tf)) return;
782
+ // VEXP_RULES_CHECK=0: a hook neither writes the index nor waits on
783
+ // it. Under a rule change verify would reconcile first, and a large
784
+ // one outlives the 15 s budget, killed and restarted at every idle.
735
785
  const out = await runVexp(["verify", "--json", "--task-file", tf], {
736
786
  timeout: 15000,
737
787
  cwd: directory,
788
+ env: { ...process.env, VEXP_RULES_CHECK: "0" },
738
789
  });
739
790
  if (!out || !out.trim()) return;
740
791
  const rep = JSON.parse(out);
@@ -773,7 +824,11 @@ export const VexpHint = async ({ directory, client }) => {
773
824
  };
774
825
  };
775
826
  `;
776
- /** Bake the binary path into the opencode hint plugin. */
777
- export function vexpOpencodeHintPlugin(binaryPath) {
778
- return VEXP_OPENCODE_HINT.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
827
+ /** Bake the binary path and the agent (one plugin serves opencode and Kilo) into the hint plugin. */
828
+ export function vexpOpencodeHintPlugin(binaryPath, agent = "opencode") {
829
+ return VEXP_OPENCODE_HINT.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/")).replace("__VEXP_AGENT__", agent);
830
+ }
831
+ /** The agent a hint plugin directory belongs to: `.kilo/plugin` is Kilo's, anything else opencode's. */
832
+ export function opencodeFamilyAgent(pluginDir) {
833
+ return /^\.kilo[\\/]/.test(pluginDir) ? "kilo" : "opencode";
779
834
  }
@@ -0,0 +1,90 @@
1
+ import chalk from "chalk";
2
+ import { ACCOUNT_URL, activateAndRegister, formatCheckIn, lastCheckIn, registrationLine, registrationState, registrationStateLine, } from "./license.js";
3
+ /**
4
+ * What the CLI prints for a plan. AppSumo lifetime tiers print as "Lifetime ·
5
+ * Tier N": the raw internal string ("tier3") next to a date one month out read
6
+ * as a monthly deadline to a buyer who then re-activated by hand every month.
7
+ */
8
+ export function planLabel(plan) {
9
+ return /^tier[1-4]$/.test(plan) ? `Lifetime · Tier ${plan.slice(4)}` : plan;
10
+ }
11
+ export function isLifetimePlan(plan) {
12
+ return /^tier[1-4]$/.test(plan);
13
+ }
14
+ /** "Last check-in: <date>" / "never", for `vexp license` and the menu. */
15
+ export function lastCheckInText() {
16
+ const at = lastCheckIn();
17
+ return at ? formatCheckIn(at) : "never";
18
+ }
19
+ /** Print the registration line in the colour its meaning deserves, and its
20
+ * cause on a second line when there is one. */
21
+ export function printRegistration(outcome, indent, log = console.log) {
22
+ const { ok, text, hint } = registrationLine(outcome);
23
+ log(`${indent}${ok ? chalk.green(text) : chalk.yellow(text)}`);
24
+ if (hint)
25
+ log(`${indent}${chalk.dim(hint)}`);
26
+ }
27
+ /**
28
+ * `vexp license` and the menu's status: the last check-in date, then what
29
+ * this run's check-in came to, or, when none was due, what the files on disk
30
+ * say (the stamp alone would call a revoked or refused machine checked in).
31
+ */
32
+ export function printCheckInStatus(outcome, indent, log = console.log) {
33
+ if (outcome.kind === "no_license")
34
+ return;
35
+ log(`${indent}Last check-in: ${lastCheckInText()}`);
36
+ if (outcome.kind === "throttled") {
37
+ const state = registrationState();
38
+ // The blocked marker has its own paragraph in both callers.
39
+ if (state.state === "blocked")
40
+ return;
41
+ const { ok, text } = registrationStateLine(state);
42
+ log(`${indent}${ok ? chalk.green(text) : chalk.yellow(text)}`);
43
+ return;
44
+ }
45
+ printRegistration(outcome, indent, log);
46
+ }
47
+ /**
48
+ * Activate a key and register this device, then say both: the single path
49
+ * behind `vexp activate` and the interactive menu (License > activate). The
50
+ * menu used to write the key and stop there, so a buyer who activated from it
51
+ * and then worked only through an agent never appeared on the dashboard.
52
+ * Never throws; `ok` is false when the key was refused.
53
+ */
54
+ export async function runActivation(key, opts = {}) {
55
+ const indent = opts.indent ?? " ";
56
+ const log = opts.log ?? console.log;
57
+ const error = opts.error ?? console.error;
58
+ let result;
59
+ try {
60
+ result = await activateAndRegister(key);
61
+ }
62
+ catch (err) {
63
+ error(chalk.red(`${indent}✗ ${err instanceof Error ? err.message : String(err)}`));
64
+ return { ok: false };
65
+ }
66
+ const { claims, outcome, renewed } = result;
67
+ if (outcome.kind === "revoked" || outcome.kind === "deactivated") {
68
+ // A refunded or revoked key still verifies until its 30-day token runs
69
+ // out; "activated!" would read as all is well to someone who then loses
70
+ // the plan within the month.
71
+ log(chalk.yellow(`\n${indent}! License installed, but vexp.dev reports it ${outcome.kind}: it stops working on ${new Date(claims.exp * 1000).toLocaleDateString()}. Get your current key from ${ACCOUNT_URL}`));
72
+ log(`${indent} Plan: ${planLabel(claims.plan)}`);
73
+ log(`${indent} Email: ${claims.sub}`);
74
+ return { ok: true, claims, outcome };
75
+ }
76
+ log(chalk.green(`\n${indent}✓ License activated!`));
77
+ log(`${indent} Plan: ${planLabel(claims.plan)}`);
78
+ log(`${indent} Email: ${claims.sub}`);
79
+ if (isLifetimePlan(claims.plan)) {
80
+ log(`${indent} Renewal: none - lifetime licence (the local token auto-renews on check-in)`);
81
+ }
82
+ else if (!renewed) {
83
+ log(`${indent} Expires: ${new Date(claims.exp * 1000).toLocaleDateString()}`);
84
+ }
85
+ if (renewed) {
86
+ log(chalk.dim(`${indent} The key had expired; vexp.dev renewed it for this machine.`));
87
+ }
88
+ printRegistration(outcome, indent, log);
89
+ return { ok: true, claims, outcome };
90
+ }