vexp-cli 2.3.0 → 2.4.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/agent-config.js +257 -89
- package/dist/doctor.js +233 -25
- package/dist/hook-template.js +63 -0
- package/dist/serve.js +10 -0
- package/mcp/mcp-server.cjs +43 -43
- package/package.json +6 -6
package/dist/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as net from "net";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { socketPathFor } from "./socket-path.js";
|
|
7
8
|
// `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
|
|
@@ -150,15 +151,50 @@ export async function runDoctor() {
|
|
|
150
151
|
if (st.llm_configured_but_inactive === true) {
|
|
151
152
|
line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed. Run 'vexp daemon-cmd restart' to load the model.`);
|
|
152
153
|
}
|
|
154
|
+
// 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
|
|
155
|
+
// serving the OLD feature set and its gaps read as product bugs
|
|
156
|
+
// (field case: pre-ledger daemon after the 2.4.0 install). The VS Code
|
|
157
|
+
// extension restarts automatically; CLI users get told explicitly.
|
|
158
|
+
try {
|
|
159
|
+
const { getBinaryPath } = await import("./binary.js");
|
|
160
|
+
const { execFileSync } = await import("node:child_process");
|
|
161
|
+
const out = execFileSync(getBinaryPath(), ["--version"], { timeout: 5000, encoding: "utf8" });
|
|
162
|
+
const bundled = out.trim().split(/\s+/).pop();
|
|
163
|
+
const running = st.daemon_version;
|
|
164
|
+
if (bundled && running && bundled !== running) {
|
|
165
|
+
line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version. Run 'vexp daemon-cmd restart' to upgrade it now.`);
|
|
166
|
+
}
|
|
167
|
+
else if (st.binary_stale === true) {
|
|
168
|
+
line(WARN, `daemon is running a deleted executable (upgraded on disk) — run 'vexp daemon-cmd restart' to load the new build.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* best-effort */ }
|
|
153
172
|
// 2.3 C1 — which agent sessions actually used vexp (a session with zero
|
|
154
173
|
// calls never shows up here; that absence is the diagnostic).
|
|
155
174
|
const sessions = Array.isArray(st.sessions) ? st.sessions : [];
|
|
175
|
+
// 2.3.3 Savings Ledger — activity counts even when no tool is ever
|
|
176
|
+
// called: silence on oriented prompts is vexp WORKING, and the old
|
|
177
|
+
// "never used" warning on quiet-but-active daemons generated real
|
|
178
|
+
// support tickets ("is it working? it never got called").
|
|
179
|
+
const ledger = (st.ledger ?? {});
|
|
180
|
+
const analyzed = Number(ledger.prompts_analyzed) || 0;
|
|
181
|
+
if (analyzed > 0) {
|
|
182
|
+
line(OK, `savings ledger (7d): ${analyzed} prompt(s) analyzed — ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served. Details: vexp savings`);
|
|
183
|
+
}
|
|
156
184
|
if (sessions.length > 0) {
|
|
157
185
|
const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
|
|
158
186
|
line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
|
|
159
187
|
}
|
|
188
|
+
else if (analyzed > 0) {
|
|
189
|
+
line(OK, `no tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)`);
|
|
190
|
+
}
|
|
160
191
|
else if (Number(st.daemon_uptime_s) > 600) {
|
|
161
|
-
|
|
192
|
+
// Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
|
|
193
|
+
// the daemon was unreachable serves from its own in-process index and
|
|
194
|
+
// never attaches to the daemon later — its calls are real but
|
|
195
|
+
// invisible to these counters. Don't tell that user "the agent is not
|
|
196
|
+
// using vexp"; tell them how to converge on the daemon.
|
|
197
|
+
line(WARN, `no agent session has called vexp through this daemon since it started — either no agent is using vexp here (check its MCP config), or the agent's vexp MCP server started BEFORE the daemon and is running embedded (in-process). If calls do succeed in the agent, restart the agent (with the daemon already up) so its MCP server attaches to the daemon.`);
|
|
162
198
|
}
|
|
163
199
|
else {
|
|
164
200
|
line(OK, `no session calls yet (daemon just started)`);
|
|
@@ -201,6 +237,12 @@ export async function runDoctor() {
|
|
|
201
237
|
// 3) License (fresh.jwt rolling token vs license.jwt).
|
|
202
238
|
console.log(chalk.bold("\nLicense tokens (~/.vexp)"));
|
|
203
239
|
const now = Math.floor(Date.now() / 1000);
|
|
240
|
+
// Either valid token keeps the plan ACTIVE. An expired license.jwt next to
|
|
241
|
+
// a valid fresh.jwt is normal steady-state (the long token re-rolls on the
|
|
242
|
+
// next online validation) — labeling it a bare WARN read as "my license
|
|
243
|
+
// expired" and generated tickets from perfectly licensed users.
|
|
244
|
+
const freshExp = jwtExp(path.join(home, ".vexp", "fresh.jwt"));
|
|
245
|
+
const freshValid = freshExp != null && freshExp > now;
|
|
204
246
|
for (const name of ["fresh.jwt", "license.jwt"]) {
|
|
205
247
|
const p = path.join(home, ".vexp", name);
|
|
206
248
|
if (!fs.existsSync(p)) {
|
|
@@ -214,38 +256,58 @@ export async function runDoctor() {
|
|
|
214
256
|
}
|
|
215
257
|
const days = Math.round((exp - now) / 86400);
|
|
216
258
|
if (exp < now) {
|
|
217
|
-
|
|
259
|
+
if (name === "fresh.jwt") {
|
|
260
|
+
line(OK, `${name}: expired ${-days}d ago (benign — rolling token, falls back to license.jwt)`);
|
|
261
|
+
}
|
|
262
|
+
else if (freshValid) {
|
|
263
|
+
line(OK, `${name}: long token expired ${-days}d ago — plan still ACTIVE via fresh.jwt; it renews automatically on the next online validation`);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
line(WARN, `${name}: expired ${-days}d ago and no valid fresh token — plan features may be limited; go online or re-activate the license`);
|
|
267
|
+
}
|
|
218
268
|
}
|
|
219
269
|
else {
|
|
220
270
|
line(OK, `${name}: valid, ~${days}d remaining`);
|
|
221
271
|
}
|
|
222
272
|
}
|
|
223
|
-
// 4) Codex MCP transport stanza.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
273
|
+
// 4) Codex MCP transport stanza — global AND project-level. Codex supports
|
|
274
|
+
// per-project `.codex/config.toml`; checking only the global file made
|
|
275
|
+
// doctor report "[OK] no stanza" to users whose (working) config lives in
|
|
276
|
+
// the workspace.
|
|
277
|
+
console.log(chalk.bold("\nCodex (config.toml)"));
|
|
278
|
+
const codexConfigs = [
|
|
279
|
+
{ label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
|
|
280
|
+
{ label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
|
|
281
|
+
];
|
|
282
|
+
let codexStanzaSeen = false;
|
|
283
|
+
for (const { label, file } of codexConfigs) {
|
|
284
|
+
if (!fs.existsSync(file)) {
|
|
285
|
+
line(OK, `${label}: absent`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const toml = fs.readFileSync(file, "utf-8");
|
|
231
289
|
const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
|
|
232
290
|
const section = m ? m[0] : "";
|
|
233
|
-
if (!section)
|
|
234
|
-
line(OK,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
line(WARN, "stanza present but neither url nor command found");
|
|
291
|
+
if (!section) {
|
|
292
|
+
line(OK, `${label}: no [mcp_servers.vexp] stanza`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
codexStanzaSeen = true;
|
|
296
|
+
const hasUrl = /^\s*url\s*=/m.test(section);
|
|
297
|
+
const hasCmd = /^\s*command\s*=/m.test(section);
|
|
298
|
+
if (hasUrl && hasCmd)
|
|
299
|
+
line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.`);
|
|
300
|
+
else if (hasUrl)
|
|
301
|
+
line(OK, `${label}: transport http (url)`);
|
|
302
|
+
else if (hasCmd) {
|
|
303
|
+
const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
|
|
304
|
+
line(OK, `${label}: transport stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
|
|
248
305
|
}
|
|
306
|
+
else
|
|
307
|
+
line(WARN, `${label}: stanza present but neither url nor command found`);
|
|
308
|
+
}
|
|
309
|
+
if (!codexStanzaSeen) {
|
|
310
|
+
console.log(chalk.dim(" → no vexp stanza in either file (Codex not configured for vexp)"));
|
|
249
311
|
}
|
|
250
312
|
// 5) Claude Code entry (should be UNPINNED after the multi-session fix).
|
|
251
313
|
console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
|
|
@@ -263,6 +325,152 @@ export async function runDoctor() {
|
|
|
263
325
|
catch {
|
|
264
326
|
line(OK, "no ~/.claude.json");
|
|
265
327
|
}
|
|
328
|
+
// 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
|
|
329
|
+
// just check presence. A shell-form command that word-splits on a project
|
|
330
|
+
// path containing a space fails non-blocking on every call: the guard never
|
|
331
|
+
// denies anything while the config "looks correct" and presence-only checks
|
|
332
|
+
// report healthy (Nathan, 2026-07).
|
|
333
|
+
console.log(chalk.bold("\nClaude Code guard hook (.claude/settings.json)"));
|
|
334
|
+
{
|
|
335
|
+
const sPath = path.join(ws.root, ".claude", "settings.json");
|
|
336
|
+
let guardHooks = [];
|
|
337
|
+
let settingsReadable = false;
|
|
338
|
+
try {
|
|
339
|
+
const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
|
|
340
|
+
settingsReadable = true;
|
|
341
|
+
const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
342
|
+
for (const m of pre) {
|
|
343
|
+
const hks = Array.isArray(m?.hooks) ? m.hooks : [];
|
|
344
|
+
for (const h of hks) {
|
|
345
|
+
if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
|
|
346
|
+
guardHooks.push(h);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch { /* absent or unparseable */ }
|
|
351
|
+
if (!settingsReadable) {
|
|
352
|
+
line(OK, "no .claude/settings.json (guard not installed)");
|
|
353
|
+
}
|
|
354
|
+
else if (guardHooks.length === 0) {
|
|
355
|
+
line(OK, "no vexp guard configured (2.3 default — enable with 'vexp setup --guard-strict')");
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
for (const h of guardHooks) {
|
|
359
|
+
const cmd = h.command;
|
|
360
|
+
const execForm = Array.isArray(h.args);
|
|
361
|
+
const timeoutS = typeof h.timeout === "number" ? h.timeout : 600;
|
|
362
|
+
if (!execForm && /\$\{?CLAUDE_PROJECT_DIR\}?\//.test(cmd) && !cmd.includes('"')) {
|
|
363
|
+
line(ws.root.includes(" ") ? BAD : WARN, `shell-form hook command with unquoted $CLAUDE_PROJECT_DIR — word-splits on paths with spaces${ws.root.includes(" ") ? ` and THIS project path has one: the guard never runs` : ""}. Re-run 'vexp setup --guard-strict' to rewrite with a quoted path.`);
|
|
364
|
+
}
|
|
365
|
+
if (execForm && process.platform === "win32") {
|
|
366
|
+
// Exec form spawns the .sh directly, which Windows cannot do at
|
|
367
|
+
// all — the entry LOOKS installed and enforces nothing. This was
|
|
368
|
+
// invisible for days because doctor used to skip the live check
|
|
369
|
+
// on Windows entirely.
|
|
370
|
+
line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (bash-prefixed shell form).`);
|
|
371
|
+
}
|
|
372
|
+
if (timeoutS > 600) {
|
|
373
|
+
line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
|
|
374
|
+
}
|
|
375
|
+
// Run it exactly as Claude Code would: exec form = direct spawn with
|
|
376
|
+
// the placeholder substituted by the host; shell form = a shell with
|
|
377
|
+
// CLAUDE_PROJECT_DIR in the environment. On Windows Claude Code runs
|
|
378
|
+
// shell-form hooks through Git Bash, so probe via bash there too —
|
|
379
|
+
// if bash is missing, that IS the finding (Claude Code itself
|
|
380
|
+
// requires Git Bash on Windows).
|
|
381
|
+
const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
|
|
382
|
+
const shell = process.platform === "win32" ? "bash" : "sh";
|
|
383
|
+
const r = execForm
|
|
384
|
+
? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
|
|
385
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
386
|
+
timeout: 5000,
|
|
387
|
+
encoding: "utf-8",
|
|
388
|
+
})
|
|
389
|
+
: spawnSync(shell, ["-c", cmd], {
|
|
390
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
391
|
+
timeout: 5000,
|
|
392
|
+
encoding: "utf-8",
|
|
393
|
+
});
|
|
394
|
+
if (r.error) {
|
|
395
|
+
line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${substituted}' — the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
|
|
396
|
+
}
|
|
397
|
+
else if (r.status !== 0) {
|
|
398
|
+
line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Claude Code treats this as a non-blocking failure, so searches proceed unguarded.`);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const decision = /"permissionDecision"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
|
|
402
|
+
if (decision)
|
|
403
|
+
line(OK, `guard hook runs (live decision here: ${decision})`);
|
|
404
|
+
else
|
|
405
|
+
line(WARN, `guard hook ran (exit 0) but produced no permissionDecision output — check ${path.join(".claude", "hooks", "vexp-guard.sh")}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
|
|
411
|
+
// hooks fail OPEN too (`failClosed` defaults to false), so a guard that
|
|
412
|
+
// cannot spawn silently enforces nothing there as well. The guard's stdin
|
|
413
|
+
// protocol: JSON {tool_name, tool_input, workspace_roots, cwd}; a healthy
|
|
414
|
+
// run answers {"permission":"allow"} with exit 0 OR a deny verdict with
|
|
415
|
+
// exit 2 — BOTH mean "the hook works", anything else is a failure.
|
|
416
|
+
console.log(chalk.bold("\nCursor guard hook (.cursor/hooks.json)"));
|
|
417
|
+
{
|
|
418
|
+
const cursorCfgPath = path.join(ws.root, ".cursor", "hooks.json");
|
|
419
|
+
let cursorCmds = [];
|
|
420
|
+
let cursorReadable = false;
|
|
421
|
+
try {
|
|
422
|
+
const cfg = JSON.parse(fs.readFileSync(cursorCfgPath, "utf-8"));
|
|
423
|
+
cursorReadable = true;
|
|
424
|
+
const pre = Array.isArray(cfg?.hooks?.preToolUse) ? cfg.hooks.preToolUse : [];
|
|
425
|
+
for (const h of pre) {
|
|
426
|
+
if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
|
|
427
|
+
cursorCmds.push(h.command);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch { /* absent or unparseable */ }
|
|
431
|
+
if (!cursorReadable) {
|
|
432
|
+
line(OK, "no .cursor/hooks.json (guard not installed)");
|
|
433
|
+
}
|
|
434
|
+
else if (cursorCmds.length === 0) {
|
|
435
|
+
line(OK, "no vexp guard configured (enable with 'vexp setup --guard-strict')");
|
|
436
|
+
}
|
|
437
|
+
else if (process.platform === "win32") {
|
|
438
|
+
line(OK, `guard configured (${cursorCmds.length} entry) — live execution check skipped on Windows`);
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
// A benign Grep probe: with a healthy daemon the guard denies (exit 2),
|
|
442
|
+
// without one it allows (exit 0) — either proves the hook executes.
|
|
443
|
+
const probe = JSON.stringify({
|
|
444
|
+
tool_name: "Grep",
|
|
445
|
+
tool_input: {},
|
|
446
|
+
workspace_roots: [ws.root],
|
|
447
|
+
cwd: ws.root,
|
|
448
|
+
});
|
|
449
|
+
for (const cmd of cursorCmds) {
|
|
450
|
+
// Cursor runs project hooks from the project root; emulate that.
|
|
451
|
+
const r = spawnSync("sh", ["-c", cmd], {
|
|
452
|
+
cwd: ws.root,
|
|
453
|
+
input: probe,
|
|
454
|
+
timeout: 5000,
|
|
455
|
+
encoding: "utf-8",
|
|
456
|
+
});
|
|
457
|
+
if (r.error) {
|
|
458
|
+
line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${cmd}' — Cursor hooks fail open, so the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const decision = /"permission"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
|
|
462
|
+
if ((r.status === 0 || r.status === 2) && decision) {
|
|
463
|
+
line(OK, `guard hook runs (live decision here: ${decision})`);
|
|
464
|
+
}
|
|
465
|
+
else if (r.status !== 0 && r.status !== 2) {
|
|
466
|
+
line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Cursor treats hook failures as allow, so searches proceed unguarded.`);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
line(WARN, `guard hook ran (exit ${r.status}) but produced no permission verdict — check ${path.join(".cursor", "hooks", "vexp-guard.js")}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
266
474
|
// 6) HTTP MCP supervisor.
|
|
267
475
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
268
476
|
try {
|
package/dist/hook-template.js
CHANGED
|
@@ -374,3 +374,66 @@ process.stdin.on("end", () => {
|
|
|
374
374
|
try { main(input); } catch (e) { allow(); } // any surprise -> fail open
|
|
375
375
|
});
|
|
376
376
|
`;
|
|
377
|
+
/**
|
|
378
|
+
* UserPromptSubmit hint hook (2.3.3 event-driven mandate). The resident
|
|
379
|
+
* CLAUDE.md strategy text is gone; instead, when a prompt names no known
|
|
380
|
+
* symbol or file of the indexed workspace, the daemon returns a one-line
|
|
381
|
+
* hint suggesting run_pipeline. All logic (stdin parsing, classification,
|
|
382
|
+
* hook-JSON output) lives in the Rust binary so the script is OS-neutral;
|
|
383
|
+
* Claude Code runs shell-form hooks through Git Bash on Windows.
|
|
384
|
+
* FAIL-OPEN CONTRACT: every failure path (binary missing, daemon down,
|
|
385
|
+
* timeout) exits 0 with no output = vanilla behavior, never a broken
|
|
386
|
+
* prompt. Keep in lockstep with the VS Code extension copy.
|
|
387
|
+
* The __VEXP_BIN__ placeholder is baked at install time.
|
|
388
|
+
*/
|
|
389
|
+
export const VEXP_HINT_HOOK = `#!/bin/bash
|
|
390
|
+
# vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.
|
|
391
|
+
VEXP_BIN="__VEXP_BIN__"
|
|
392
|
+
[ -x "$VEXP_BIN" ] || exit 0
|
|
393
|
+
"$VEXP_BIN" prompt-hint 2>/dev/null
|
|
394
|
+
exit 0
|
|
395
|
+
`;
|
|
396
|
+
/** Bake the binary path into the hint hook script. */
|
|
397
|
+
export function vexpHintHookScript(binaryPath) {
|
|
398
|
+
return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* opencode/Kilo per-prompt hint plugin (2.4.0). The plugin API's
|
|
402
|
+
* `chat.message` hook sees the user message before the LLM call and can
|
|
403
|
+
* append parts — the opencode-family equivalent of UserPromptSubmit +
|
|
404
|
+
* additionalContext. All classification lives in the Rust binary
|
|
405
|
+
* (prompt-hint, Claude-shaped JSON envelope; we extract additionalContext
|
|
406
|
+
* here). FAIL-OPEN: any error/timeout/missing binary => no parts appended.
|
|
407
|
+
* __VEXP_BIN__ is baked at install time. Keep in lockstep with the VS Code
|
|
408
|
+
* extension copy.
|
|
409
|
+
*/
|
|
410
|
+
export const VEXP_OPENCODE_HINT = `// vexp-hint: per-prompt orientation (fail-open). Managed by vexp.
|
|
411
|
+
const VEXP_BIN = "__VEXP_BIN__";
|
|
412
|
+
export const VexpHint = async ({ directory }) => {
|
|
413
|
+
return {
|
|
414
|
+
"chat.message": async (_input, output) => {
|
|
415
|
+
try {
|
|
416
|
+
const { execFileSync } = await import("node:child_process");
|
|
417
|
+
const text = (output.parts || [])
|
|
418
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
419
|
+
.map((p) => p.text)
|
|
420
|
+
.join("\\n");
|
|
421
|
+
if (!text || text.length < 40) return;
|
|
422
|
+
const out = execFileSync(VEXP_BIN, ["prompt-hint"], {
|
|
423
|
+
input: JSON.stringify({ prompt: text }),
|
|
424
|
+
timeout: 4000,
|
|
425
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: directory },
|
|
426
|
+
encoding: "utf8",
|
|
427
|
+
});
|
|
428
|
+
if (!out || !out.trim()) return;
|
|
429
|
+
const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
|
|
430
|
+
if (hint) output.parts.push({ type: "text", text: hint });
|
|
431
|
+
} catch (e) { /* fail open */ }
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
`;
|
|
436
|
+
/** Bake the binary path into the opencode hint plugin. */
|
|
437
|
+
export function vexpOpencodeHintPlugin(binaryPath) {
|
|
438
|
+
return VEXP_OPENCODE_HINT.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
|
|
439
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -140,7 +140,17 @@ async function resurrectAll() {
|
|
|
140
140
|
// Prune rows whose workspace vanished, but keep routing rows alive in the
|
|
141
141
|
// registry — they are data, not spawn instructions.
|
|
142
142
|
const pruned = {};
|
|
143
|
+
// Windows: dedupe case-variant keys for the same workspace (C:\ vs c:\)
|
|
144
|
+
// left behind by pre-2.3.3 daemons — they map to the same pipe and every
|
|
145
|
+
// rewrite here would otherwise immortalize the phantom twin.
|
|
146
|
+
const seenKeys = new Set();
|
|
143
147
|
for (const [ws, sock] of Object.entries(reg)) {
|
|
148
|
+
const canonical = process.platform === "win32" ? ws.toLowerCase() : ws;
|
|
149
|
+
if (seenKeys.has(canonical)) {
|
|
150
|
+
appendLog(`registry prune: ${ws} (case-variant duplicate)`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
seenKeys.add(canonical);
|
|
144
154
|
const manifest = path.join(ws, ".vexp", "manifest.json");
|
|
145
155
|
if (!fs.existsSync(manifest)) {
|
|
146
156
|
appendLog(`registry prune: ${ws} (no manifest)`);
|