vexp-cli 3.2.4 → 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/README.md +4 -4
- package/dist/agent-config.js +415 -70
- package/dist/agent-consent.js +244 -0
- package/dist/cli.js +197 -95
- package/dist/codex-trust.js +608 -0
- package/dist/doctor.js +1112 -112
- package/dist/forget.js +7 -3
- package/dist/hook-template.js +78 -23
- package/dist/license-activate.js +90 -0
- package/dist/license.js +577 -125
- package/dist/serve.js +3 -2
- package/dist/socket-path.js +25 -1
- package/mcp/mcp-server.cjs +48 -48
- package/package.json +7 -7
package/dist/agent-config.js
CHANGED
|
@@ -11,7 +11,9 @@ import * as path from "path";
|
|
|
11
11
|
import * as os from "os";
|
|
12
12
|
import { spawnSync } from "child_process";
|
|
13
13
|
import * as crypto from "crypto";
|
|
14
|
-
import {
|
|
14
|
+
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
15
|
+
import { readAgentConsent, consentKey, decisionForKey } from "./agent-consent.js";
|
|
16
|
+
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, codexWindowsHookCommand, opencodeFamilyAgent, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook, upperDriveLetter } from "./hook-template.js";
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
16
18
|
// Constants
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
@@ -39,7 +41,9 @@ const CANONICAL_VEXP_TOML_SECTION_RE = /\n?\[mcp_servers\.vexp(?:\.[a-z_]+)?\][\
|
|
|
39
41
|
* MUST stay in lockstep with that function.
|
|
40
42
|
*/
|
|
41
43
|
function workspaceHash(workspaceRoot) {
|
|
42
|
-
|
|
44
|
+
// The route hash must name the row the daemon registers: its canonical
|
|
45
|
+
// root, which on a mapped drive is the share, not the drive letter.
|
|
46
|
+
const input = canonicalWorkspaceRoot(workspaceRoot).toLowerCase();
|
|
43
47
|
let hash = BigInt("0xcbf29ce484222325");
|
|
44
48
|
const prime = BigInt("0x100000001b3");
|
|
45
49
|
const mask = BigInt("0xffffffffffffffff");
|
|
@@ -113,10 +117,29 @@ export function interventionMode() {
|
|
|
113
117
|
// ---------------------------------------------------------------------------
|
|
114
118
|
// Agent detectors - mirrors VS Code extension's AGENT_DETECTORS
|
|
115
119
|
// ---------------------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* What Codex itself leaves under `~/.codex`: the login, the session store,
|
|
122
|
+
* the history. Any one of them means Codex has run on this machine. The
|
|
123
|
+
* folder alone does not: configureCodexGlobal creates it for config.toml.
|
|
124
|
+
* Lockstep with the extension.
|
|
125
|
+
*/
|
|
126
|
+
function codexHomeArtefacts() {
|
|
127
|
+
// CODEX_HOME is where Codex keeps all of it when set (Codex's documented
|
|
128
|
+
// relocation); the home folder alone missed such a user, and the
|
|
129
|
+
// extension's orphan sweep then took his working hooks for leftovers.
|
|
130
|
+
const codexHome = process.env.CODEX_HOME ? path.resolve(process.env.CODEX_HOME) : path.join(os.homedir(), ".codex");
|
|
131
|
+
return ["auth.json", "sessions", "session_index.jsonl", "history.jsonl", "installation_id", "version.json"].map((f) => path.join(codexHome, f));
|
|
132
|
+
}
|
|
116
133
|
const AGENT_DETECTORS = [
|
|
117
134
|
{
|
|
118
135
|
agent: "Claude Code",
|
|
119
136
|
detectPath: ".claude",
|
|
137
|
+
// Machine marker: the ~/.claude DIRECTORY, which only Claude Code
|
|
138
|
+
// creates. Never ~/.claude.json — configureClaudeCodeGlobal writes that
|
|
139
|
+
// file itself, so it would keep Claude Code "found" forever. Without a
|
|
140
|
+
// machine marker the one tool a user had needed Setup Agents while two
|
|
141
|
+
// he did not have were set up on their own (field report, 2026-09-24).
|
|
142
|
+
detectHome: ".claude",
|
|
120
143
|
configFile: ".claude/CLAUDE.md",
|
|
121
144
|
templateName: "claude-code",
|
|
122
145
|
// MCP configured in ~/.claude.json (user-scope) via configureClaudeCodeGlobal()
|
|
@@ -182,7 +205,19 @@ const AGENT_DETECTORS = [
|
|
|
182
205
|
},
|
|
183
206
|
{
|
|
184
207
|
agent: "Codex",
|
|
185
|
-
|
|
208
|
+
// Codex's own footprint: the login, sessions and history it keeps under
|
|
209
|
+
// `~/.codex` (see codexHomeArtefacts), or a project-level config Codex
|
|
210
|
+
// itself keeps. Nothing vexp writes is a marker: not AGENTS.md (written
|
|
211
|
+
// for Antigravity, opencode and ZCode too), not the `.codex/` folder our
|
|
212
|
+
// hooks.json lands in, and not `~/.codex` itself, which
|
|
213
|
+
// configureCodexGlobal creates for its config.toml; any of those would
|
|
214
|
+
// keep Codex "detected" forever on a machine that never had it (field
|
|
215
|
+
// report, 2026-09-24). The ~/.codex footprint is a machine-only marker:
|
|
216
|
+
// it says Codex ran on this PC weeks ago, not that this project uses it,
|
|
217
|
+
// so it gets Codex set up here only once the user says so
|
|
218
|
+
// (planAgentSetup). Lockstep with the extension.
|
|
219
|
+
detectPath: ".codex/config.toml",
|
|
220
|
+
detectAbsPaths: codexHomeArtefacts,
|
|
186
221
|
configFile: "AGENTS.md",
|
|
187
222
|
templateName: "agents-md",
|
|
188
223
|
// MCP configured in ~/.codex/config.toml (global) via configureCodexGlobal()
|
|
@@ -263,13 +298,152 @@ const AGENT_DETECTORS = [
|
|
|
263
298
|
// same mandate every AGENTS.md agent gets, under a name of its own. It
|
|
264
299
|
// connects to vexp on its side, so there is no MCP file to write here:
|
|
265
300
|
// the instructions are the whole integration. Detected by the file
|
|
266
|
-
// itself,
|
|
301
|
+
// itself (Codex, by contrast, by its own ~/.codex footprint).
|
|
267
302
|
agent: "Friday Code",
|
|
268
303
|
detectPath: "friday.md",
|
|
269
304
|
configFile: "friday.md",
|
|
270
305
|
templateName: "agents-md",
|
|
271
306
|
},
|
|
272
307
|
];
|
|
308
|
+
/**
|
|
309
|
+
* Every file vexp writes as an agent's instructions. As a detection marker
|
|
310
|
+
* such a file proves only that vexp ran here: a `.clinerules` written because
|
|
311
|
+
* Cline's VS Code storage exists "detected" Cline in that project at every
|
|
312
|
+
* start after, the loop AGENTS.md closed for Codex through 3.2.5. It counts
|
|
313
|
+
* only when someone put content of their own in it.
|
|
314
|
+
*/
|
|
315
|
+
const VEXP_WRITTEN_MARKERS = new Set(AGENT_DETECTORS.map((d) => d.configFile));
|
|
316
|
+
/** `text` without vexp's `<!-- vexp ... -->` … `<!-- /vexp -->` block (to
|
|
317
|
+
* the end of the file for the old unterminated format). */
|
|
318
|
+
function withoutVexpSection(text) {
|
|
319
|
+
const m = VEXP_MARKER_RE.exec(text);
|
|
320
|
+
if (!m)
|
|
321
|
+
return text;
|
|
322
|
+
let start = m.index;
|
|
323
|
+
while (start > 0 && text[start - 1] !== "\n")
|
|
324
|
+
start--;
|
|
325
|
+
const endIdx = text.indexOf(VEXP_MARKER_END, m.index);
|
|
326
|
+
const end = endIdx === -1 ? text.length : endIdx + VEXP_MARKER_END.length;
|
|
327
|
+
return text.slice(0, start) + text.slice(end);
|
|
328
|
+
}
|
|
329
|
+
/** Does a vexp-written marker hold anything of the user's? Unreadable counts
|
|
330
|
+
* as yes: when in doubt, detection behaves as it always did. */
|
|
331
|
+
function hasOwnContent(p) {
|
|
332
|
+
let st;
|
|
333
|
+
try {
|
|
334
|
+
st = fs.statSync(p);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
if (st.isDirectory()) {
|
|
340
|
+
// appendOrCreate writes `<dir>/vexp.md` when the target is a directory.
|
|
341
|
+
let entries;
|
|
342
|
+
try {
|
|
343
|
+
entries = fs.readdirSync(p);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
return entries.some((e) => e !== "vexp.md" || hasOwnContent(path.join(p, e)));
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
return withoutVexpSection(fs.readFileSync(p, "utf-8")).trim() !== "";
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function isDirectory(p) {
|
|
358
|
+
try {
|
|
359
|
+
return fs.statSync(p).isDirectory();
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** Where `d` is found for this workspace, if anywhere. */
|
|
366
|
+
export function classifyAgent(d, workspaceRoot) {
|
|
367
|
+
const markers = [d.detectPath, ...(d.detectPaths ?? [])];
|
|
368
|
+
const inProject = markers.some((m) => {
|
|
369
|
+
const p = path.join(workspaceRoot, m);
|
|
370
|
+
if (!fs.existsSync(p))
|
|
371
|
+
return false;
|
|
372
|
+
return !VEXP_WRITTEN_MARKERS.has(m) || hasOwnContent(p);
|
|
373
|
+
});
|
|
374
|
+
if (inProject)
|
|
375
|
+
return "project";
|
|
376
|
+
if (d.detectHome && isDirectory(path.join(os.homedir(), d.detectHome)))
|
|
377
|
+
return "machine";
|
|
378
|
+
if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p)))
|
|
379
|
+
return "machine";
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
/** Every agent found for this workspace, in detector order. */
|
|
383
|
+
export function classifyAgents(workspaceRoot) {
|
|
384
|
+
const out = [];
|
|
385
|
+
for (const d of AGENT_DETECTORS) {
|
|
386
|
+
const kind = classifyAgent(d, workspaceRoot);
|
|
387
|
+
if (kind)
|
|
388
|
+
out.push({ agent: d.agent, kind });
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* What a setup does without asking. Project markers and earlier "yes"
|
|
394
|
+
* answers are configured as they always were; traces on the machine alone
|
|
395
|
+
* (Codex's login, Antigravity's folder, Cline's storage, ~/.claude) wait for
|
|
396
|
+
* the user. Nothing vexp wrote counts as an answer: a 3.2.5 user whose Codex
|
|
397
|
+
* setup came from AGENTS.md is asked once, like everyone else.
|
|
398
|
+
*/
|
|
399
|
+
export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
|
|
400
|
+
const plan = { configure: [], offer: [], declined: [], kinds: {} };
|
|
401
|
+
const key = consentKey(workspaceRoot);
|
|
402
|
+
for (const d of AGENT_DETECTORS) {
|
|
403
|
+
const kind = classifyAgent(d, workspaceRoot);
|
|
404
|
+
if (kind)
|
|
405
|
+
plan.kinds[d.agent] = kind;
|
|
406
|
+
const decision = decisionForKey(consent, key, d.agent);
|
|
407
|
+
if (kind === "project" || decision === "yes")
|
|
408
|
+
plan.configure.push(d.agent);
|
|
409
|
+
else if (kind === "machine")
|
|
410
|
+
(decision === "no" || decision === "never" ? plan.declined : plan.offer).push(d.agent);
|
|
411
|
+
}
|
|
412
|
+
return plan;
|
|
413
|
+
}
|
|
414
|
+
/** The command that configures `agents` here and does nothing else: `vexp
|
|
415
|
+
* setup` without --no-index indexes the project again first. */
|
|
416
|
+
export function setupAgentsCommand(agents, dir) {
|
|
417
|
+
return `vexp setup ${dir ? `"${dir}" ` : ""}--no-index --agents "${agents.join(",")}"`;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* `vexp setup` with no one at the keyboard (an agent following doctor's
|
|
421
|
+
* advice, a script): the agents it sets up, the lines it prints about the
|
|
422
|
+
* ones it leaves alone, and whether it found no agent at all, the one case
|
|
423
|
+
* that exits 1. It used to exit 1 also when every agent found was only on
|
|
424
|
+
* the machine and waiting for a choice, right after naming the command that
|
|
425
|
+
* adds them, and that command indexed the project again (review of the
|
|
426
|
+
* 2026-09-24 fix). A tool waiting for a choice, or declined, is left alone
|
|
427
|
+
* as the user wants: nothing to configure then is no failure.
|
|
428
|
+
*/
|
|
429
|
+
export function nonInteractiveAgentChoice(plan, dir) {
|
|
430
|
+
const notes = [];
|
|
431
|
+
if (plan.offer.length > 0) {
|
|
432
|
+
notes.push(`Found on this machine, not set up for this project: ${plan.offer.join(", ")}.`);
|
|
433
|
+
notes.push(`To set ${plan.offer.length === 1 ? "it" : "them"} up here: ${setupAgentsCommand(plan.offer, dir)}`);
|
|
434
|
+
}
|
|
435
|
+
if (plan.configure.length === 0) {
|
|
436
|
+
if (plan.declined.length > 0)
|
|
437
|
+
notes.push(`Not set up, as answered earlier: ${plan.declined.join(", ")}.`);
|
|
438
|
+
if (plan.offer.length > 0 || plan.declined.length > 0)
|
|
439
|
+
notes.push("No agent set up for this project.");
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
selected: [...plan.configure],
|
|
443
|
+
notes,
|
|
444
|
+
noAgentFound: plan.configure.length === 0 && plan.offer.length === 0 && plan.declined.length === 0,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
273
447
|
// ---------------------------------------------------------------------------
|
|
274
448
|
// Public API
|
|
275
449
|
// ---------------------------------------------------------------------------
|
|
@@ -306,8 +480,9 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
|
|
|
306
480
|
}
|
|
307
481
|
break;
|
|
308
482
|
case "Codex":
|
|
309
|
-
|
|
310
|
-
out.push("
|
|
483
|
+
// Only the user config: the entry names no project, so one serves them all.
|
|
484
|
+
out.push("~/.codex/config.toml [mcp_servers.vexp] (MCP server entry, machine-wide)");
|
|
485
|
+
out.push(`.codex/vexp-hint.sh${process.platform === "win32" ? " + .codex/vexp-hint.cmd" : ""} + .codex/hooks.json hooks.UserPromptSubmit (orientation; approve it once in codex with /hooks)`);
|
|
311
486
|
break;
|
|
312
487
|
case "Cline":
|
|
313
488
|
out.push("cline_mcp_settings.json in each installed VS Code variant (MCP, machine-global)");
|
|
@@ -353,37 +528,29 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
|
|
|
353
528
|
return out;
|
|
354
529
|
}
|
|
355
530
|
/**
|
|
356
|
-
* Detect which AI coding agents are present in the workspace
|
|
531
|
+
* Detect which AI coding agents are present — in the workspace or, for the
|
|
532
|
+
* machine-only markers, on this machine (see classifyAgent).
|
|
357
533
|
*/
|
|
358
534
|
export function detectAgents(workspaceRoot) {
|
|
359
|
-
return AGENT_DETECTORS.filter((d) =>
|
|
360
|
-
const markers = [d.detectPath, ...(d.detectPaths ?? [])];
|
|
361
|
-
if (markers.some((m) => fs.existsSync(path.join(workspaceRoot, m)))) {
|
|
362
|
-
return true;
|
|
363
|
-
}
|
|
364
|
-
// Home-level marker: the IDE is installed on this machine even though
|
|
365
|
-
// the workspace carries no footprint (e.g. Antigravity).
|
|
366
|
-
if (d.detectHome && fs.existsSync(path.join(os.homedir(), d.detectHome))) {
|
|
367
|
-
return true;
|
|
368
|
-
}
|
|
369
|
-
if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p))) {
|
|
370
|
-
return true;
|
|
371
|
-
}
|
|
372
|
-
return false;
|
|
373
|
-
});
|
|
535
|
+
return AGENT_DETECTORS.filter((d) => classifyAgent(d, workspaceRoot) !== undefined);
|
|
374
536
|
}
|
|
375
537
|
/**
|
|
376
|
-
* Configure
|
|
377
|
-
*
|
|
538
|
+
* Configure agents: write instruction files + MCP configs. With
|
|
539
|
+
* `agentFilter`, the detected agents it names; without, what
|
|
540
|
+
* planAgentSetup configures without asking.
|
|
378
541
|
*/
|
|
379
542
|
export function configureAgents(workspaceRoot, binaryPath, version, agentFilter, mcpServerPath) {
|
|
380
543
|
const results = [];
|
|
381
544
|
const mcpConfigs = [];
|
|
382
545
|
const writtenConfigFiles = new Set();
|
|
383
|
-
let agents
|
|
546
|
+
let agents;
|
|
384
547
|
if (agentFilter && agentFilter.length > 0) {
|
|
385
548
|
const filter = agentFilter.map((a) => a.toLowerCase());
|
|
386
|
-
agents =
|
|
549
|
+
agents = detectAgents(workspaceRoot).filter((d) => filter.includes(d.agent.toLowerCase()));
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
const plan = planAgentSetup(workspaceRoot);
|
|
553
|
+
agents = AGENT_DETECTORS.filter((d) => plan.configure.includes(d.agent));
|
|
387
554
|
}
|
|
388
555
|
for (const detected of agents) {
|
|
389
556
|
const detector = withCursorTarget(detected, workspaceRoot);
|
|
@@ -470,7 +637,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
470
637
|
}
|
|
471
638
|
// Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
|
|
472
639
|
if (detector.agent === "Claude Code") {
|
|
473
|
-
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
|
|
640
|
+
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
|
|
474
641
|
if (wrote)
|
|
475
642
|
mcpConfigs.push("~/.claude.json");
|
|
476
643
|
// Event-driven hint hook: default-on (non-blocking, fail-open — the
|
|
@@ -847,7 +1014,7 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
847
1014
|
removeDeadWindsurfMcpJson(workspaceRoot);
|
|
848
1015
|
}
|
|
849
1016
|
if (detector.agent === "Claude Code") {
|
|
850
|
-
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
|
|
1017
|
+
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
|
|
851
1018
|
if (wrote)
|
|
852
1019
|
mcpConfigs.push("~/.claude.json");
|
|
853
1020
|
installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
@@ -1223,6 +1390,50 @@ export function isBareInterpreter(entry) {
|
|
|
1223
1390
|
function adoptableEntry(entry) {
|
|
1224
1391
|
return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry);
|
|
1225
1392
|
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Is `recorded` strictly older than `mine`? Copy of the extension's
|
|
1395
|
+
* isOlderVexpVersion (itself one of several: mcp-supervisor.ts carries the
|
|
1396
|
+
* tests of the order). Unparseable is never older: such an entry is kept.
|
|
1397
|
+
*/
|
|
1398
|
+
function isOlderVexpVersion(recorded, mine) {
|
|
1399
|
+
if (recorded === mine)
|
|
1400
|
+
return false;
|
|
1401
|
+
const parts = (v) => {
|
|
1402
|
+
const nums = v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
|
|
1403
|
+
return nums.length === 3 && nums.every((n) => Number.isFinite(n)) ? nums : null;
|
|
1404
|
+
};
|
|
1405
|
+
const a = parts(recorded);
|
|
1406
|
+
const b = parts(mine);
|
|
1407
|
+
if (!a || !b)
|
|
1408
|
+
return false;
|
|
1409
|
+
for (let i = 0; i < 3; i++) {
|
|
1410
|
+
if (a[i] !== b[i])
|
|
1411
|
+
return a[i] < b[i];
|
|
1412
|
+
}
|
|
1413
|
+
return false;
|
|
1414
|
+
}
|
|
1415
|
+
/** The extension version an MCP entry points into (`vexp.vexp-vscode-3.2.5`,
|
|
1416
|
+
* with or without a platform suffix), or undefined when it is not one of
|
|
1417
|
+
* the extension's folders. Lockstep with the extension. */
|
|
1418
|
+
function vexpInstallVersion(entry) {
|
|
1419
|
+
if (!entry || typeof entry !== "object")
|
|
1420
|
+
return undefined;
|
|
1421
|
+
const e = entry;
|
|
1422
|
+
const parts = [];
|
|
1423
|
+
if (typeof e.command === "string")
|
|
1424
|
+
parts.push(e.command);
|
|
1425
|
+
if (Array.isArray(e.args)) {
|
|
1426
|
+
for (const a of e.args)
|
|
1427
|
+
if (typeof a === "string")
|
|
1428
|
+
parts.push(a);
|
|
1429
|
+
}
|
|
1430
|
+
for (const p of parts) {
|
|
1431
|
+
const m = p.match(/vexp\.vexp-vscode-(\d+\.\d+\.\d+)/);
|
|
1432
|
+
if (m)
|
|
1433
|
+
return m[1];
|
|
1434
|
+
}
|
|
1435
|
+
return undefined;
|
|
1436
|
+
}
|
|
1226
1437
|
function vexpEntryStillResolves(entry) {
|
|
1227
1438
|
if (!entry || typeof entry !== "object")
|
|
1228
1439
|
return false;
|
|
@@ -1350,6 +1561,38 @@ const unreachableTargets = [];
|
|
|
1350
1561
|
export function takeUnreachableTargets() {
|
|
1351
1562
|
return unreachableTargets.splice(0, unreachableTargets.length);
|
|
1352
1563
|
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Projects whose Codex hook was written for the first time, or whose hook
|
|
1566
|
+
* definition changed, in this run. Codex runs a project hook only once the
|
|
1567
|
+
* user has approved that exact definition (a trust record in its own
|
|
1568
|
+
* config.toml, keyed by the hook's position and hashed over its command);
|
|
1569
|
+
* vexp never writes that record for them. Nothing in the project shows the
|
|
1570
|
+
* hook is waiting for that, so the step has to be said where the hook was
|
|
1571
|
+
* written.
|
|
1572
|
+
*/
|
|
1573
|
+
const codexHookApprovals = [];
|
|
1574
|
+
/** Drain the list of projects whose Codex hook needs the user's approval. */
|
|
1575
|
+
export function takeCodexHookApprovals() {
|
|
1576
|
+
return codexHookApprovals.splice(0, codexHookApprovals.length);
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The approval step, in the words the VS Code extension shows too
|
|
1580
|
+
* (agent-auto-config.ts codexHookApprovalStep, lockstep-tested). ASCII only.
|
|
1581
|
+
*
|
|
1582
|
+
* Both places that approve a hook are named: the Codex extension for VS Code
|
|
1583
|
+
* has its own Trust action (Settings > Hooks), and a terminal has /hooks. On
|
|
1584
|
+
* Windows an approval in one does not reach the other: Codex keys the record
|
|
1585
|
+
* by the folder as the session spells it, `C:\` in a terminal and `c:\` in
|
|
1586
|
+
* the VS Code extension (codex.exe 0.155, hooks/list).
|
|
1587
|
+
*/
|
|
1588
|
+
export function codexHookApprovalStep(workspaceRoot, platform = process.platform) {
|
|
1589
|
+
const step = "Codex runs vexp's orientation hook only after you approve it once. " +
|
|
1590
|
+
"In the Codex extension for VS Code: open the Codex panel > Settings > Hooks and trust vexp-hint. " +
|
|
1591
|
+
`In a terminal: run codex in ${workspaceRoot}, type /hooks and approve vexp-hint (trust the folder first if Codex asks).`;
|
|
1592
|
+
return platform === "win32"
|
|
1593
|
+
? `${step} On Windows the two keep separate approvals (they spell the drive letter differently), so approve it in each one you use; 'vexp doctor' shows which have it.`
|
|
1594
|
+
: `${step} 'vexp doctor' shows whether Codex has it approved.`;
|
|
1595
|
+
}
|
|
1353
1596
|
/**
|
|
1354
1597
|
* Cline keeps its MCP registry inside the VS Code extension's globalStorage,
|
|
1355
1598
|
* so there is nothing to write until the extension has been installed once.
|
|
@@ -1942,6 +2185,10 @@ function readMcpPort(workspaceRoot) {
|
|
|
1942
2185
|
// tell OUR config apart from a config the user hand-wrote. Must stay in sync
|
|
1943
2186
|
// with packages/vexp-vscode/src/providers/agent-auto-config.ts.
|
|
1944
2187
|
const CODEX_MANAGED_MARKER = "# vexp-managed";
|
|
2188
|
+
// What marks a section `vexp use --pin` pinned on purpose. Setup and every
|
|
2189
|
+
// VS Code start keep such a pin; only `vexp use` (another project, or
|
|
2190
|
+
// --unpin) changes it. Twin: agent-auto-config.ts.
|
|
2191
|
+
const CODEX_USE_PIN_MARK = "pinned by 'vexp use'";
|
|
1945
2192
|
/** Quote a value as a TOML literal string (single quotes, no escaping) so
|
|
1946
2193
|
* Windows paths work verbatim. Falls back to a basic escaped string if the
|
|
1947
2194
|
* value contains a single quote. */
|
|
@@ -1979,6 +2226,13 @@ function codexDirectBinary(section) {
|
|
|
1979
2226
|
return undefined;
|
|
1980
2227
|
return cmd;
|
|
1981
2228
|
}
|
|
2229
|
+
/** The project `vexp use --pin` pinned our section to, when it did. Twin: agent-auto-config.ts. */
|
|
2230
|
+
function explicitCodexPin(section) {
|
|
2231
|
+
if (!section.includes(CODEX_MANAGED_MARKER) || !section.includes(CODEX_USE_PIN_MARK))
|
|
2232
|
+
return undefined;
|
|
2233
|
+
const m = /^\s*VEXP_WORKSPACE\s*=\s*(.+?)\s*$/m.exec(section);
|
|
2234
|
+
return m ? parseTomlString(m[1]) : undefined;
|
|
2235
|
+
}
|
|
1982
2236
|
function extractCodexVexpSection(content) {
|
|
1983
2237
|
const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
|
|
1984
2238
|
return m ? m.join("\n") : "";
|
|
@@ -1999,6 +2253,19 @@ export function codexGlobalSectionIsUserManaged() {
|
|
|
1999
2253
|
}
|
|
2000
2254
|
return isUserManagedCodexSection(extractCodexVexpSection(content));
|
|
2001
2255
|
}
|
|
2256
|
+
/** The project an explicit `vexp use --pin` pinned ~/.codex/config.toml's
|
|
2257
|
+
* vexp entry to, if it did. */
|
|
2258
|
+
export function codexGlobalExplicitPin() {
|
|
2259
|
+
const home = os.homedir();
|
|
2260
|
+
if (!home)
|
|
2261
|
+
return undefined;
|
|
2262
|
+
try {
|
|
2263
|
+
return explicitCodexPin(extractCodexVexpSection(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf-8")));
|
|
2264
|
+
}
|
|
2265
|
+
catch {
|
|
2266
|
+
return undefined;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2002
2269
|
/** True when the existing [mcp_servers.vexp] section was written by the user
|
|
2003
2270
|
* (not by vexp) and must NOT be overwritten. */
|
|
2004
2271
|
function isUserManagedCodexSection(section) {
|
|
@@ -2015,7 +2282,19 @@ function isUserManagedCodexSection(section) {
|
|
|
2015
2282
|
return false;
|
|
2016
2283
|
}
|
|
2017
2284
|
/** Build the canonical [mcp_servers.vexp] section for the chosen transport.
|
|
2018
|
-
* Returns null for `direct` when no usable core binary is available.
|
|
2285
|
+
* Returns null for `direct` when no usable core binary is available.
|
|
2286
|
+
*
|
|
2287
|
+
* The direct section names no project unless `pinTo` says so: Codex starts a
|
|
2288
|
+
* stdio server per thread, in that thread's folder, and restarts it when the
|
|
2289
|
+
* folder changes (codex-rs rmcp-client stdio_server_launcher + codex-mcp
|
|
2290
|
+
* runtime, since openai/codex#19031, 2026-04), and `vexp-core mcp` finds the
|
|
2291
|
+
* project from there. The pin it carried until 3.2.5 named the project set
|
|
2292
|
+
* up LAST, so with two projects Codex queried the wrong index in one of
|
|
2293
|
+
* them. `pinTo` is `vexp use --pin`, on purpose. The http section keeps its
|
|
2294
|
+
* project: the URL routes to one workspace's daemon.
|
|
2295
|
+
*
|
|
2296
|
+
* BYTE-IDENTICAL with the extension's buildCodexSection, marker lines
|
|
2297
|
+
* included, or the two rewrite this machine-wide file in turn. */
|
|
2019
2298
|
function buildCodexSection(opts) {
|
|
2020
2299
|
if (opts.transport === "direct") {
|
|
2021
2300
|
if (!opts.coreBinaryPath)
|
|
@@ -2023,18 +2302,21 @@ function buildCodexSection(opts) {
|
|
|
2023
2302
|
const lines = [
|
|
2024
2303
|
"",
|
|
2025
2304
|
"[mcp_servers.vexp]",
|
|
2026
|
-
|
|
2305
|
+
opts.pinTo
|
|
2306
|
+
? `${CODEX_MANAGED_MARKER}: direct transport, ${CODEX_USE_PIN_MARK} --pin (run 'vexp use --unpin' to follow each Codex session's folder again)`
|
|
2307
|
+
: `${CODEX_MANAGED_MARKER}: direct transport, started in each Codex session's folder (VEXP_CODEX_TRANSPORT or the vexp.codexMcpTransport setting chooses the transport)`,
|
|
2027
2308
|
`command = ${tomlString(opts.coreBinaryPath)}`,
|
|
2028
|
-
//
|
|
2029
|
-
// the env pin below, so a stale cwd / stale cached env
|
|
2030
|
-
//
|
|
2031
|
-
|
|
2309
|
+
// A pin passes --workspace explicitly (highest precedence in cmd_mcp)
|
|
2310
|
+
// IN ADDITION to the env pin below, so a stale cwd / stale cached env
|
|
2311
|
+
// can't drift the child to another repo. JSON.stringify yields a
|
|
2312
|
+
// TOML-valid quoted string array.
|
|
2313
|
+
`args = ${opts.pinTo ? JSON.stringify(["mcp", "--workspace", opts.pinTo]) : '["mcp"]'}`,
|
|
2032
2314
|
];
|
|
2033
|
-
if (opts.
|
|
2034
|
-
lines.push(`cwd = ${tomlString(opts.
|
|
2315
|
+
if (opts.pinTo)
|
|
2316
|
+
lines.push(`cwd = ${tomlString(opts.pinTo)}`);
|
|
2035
2317
|
lines.push("tool_timeout_sec = 120", "", "[mcp_servers.vexp.env]");
|
|
2036
|
-
if (opts.
|
|
2037
|
-
lines.push(`VEXP_WORKSPACE = ${tomlString(opts.
|
|
2318
|
+
if (opts.pinTo)
|
|
2319
|
+
lines.push(`VEXP_WORKSPACE = ${tomlString(opts.pinTo)}`);
|
|
2038
2320
|
if (opts.home)
|
|
2039
2321
|
lines.push(`VEXP_HOME = ${tomlString(opts.home)}`);
|
|
2040
2322
|
lines.push("");
|
|
@@ -2045,7 +2327,7 @@ function buildCodexSection(opts) {
|
|
|
2045
2327
|
const desiredUrl = `http://127.0.0.1:${opts.mcpPort}${urlPath}`;
|
|
2046
2328
|
return `
|
|
2047
2329
|
[mcp_servers.vexp]
|
|
2048
|
-
${CODEX_MANAGED_MARKER}: http transport (
|
|
2330
|
+
${CODEX_MANAGED_MARKER}: http transport, routed to one project (VEXP_CODEX_TRANSPORT or the vexp.codexMcpTransport setting chooses the transport)
|
|
2049
2331
|
url = "${desiredUrl}"
|
|
2050
2332
|
tool_timeout_sec = 120
|
|
2051
2333
|
|
|
@@ -2055,16 +2337,30 @@ Authorization = "Bearer ${opts.token}"
|
|
|
2055
2337
|
}
|
|
2056
2338
|
/**
|
|
2057
2339
|
* Configure MCP in ~/.codex/config.toml (global).
|
|
2058
|
-
*
|
|
2340
|
+
*
|
|
2341
|
+
* Codex reads MCP servers from its user config and, for a project the user
|
|
2342
|
+
* has trusted, from every `.codex/config.toml` between the project root and
|
|
2343
|
+
* the session's folder, deep-merged over the user entry key by key
|
|
2344
|
+
* (codex-rs config loader). vexp writes only the user file: a project file
|
|
2345
|
+
* counts only once the project is trusted, a parse error in it stops Codex
|
|
2346
|
+
* from loading its config at all, it is usually committed with this
|
|
2347
|
+
* machine's paths in it, and Codex's "Always allow" would start writing
|
|
2348
|
+
* into it. Nothing is lost by it: the direct entry names no project (see
|
|
2349
|
+
* buildCodexSection), so one user entry serves every project.
|
|
2059
2350
|
*
|
|
2060
2351
|
* Default transport is `direct` (stdio: `vexp-core mcp`), which works with AND
|
|
2061
2352
|
* without VS Code/daemon: `vexp-core mcp` auto-proxies to a running daemon when
|
|
2062
2353
|
* reachable and otherwise runs an embedded in-process index. Override with the
|
|
2063
2354
|
* VEXP_CODEX_TRANSPORT env var ("direct" | "http").
|
|
2064
2355
|
*
|
|
2356
|
+
* `pin` is `vexp use <project> --pin`: the one way to name a project in the entry,
|
|
2357
|
+
* kept by every later setup until `vexp use` changes it. `unpin` is
|
|
2358
|
+
* `vexp use --unpin`. [mcp_servers.vexp.tools.*] tables (Codex's per-tool
|
|
2359
|
+
* approvals) are never touched.
|
|
2360
|
+
*
|
|
2065
2361
|
* Never clobbers a config the user wrote by hand.
|
|
2066
2362
|
*/
|
|
2067
|
-
export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot) {
|
|
2363
|
+
export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot, opts = {}) {
|
|
2068
2364
|
const home = os.homedir();
|
|
2069
2365
|
if (!home)
|
|
2070
2366
|
return false;
|
|
@@ -2102,13 +2398,14 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
|
|
|
2102
2398
|
// Step 2b: adopt the binary a previous install already pinned here, when it
|
|
2103
2399
|
// still exists. Our own sections are not "user-managed", so `vexp setup` and
|
|
2104
2400
|
// every IDE activation rewrote this file with their own binary path — and the
|
|
2105
|
-
// file is machine-global
|
|
2106
|
-
//
|
|
2107
|
-
//
|
|
2108
|
-
// must still follow the project being configured. See the twin comment in
|
|
2401
|
+
// file is machine-global, so it churned for every project at once. The
|
|
2402
|
+
// binary is interchangeable; adopting it keeps the section byte-identical
|
|
2403
|
+
// whoever writes it. See the twin comment in
|
|
2109
2404
|
// vexp-vscode/src/providers/agent-auto-config.ts.
|
|
2110
2405
|
const coreBinaryPath = (transport === "direct" ? codexDirectBinary(existingSection) : undefined) ?? ourCoreBinary;
|
|
2111
|
-
|
|
2406
|
+
// A pin `vexp use --pin` made stays until `vexp use` says otherwise.
|
|
2407
|
+
const pinTo = opts.pin ? workspaceRoot : opts.unpin ? undefined : explicitCodexPin(existingSection);
|
|
2408
|
+
const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, pinTo, mcpPort, token, home });
|
|
2112
2409
|
if (!newSection) {
|
|
2113
2410
|
// direct requested but binary missing → preserve the existing section (only
|
|
2114
2411
|
// persist any legacy cleanup we already did); never emit a url fallback.
|
|
@@ -2420,7 +2717,9 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
|
2420
2717
|
* Configure vexp MCP in ~/.claude.json (user-scope) using the Rust binary.
|
|
2421
2718
|
* Returns true if config was written/updated.
|
|
2422
2719
|
*/
|
|
2423
|
-
export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot
|
|
2720
|
+
export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot,
|
|
2721
|
+
/** This install's version: an entry left by an OLDER extension is taken over. */
|
|
2722
|
+
version) {
|
|
2424
2723
|
const home = os.homedir();
|
|
2425
2724
|
if (!home)
|
|
2426
2725
|
return false;
|
|
@@ -2458,8 +2757,19 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
|
|
|
2458
2757
|
// pinning VEXP_WORKSPACE must be rewritten no matter whose it is, because
|
|
2459
2758
|
// this file applies to EVERY project and the pin forces all parallel
|
|
2460
2759
|
// sessions onto one daemon (the migration described above).
|
|
2461
|
-
|
|
2462
|
-
|
|
2760
|
+
//
|
|
2761
|
+
// Ordered, not symmetric: an entry inside an OLDER vexp.vexp-vscode-<ver>
|
|
2762
|
+
// folder still resolves right after an update, and keeping it meant
|
|
2763
|
+
// Claude Code lost vexp the moment VS Code reaped that folder (seen on
|
|
2764
|
+
// 3.2.5 machines still pointing at 3.2.4, 2026-09-24). Take over an older
|
|
2765
|
+
// one, keep a newer one, and keep anything we cannot version (the npm
|
|
2766
|
+
// CLI's own path, a custom launcher). Lockstep with the extension.
|
|
2767
|
+
if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing)) {
|
|
2768
|
+
const theirs = vexpInstallVersion(existing);
|
|
2769
|
+
const ours = vexpInstallVersion({ command: desiredCommand, args: desiredArgs }) ?? version;
|
|
2770
|
+
if (!theirs || !ours || !isOlderVexpVersion(theirs, ours))
|
|
2771
|
+
return false;
|
|
2772
|
+
}
|
|
2463
2773
|
const servers = config.mcpServers ?? {};
|
|
2464
2774
|
servers["vexp"] = {
|
|
2465
2775
|
command: desiredCommand,
|
|
@@ -2972,7 +3282,7 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
2972
3282
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
2973
3283
|
const hookPath = path.join(hookDir, "vexp-hint.sh");
|
|
2974
3284
|
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
2975
|
-
const script = vexpHintHookScript(binaryPath);
|
|
3285
|
+
const script = vexpHintHookScript(binaryPath, "claude-code");
|
|
2976
3286
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
2977
3287
|
// v5: the edit-time coupling hook, written beside the prompt-time one -
|
|
2978
3288
|
// but only when interventions are on. It is one of the two mechanisms that
|
|
@@ -3094,7 +3404,7 @@ export function installOpencodeCompressPlugin(workspaceRoot, binaryPath, pluginD
|
|
|
3094
3404
|
}
|
|
3095
3405
|
export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir) {
|
|
3096
3406
|
const pluginPath = path.join(workspaceRoot, pluginDir, "vexp-hint.js");
|
|
3097
|
-
const content = vexpOpencodeHintPlugin(binaryPath);
|
|
3407
|
+
const content = vexpOpencodeHintPlugin(binaryPath, opencodeFamilyAgent(pluginDir));
|
|
3098
3408
|
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
3099
3409
|
const existed = fs.existsSync(pluginPath);
|
|
3100
3410
|
if (existed && fs.readFileSync(pluginPath, "utf-8") === content)
|
|
@@ -3110,25 +3420,26 @@ export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir)
|
|
|
3110
3420
|
* Fail-open: missing bash/binary/daemon => no output => vanilla.
|
|
3111
3421
|
*/
|
|
3112
3422
|
export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
3113
|
-
|
|
3423
|
+
// Codex has no project-dir variable, so the command names the script by
|
|
3424
|
+
// its absolute path: spelled with the drive letter the extension uses
|
|
3425
|
+
// too, or each installer rewrites what the other wrote (upperDriveLetter).
|
|
3426
|
+
const dir = path.join(upperDriveLetter(workspaceRoot), ".codex");
|
|
3114
3427
|
const hookPath = path.join(dir, "vexp-hint.sh");
|
|
3115
3428
|
const hooksJsonPath = path.join(dir, "hooks.json");
|
|
3116
|
-
const script = vexpHintHookScript(binaryPath);
|
|
3429
|
+
const script = vexpHintHookScript(binaryPath, "codex");
|
|
3117
3430
|
fs.mkdirSync(dir, { recursive: true });
|
|
3118
3431
|
const existed = fs.existsSync(hookPath);
|
|
3119
3432
|
let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
|
|
3120
3433
|
if (!scriptIdentical) {
|
|
3121
3434
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3122
3435
|
}
|
|
3123
|
-
// Windows gets a batch twin.
|
|
3124
|
-
//
|
|
3125
|
-
//
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3129
|
-
//
|
|
3130
|
-
// write). Older builds ignore the field rather than reject the file —
|
|
3131
|
-
// the handler is not deny_unknown_fields, unlike the top level.
|
|
3436
|
+
// Windows gets a batch twin. `bash "..."` needs a bash, and a user with
|
|
3437
|
+
// neither Git Bash nor WSL got "bash not found" on every prompt, so
|
|
3438
|
+
// orientation was inert. Codex's own answer is `commandWindows`, an
|
|
3439
|
+
// optional per-OS override on the command handler (openai/codex#22159,
|
|
3440
|
+
// ~0.131, months before the version that requires the nested `hooks` shape
|
|
3441
|
+
// we already write). Older builds ignore the field rather than reject the
|
|
3442
|
+
// file — the handler is not deny_unknown_fields, unlike the top level.
|
|
3132
3443
|
const cmdPath = path.join(dir, "vexp-hint.cmd");
|
|
3133
3444
|
let cmdCommand;
|
|
3134
3445
|
if (process.platform === "win32") {
|
|
@@ -3138,10 +3449,10 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3138
3449
|
fs.writeFileSync(cmdPath, cmdScript);
|
|
3139
3450
|
scriptIdentical = false;
|
|
3140
3451
|
}
|
|
3141
|
-
//
|
|
3142
|
-
//
|
|
3143
|
-
//
|
|
3144
|
-
cmdCommand =
|
|
3452
|
+
// Through `cmd /d /c call`, not the bare path: Codex runs the line in
|
|
3453
|
+
// PowerShell by default, where a quoted path is only a string to print
|
|
3454
|
+
// (codexWindowsHookCommand has the details).
|
|
3455
|
+
cmdCommand = codexWindowsHookCommand(cmdPath);
|
|
3145
3456
|
}
|
|
3146
3457
|
let root = {};
|
|
3147
3458
|
if (fs.existsSync(hooksJsonPath)) {
|
|
@@ -3177,9 +3488,8 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3177
3488
|
? codexHooks.UserPromptSubmit
|
|
3178
3489
|
: [];
|
|
3179
3490
|
const existing = [...wrapped, ...legacyTopLevel];
|
|
3180
|
-
const filtered = existing.filter((h) => !isVexpHintHookEntry(h));
|
|
3181
3491
|
// Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution.
|
|
3182
|
-
|
|
3492
|
+
const ours = {
|
|
3183
3493
|
hooks: [
|
|
3184
3494
|
{
|
|
3185
3495
|
type: "command",
|
|
@@ -3188,11 +3498,16 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3188
3498
|
timeout: 5,
|
|
3189
3499
|
},
|
|
3190
3500
|
],
|
|
3191
|
-
}
|
|
3501
|
+
};
|
|
3502
|
+
const merged = placeCodexHintEntry(existing, ours);
|
|
3192
3503
|
const before = JSON.stringify(root);
|
|
3193
|
-
codexHooks.UserPromptSubmit =
|
|
3504
|
+
codexHooks.UserPromptSubmit = merged;
|
|
3194
3505
|
root.hooks = codexHooks;
|
|
3195
3506
|
const identical = before === JSON.stringify(root);
|
|
3507
|
+
if (codexHookDefinitionChanged(wrapped, merged, ours)) {
|
|
3508
|
+
if (!codexHookApprovals.includes(workspaceRoot))
|
|
3509
|
+
codexHookApprovals.push(workspaceRoot);
|
|
3510
|
+
}
|
|
3196
3511
|
if (scriptIdentical && identical)
|
|
3197
3512
|
return null;
|
|
3198
3513
|
if (!identical) {
|
|
@@ -3200,6 +3515,36 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3200
3515
|
}
|
|
3201
3516
|
return existed ? "updated" : "created";
|
|
3202
3517
|
}
|
|
3518
|
+
/**
|
|
3519
|
+
* vexp's UserPromptSubmit group, put where the previous one was.
|
|
3520
|
+
*
|
|
3521
|
+
* Codex keys a hook's trust record by its position in the event's list
|
|
3522
|
+
* (`<hooks.json>:user_prompt_submit:<group>:<handler>`, codex-rs hooks
|
|
3523
|
+
* hook_key). Removing our group and appending it at the end moved it whenever
|
|
3524
|
+
* the user had a hook of their own after it: our key changed, and theirs
|
|
3525
|
+
* shifted onto hashes recorded for other hooks, so every one of them read as
|
|
3526
|
+
* modified and stopped running until approved again. In place, nothing moves.
|
|
3527
|
+
* Twin: agent-auto-config.ts placeCodexHintEntry.
|
|
3528
|
+
*/
|
|
3529
|
+
export function placeCodexHintEntry(existing, ours) {
|
|
3530
|
+
const at = existing.findIndex((h) => isVexpHintHookEntry(h));
|
|
3531
|
+
const merged = existing.flatMap((h, i) => (i === at ? [ours] : isVexpHintHookEntry(h) ? [] : [h]));
|
|
3532
|
+
if (at < 0)
|
|
3533
|
+
merged.push(ours);
|
|
3534
|
+
return merged;
|
|
3535
|
+
}
|
|
3536
|
+
/**
|
|
3537
|
+
* Did writing `ours` change what Codex trusts: a first write, a different
|
|
3538
|
+
* definition, or a different position? `before` is the list Codex read until
|
|
3539
|
+
* now (the nested one; a legacy top-level list is not loaded by a Codex that
|
|
3540
|
+
* reads hooks.json this way). Twin: agent-auto-config.ts.
|
|
3541
|
+
*/
|
|
3542
|
+
export function codexHookDefinitionChanged(before, after, ours) {
|
|
3543
|
+
const was = before.findIndex((h) => isVexpHintHookEntry(h));
|
|
3544
|
+
if (was < 0)
|
|
3545
|
+
return true;
|
|
3546
|
+
return was !== after.indexOf(ours) || JSON.stringify(before[was]) !== JSON.stringify(ours);
|
|
3547
|
+
}
|
|
3203
3548
|
// ---------------------------------------------------------------------------
|
|
3204
3549
|
// opencode plugin - blocks grep/glob (and shelled-out search) when the daemon
|
|
3205
3550
|
// is healthy. opencode has no PreToolUse hook, but auto-loads plugins from
|