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/README.md +4 -4
- package/dist/agent-config.js +411 -69
- package/dist/agent-consent.js +244 -0
- package/dist/cli.js +197 -95
- package/dist/codex-trust.js +608 -0
- package/dist/doctor.js +1105 -107
- package/dist/forget.js +4 -2
- package/dist/hook-template.js +78 -23
- package/dist/license-activate.js +90 -0
- package/dist/license.js +577 -125
- package/mcp/mcp-server.cjs +48 -48
- package/package.json +7 -7
package/dist/agent-config.js
CHANGED
|
@@ -12,7 +12,8 @@ import * as os from "os";
|
|
|
12
12
|
import { spawnSync } from "child_process";
|
|
13
13
|
import * as crypto from "crypto";
|
|
14
14
|
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
15
|
-
import {
|
|
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";
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Constants
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
@@ -116,10 +117,29 @@ export function interventionMode() {
|
|
|
116
117
|
// ---------------------------------------------------------------------------
|
|
117
118
|
// Agent detectors - mirrors VS Code extension's AGENT_DETECTORS
|
|
118
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
|
+
}
|
|
119
133
|
const AGENT_DETECTORS = [
|
|
120
134
|
{
|
|
121
135
|
agent: "Claude Code",
|
|
122
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",
|
|
123
143
|
configFile: ".claude/CLAUDE.md",
|
|
124
144
|
templateName: "claude-code",
|
|
125
145
|
// MCP configured in ~/.claude.json (user-scope) via configureClaudeCodeGlobal()
|
|
@@ -185,7 +205,19 @@ const AGENT_DETECTORS = [
|
|
|
185
205
|
},
|
|
186
206
|
{
|
|
187
207
|
agent: "Codex",
|
|
188
|
-
|
|
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,
|
|
189
221
|
configFile: "AGENTS.md",
|
|
190
222
|
templateName: "agents-md",
|
|
191
223
|
// MCP configured in ~/.codex/config.toml (global) via configureCodexGlobal()
|
|
@@ -266,13 +298,152 @@ const AGENT_DETECTORS = [
|
|
|
266
298
|
// same mandate every AGENTS.md agent gets, under a name of its own. It
|
|
267
299
|
// connects to vexp on its side, so there is no MCP file to write here:
|
|
268
300
|
// the instructions are the whole integration. Detected by the file
|
|
269
|
-
// itself,
|
|
301
|
+
// itself (Codex, by contrast, by its own ~/.codex footprint).
|
|
270
302
|
agent: "Friday Code",
|
|
271
303
|
detectPath: "friday.md",
|
|
272
304
|
configFile: "friday.md",
|
|
273
305
|
templateName: "agents-md",
|
|
274
306
|
},
|
|
275
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
|
+
}
|
|
276
447
|
// ---------------------------------------------------------------------------
|
|
277
448
|
// Public API
|
|
278
449
|
// ---------------------------------------------------------------------------
|
|
@@ -309,8 +480,9 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
|
|
|
309
480
|
}
|
|
310
481
|
break;
|
|
311
482
|
case "Codex":
|
|
312
|
-
|
|
313
|
-
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)`);
|
|
314
486
|
break;
|
|
315
487
|
case "Cline":
|
|
316
488
|
out.push("cline_mcp_settings.json in each installed VS Code variant (MCP, machine-global)");
|
|
@@ -356,37 +528,29 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
|
|
|
356
528
|
return out;
|
|
357
529
|
}
|
|
358
530
|
/**
|
|
359
|
-
* 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).
|
|
360
533
|
*/
|
|
361
534
|
export function detectAgents(workspaceRoot) {
|
|
362
|
-
return AGENT_DETECTORS.filter((d) =>
|
|
363
|
-
const markers = [d.detectPath, ...(d.detectPaths ?? [])];
|
|
364
|
-
if (markers.some((m) => fs.existsSync(path.join(workspaceRoot, m)))) {
|
|
365
|
-
return true;
|
|
366
|
-
}
|
|
367
|
-
// Home-level marker: the IDE is installed on this machine even though
|
|
368
|
-
// the workspace carries no footprint (e.g. Antigravity).
|
|
369
|
-
if (d.detectHome && fs.existsSync(path.join(os.homedir(), d.detectHome))) {
|
|
370
|
-
return true;
|
|
371
|
-
}
|
|
372
|
-
if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p))) {
|
|
373
|
-
return true;
|
|
374
|
-
}
|
|
375
|
-
return false;
|
|
376
|
-
});
|
|
535
|
+
return AGENT_DETECTORS.filter((d) => classifyAgent(d, workspaceRoot) !== undefined);
|
|
377
536
|
}
|
|
378
537
|
/**
|
|
379
|
-
* Configure
|
|
380
|
-
*
|
|
538
|
+
* Configure agents: write instruction files + MCP configs. With
|
|
539
|
+
* `agentFilter`, the detected agents it names; without, what
|
|
540
|
+
* planAgentSetup configures without asking.
|
|
381
541
|
*/
|
|
382
542
|
export function configureAgents(workspaceRoot, binaryPath, version, agentFilter, mcpServerPath) {
|
|
383
543
|
const results = [];
|
|
384
544
|
const mcpConfigs = [];
|
|
385
545
|
const writtenConfigFiles = new Set();
|
|
386
|
-
let agents
|
|
546
|
+
let agents;
|
|
387
547
|
if (agentFilter && agentFilter.length > 0) {
|
|
388
548
|
const filter = agentFilter.map((a) => a.toLowerCase());
|
|
389
|
-
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));
|
|
390
554
|
}
|
|
391
555
|
for (const detected of agents) {
|
|
392
556
|
const detector = withCursorTarget(detected, workspaceRoot);
|
|
@@ -473,7 +637,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
473
637
|
}
|
|
474
638
|
// Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
|
|
475
639
|
if (detector.agent === "Claude Code") {
|
|
476
|
-
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
|
|
640
|
+
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
|
|
477
641
|
if (wrote)
|
|
478
642
|
mcpConfigs.push("~/.claude.json");
|
|
479
643
|
// Event-driven hint hook: default-on (non-blocking, fail-open — the
|
|
@@ -850,7 +1014,7 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
850
1014
|
removeDeadWindsurfMcpJson(workspaceRoot);
|
|
851
1015
|
}
|
|
852
1016
|
if (detector.agent === "Claude Code") {
|
|
853
|
-
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
|
|
1017
|
+
const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
|
|
854
1018
|
if (wrote)
|
|
855
1019
|
mcpConfigs.push("~/.claude.json");
|
|
856
1020
|
installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
@@ -1226,6 +1390,50 @@ export function isBareInterpreter(entry) {
|
|
|
1226
1390
|
function adoptableEntry(entry) {
|
|
1227
1391
|
return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry);
|
|
1228
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
|
+
}
|
|
1229
1437
|
function vexpEntryStillResolves(entry) {
|
|
1230
1438
|
if (!entry || typeof entry !== "object")
|
|
1231
1439
|
return false;
|
|
@@ -1353,6 +1561,38 @@ const unreachableTargets = [];
|
|
|
1353
1561
|
export function takeUnreachableTargets() {
|
|
1354
1562
|
return unreachableTargets.splice(0, unreachableTargets.length);
|
|
1355
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
|
+
}
|
|
1356
1596
|
/**
|
|
1357
1597
|
* Cline keeps its MCP registry inside the VS Code extension's globalStorage,
|
|
1358
1598
|
* so there is nothing to write until the extension has been installed once.
|
|
@@ -1945,6 +2185,10 @@ function readMcpPort(workspaceRoot) {
|
|
|
1945
2185
|
// tell OUR config apart from a config the user hand-wrote. Must stay in sync
|
|
1946
2186
|
// with packages/vexp-vscode/src/providers/agent-auto-config.ts.
|
|
1947
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'";
|
|
1948
2192
|
/** Quote a value as a TOML literal string (single quotes, no escaping) so
|
|
1949
2193
|
* Windows paths work verbatim. Falls back to a basic escaped string if the
|
|
1950
2194
|
* value contains a single quote. */
|
|
@@ -1982,6 +2226,13 @@ function codexDirectBinary(section) {
|
|
|
1982
2226
|
return undefined;
|
|
1983
2227
|
return cmd;
|
|
1984
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
|
+
}
|
|
1985
2236
|
function extractCodexVexpSection(content) {
|
|
1986
2237
|
const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
|
|
1987
2238
|
return m ? m.join("\n") : "";
|
|
@@ -2002,6 +2253,19 @@ export function codexGlobalSectionIsUserManaged() {
|
|
|
2002
2253
|
}
|
|
2003
2254
|
return isUserManagedCodexSection(extractCodexVexpSection(content));
|
|
2004
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
|
+
}
|
|
2005
2269
|
/** True when the existing [mcp_servers.vexp] section was written by the user
|
|
2006
2270
|
* (not by vexp) and must NOT be overwritten. */
|
|
2007
2271
|
function isUserManagedCodexSection(section) {
|
|
@@ -2018,7 +2282,19 @@ function isUserManagedCodexSection(section) {
|
|
|
2018
2282
|
return false;
|
|
2019
2283
|
}
|
|
2020
2284
|
/** Build the canonical [mcp_servers.vexp] section for the chosen transport.
|
|
2021
|
-
* 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. */
|
|
2022
2298
|
function buildCodexSection(opts) {
|
|
2023
2299
|
if (opts.transport === "direct") {
|
|
2024
2300
|
if (!opts.coreBinaryPath)
|
|
@@ -2026,18 +2302,21 @@ function buildCodexSection(opts) {
|
|
|
2026
2302
|
const lines = [
|
|
2027
2303
|
"",
|
|
2028
2304
|
"[mcp_servers.vexp]",
|
|
2029
|
-
|
|
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)`,
|
|
2030
2308
|
`command = ${tomlString(opts.coreBinaryPath)}`,
|
|
2031
|
-
//
|
|
2032
|
-
// the env pin below, so a stale cwd / stale cached env
|
|
2033
|
-
//
|
|
2034
|
-
|
|
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"]'}`,
|
|
2035
2314
|
];
|
|
2036
|
-
if (opts.
|
|
2037
|
-
lines.push(`cwd = ${tomlString(opts.
|
|
2315
|
+
if (opts.pinTo)
|
|
2316
|
+
lines.push(`cwd = ${tomlString(opts.pinTo)}`);
|
|
2038
2317
|
lines.push("tool_timeout_sec = 120", "", "[mcp_servers.vexp.env]");
|
|
2039
|
-
if (opts.
|
|
2040
|
-
lines.push(`VEXP_WORKSPACE = ${tomlString(opts.
|
|
2318
|
+
if (opts.pinTo)
|
|
2319
|
+
lines.push(`VEXP_WORKSPACE = ${tomlString(opts.pinTo)}`);
|
|
2041
2320
|
if (opts.home)
|
|
2042
2321
|
lines.push(`VEXP_HOME = ${tomlString(opts.home)}`);
|
|
2043
2322
|
lines.push("");
|
|
@@ -2048,7 +2327,7 @@ function buildCodexSection(opts) {
|
|
|
2048
2327
|
const desiredUrl = `http://127.0.0.1:${opts.mcpPort}${urlPath}`;
|
|
2049
2328
|
return `
|
|
2050
2329
|
[mcp_servers.vexp]
|
|
2051
|
-
${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)
|
|
2052
2331
|
url = "${desiredUrl}"
|
|
2053
2332
|
tool_timeout_sec = 120
|
|
2054
2333
|
|
|
@@ -2058,16 +2337,30 @@ Authorization = "Bearer ${opts.token}"
|
|
|
2058
2337
|
}
|
|
2059
2338
|
/**
|
|
2060
2339
|
* Configure MCP in ~/.codex/config.toml (global).
|
|
2061
|
-
*
|
|
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.
|
|
2062
2350
|
*
|
|
2063
2351
|
* Default transport is `direct` (stdio: `vexp-core mcp`), which works with AND
|
|
2064
2352
|
* without VS Code/daemon: `vexp-core mcp` auto-proxies to a running daemon when
|
|
2065
2353
|
* reachable and otherwise runs an embedded in-process index. Override with the
|
|
2066
2354
|
* VEXP_CODEX_TRANSPORT env var ("direct" | "http").
|
|
2067
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
|
+
*
|
|
2068
2361
|
* Never clobbers a config the user wrote by hand.
|
|
2069
2362
|
*/
|
|
2070
|
-
export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot) {
|
|
2363
|
+
export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot, opts = {}) {
|
|
2071
2364
|
const home = os.homedir();
|
|
2072
2365
|
if (!home)
|
|
2073
2366
|
return false;
|
|
@@ -2105,13 +2398,14 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
|
|
|
2105
2398
|
// Step 2b: adopt the binary a previous install already pinned here, when it
|
|
2106
2399
|
// still exists. Our own sections are not "user-managed", so `vexp setup` and
|
|
2107
2400
|
// every IDE activation rewrote this file with their own binary path — and the
|
|
2108
|
-
// file is machine-global
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2111
|
-
// 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
|
|
2112
2404
|
// vexp-vscode/src/providers/agent-auto-config.ts.
|
|
2113
2405
|
const coreBinaryPath = (transport === "direct" ? codexDirectBinary(existingSection) : undefined) ?? ourCoreBinary;
|
|
2114
|
-
|
|
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 });
|
|
2115
2409
|
if (!newSection) {
|
|
2116
2410
|
// direct requested but binary missing → preserve the existing section (only
|
|
2117
2411
|
// persist any legacy cleanup we already did); never emit a url fallback.
|
|
@@ -2423,7 +2717,9 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
|
|
|
2423
2717
|
* Configure vexp MCP in ~/.claude.json (user-scope) using the Rust binary.
|
|
2424
2718
|
* Returns true if config was written/updated.
|
|
2425
2719
|
*/
|
|
2426
|
-
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) {
|
|
2427
2723
|
const home = os.homedir();
|
|
2428
2724
|
if (!home)
|
|
2429
2725
|
return false;
|
|
@@ -2461,8 +2757,19 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
|
|
|
2461
2757
|
// pinning VEXP_WORKSPACE must be rewritten no matter whose it is, because
|
|
2462
2758
|
// this file applies to EVERY project and the pin forces all parallel
|
|
2463
2759
|
// sessions onto one daemon (the migration described above).
|
|
2464
|
-
|
|
2465
|
-
|
|
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
|
+
}
|
|
2466
2773
|
const servers = config.mcpServers ?? {};
|
|
2467
2774
|
servers["vexp"] = {
|
|
2468
2775
|
command: desiredCommand,
|
|
@@ -2975,7 +3282,7 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
2975
3282
|
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
2976
3283
|
const hookPath = path.join(hookDir, "vexp-hint.sh");
|
|
2977
3284
|
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
2978
|
-
const script = vexpHintHookScript(binaryPath);
|
|
3285
|
+
const script = vexpHintHookScript(binaryPath, "claude-code");
|
|
2979
3286
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
2980
3287
|
// v5: the edit-time coupling hook, written beside the prompt-time one -
|
|
2981
3288
|
// but only when interventions are on. It is one of the two mechanisms that
|
|
@@ -3097,7 +3404,7 @@ export function installOpencodeCompressPlugin(workspaceRoot, binaryPath, pluginD
|
|
|
3097
3404
|
}
|
|
3098
3405
|
export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir) {
|
|
3099
3406
|
const pluginPath = path.join(workspaceRoot, pluginDir, "vexp-hint.js");
|
|
3100
|
-
const content = vexpOpencodeHintPlugin(binaryPath);
|
|
3407
|
+
const content = vexpOpencodeHintPlugin(binaryPath, opencodeFamilyAgent(pluginDir));
|
|
3101
3408
|
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
3102
3409
|
const existed = fs.existsSync(pluginPath);
|
|
3103
3410
|
if (existed && fs.readFileSync(pluginPath, "utf-8") === content)
|
|
@@ -3113,25 +3420,26 @@ export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir)
|
|
|
3113
3420
|
* Fail-open: missing bash/binary/daemon => no output => vanilla.
|
|
3114
3421
|
*/
|
|
3115
3422
|
export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
3116
|
-
|
|
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");
|
|
3117
3427
|
const hookPath = path.join(dir, "vexp-hint.sh");
|
|
3118
3428
|
const hooksJsonPath = path.join(dir, "hooks.json");
|
|
3119
|
-
const script = vexpHintHookScript(binaryPath);
|
|
3429
|
+
const script = vexpHintHookScript(binaryPath, "codex");
|
|
3120
3430
|
fs.mkdirSync(dir, { recursive: true });
|
|
3121
3431
|
const existed = fs.existsSync(hookPath);
|
|
3122
3432
|
let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
|
|
3123
3433
|
if (!scriptIdentical) {
|
|
3124
3434
|
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3125
3435
|
}
|
|
3126
|
-
// Windows gets a batch twin.
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3129
|
-
//
|
|
3130
|
-
//
|
|
3131
|
-
//
|
|
3132
|
-
//
|
|
3133
|
-
// write). Older builds ignore the field rather than reject the file —
|
|
3134
|
-
// 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.
|
|
3135
3443
|
const cmdPath = path.join(dir, "vexp-hint.cmd");
|
|
3136
3444
|
let cmdCommand;
|
|
3137
3445
|
if (process.platform === "win32") {
|
|
@@ -3141,10 +3449,10 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3141
3449
|
fs.writeFileSync(cmdPath, cmdScript);
|
|
3142
3450
|
scriptIdentical = false;
|
|
3143
3451
|
}
|
|
3144
|
-
//
|
|
3145
|
-
//
|
|
3146
|
-
//
|
|
3147
|
-
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);
|
|
3148
3456
|
}
|
|
3149
3457
|
let root = {};
|
|
3150
3458
|
if (fs.existsSync(hooksJsonPath)) {
|
|
@@ -3180,9 +3488,8 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3180
3488
|
? codexHooks.UserPromptSubmit
|
|
3181
3489
|
: [];
|
|
3182
3490
|
const existing = [...wrapped, ...legacyTopLevel];
|
|
3183
|
-
const filtered = existing.filter((h) => !isVexpHintHookEntry(h));
|
|
3184
3491
|
// Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution.
|
|
3185
|
-
|
|
3492
|
+
const ours = {
|
|
3186
3493
|
hooks: [
|
|
3187
3494
|
{
|
|
3188
3495
|
type: "command",
|
|
@@ -3191,11 +3498,16 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3191
3498
|
timeout: 5,
|
|
3192
3499
|
},
|
|
3193
3500
|
],
|
|
3194
|
-
}
|
|
3501
|
+
};
|
|
3502
|
+
const merged = placeCodexHintEntry(existing, ours);
|
|
3195
3503
|
const before = JSON.stringify(root);
|
|
3196
|
-
codexHooks.UserPromptSubmit =
|
|
3504
|
+
codexHooks.UserPromptSubmit = merged;
|
|
3197
3505
|
root.hooks = codexHooks;
|
|
3198
3506
|
const identical = before === JSON.stringify(root);
|
|
3507
|
+
if (codexHookDefinitionChanged(wrapped, merged, ours)) {
|
|
3508
|
+
if (!codexHookApprovals.includes(workspaceRoot))
|
|
3509
|
+
codexHookApprovals.push(workspaceRoot);
|
|
3510
|
+
}
|
|
3199
3511
|
if (scriptIdentical && identical)
|
|
3200
3512
|
return null;
|
|
3201
3513
|
if (!identical) {
|
|
@@ -3203,6 +3515,36 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3203
3515
|
}
|
|
3204
3516
|
return existed ? "updated" : "created";
|
|
3205
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
|
+
}
|
|
3206
3548
|
// ---------------------------------------------------------------------------
|
|
3207
3549
|
// opencode plugin - blocks grep/glob (and shelled-out search) when the daemon
|
|
3208
3550
|
// is healthy. opencode has no PreToolUse hook, but auto-loads plugins from
|