vexp-cli 3.3.8 → 3.3.9

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 CHANGED
@@ -69,7 +69,7 @@ vexp runs one small daemon per project, started by your agent's first call. By d
69
69
  idle_timeout_minutes = 30
70
70
  ```
71
71
 
72
- The next tool call or prompt starts the daemon again. `VEXP_IDLE_TIMEOUT_MINUTES` sets it for one daemon. A daemon the VS Code extension started for a window stops when that window closes. `vexp daemons` lists what is running, with each daemon's memory, and `vexp doctor` shows whether the setting is on.
72
+ The next tool call or prompt starts the daemon again. `VEXP_IDLE_TIMEOUT_MINUTES` sets it for one daemon. A daemon the VS Code extension started for a window stops when that window closes. Unset, a daemon an agent's session started (any agent) stops after 30 minutes without use and frees its slot of the plan's daemon cap; `idle_timeout_minutes = 0` turns that off, and `VEXP_NO_AUTOSTART=1` keeps sessions from starting one. `vexp daemons` lists what is running, with each daemon's memory, and `vexp doctor` shows whether the setting is on.
73
73
 
74
74
  ## Supported agents
75
75
 
@@ -2452,6 +2452,12 @@ function isUserManagedCodexSection(section) {
2452
2452
  * them. `pinTo` is `vexp use --pin`, on purpose. The http section keeps its
2453
2453
  * project: the URL routes to one workspace's daemon.
2454
2454
  *
2455
+ * `vexp-core mcp` starts the checkout's shared daemon when none runs, as the
2456
+ * Node server does. Unused for 30 minutes it stops and frees its slot of the
2457
+ * plan's daemon cap (vexp-core idle.rs; `idle_timeout_minutes` overrides it).
2458
+ * VEXP_NO_AUTOSTART=1, or a cap already full, leaves the session serving
2459
+ * in-process and taking no slot.
2460
+ *
2455
2461
  * BYTE-IDENTICAL with the extension's buildCodexSection, marker lines
2456
2462
  * included, or the two rewrite this machine-wide file in turn. */
2457
2463
  function buildCodexSection(opts) {
@@ -2508,10 +2514,21 @@ Authorization = "Bearer ${opts.token}"
2508
2514
  * buildCodexSection), so one user entry serves every project.
2509
2515
  *
2510
2516
  * Default transport is `direct` (stdio: `vexp-core mcp`), which works with AND
2511
- * without VS Code/daemon: `vexp-core mcp` auto-proxies to a running daemon when
2512
- * reachable and otherwise runs an embedded in-process index. Override with the
2517
+ * without VS Code: `vexp-core mcp` proxies to the project's daemon, starting it
2518
+ * when none runs as the Node MCP server does, so every session on a checkout
2519
+ * shares one daemon. It runs an embedded in-process index where it may not
2520
+ * start one (VEXP_NO_AUTOSTART=1, the daily limit), where the plan's daemon cap
2521
+ * refuses it, or where it does not answer in time. Override with the
2513
2522
  * VEXP_CODEX_TRANSPORT env var ("direct" | "http").
2514
2523
  *
2524
+ * Every agent configured with `vexp-core mcp` directly runs that server and
2525
+ * starts the daemon the same way: Cline, Continue, ZCode and the entries
2526
+ * written where no Node runtime is found. In an editor with the vexp
2527
+ * extension, such a server can start the daemon before the extension does.
2528
+ * The extension then adopts it as a daemon it does not own, and the rule
2529
+ * that ends a window's daemon with its window does not apply to it: it runs
2530
+ * until `vexp stop`, the idle stop (`idle_timeout_minutes`) or a reboot.
2531
+ *
2515
2532
  * `pin` is `vexp use <project> --pin`: the one way to name a project in the entry,
2516
2533
  * kept by every later setup until `vexp use` changes it. `unpin` is
2517
2534
  * `vexp use --unpin`. [mcp_servers.vexp.tools.*] tables (Codex's per-tool
package/dist/cli.js CHANGED
@@ -46,7 +46,8 @@ Daemons:
46
46
  [daemon]
47
47
  idle_timeout_minutes = 30
48
48
 
49
- VEXP_IDLE_TIMEOUT_MINUTES sets it for one daemon. A daemon an editor window started stops
49
+ VEXP_IDLE_TIMEOUT_MINUTES sets it for one daemon. Unset, a daemon an agent's session started
50
+ stops after 30 minutes without use (0 turns that off). A daemon an editor window started stops
50
51
  with that window instead. vexp daemons lists them; vexp doctor says whether the setting is on.
51
52
  `;
52
53
  program.addHelpText("after", IDLE_STOP_HELP);
@@ -187,8 +187,13 @@ export function parseEngineVersion(stdout) {
187
187
  return last ? last : undefined;
188
188
  }
189
189
  /**
190
- * The folder `daemon-cmd` works on when run in `dir`: the nearest one with a
191
- * `.git` at or above it, else `dir` itself (find_repo_root in vexp-core).
190
+ * The folder `daemon-cmd` works on when run in `dir` (daemon_cmd_root in
191
+ * vexp-core): the nearest one with a `.git` at or above it, unless that one
192
+ * was never set up for vexp and a folder between it and `dir` was, which
193
+ * is then the nearest such folder; with no `.git` above, `dir` itself. Set
194
+ * up is the engine's is_workspace_marker: one of its consent files in the
195
+ * folder's .vexp/, and neither the home directory nor a filesystem root
196
+ * (forbidden_workspace_reason refuses both).
192
197
  */
193
198
  export function daemonCmdRoot(dir) {
194
199
  let start = path.resolve(dir);
@@ -198,10 +203,16 @@ export function daemonCmdRoot(dir) {
198
203
  catch {
199
204
  /* not there: the spelling given */
200
205
  }
206
+ const setUp = (d) => (process.env.VEXP_ALLOW_HOME_WORKSPACE === "1" ||
207
+ (upgradeAttemptKey(d) !== upgradeAttemptKey(vexpHome()) && path.dirname(d) !== d)) &&
208
+ ["manifest.json", "vexp.toml", "workspace.json", "index.db"].some((f) => fs.existsSync(path.join(d, ".vexp", f)));
209
+ const below = [];
201
210
  let current = start;
202
211
  for (;;) {
203
- if (fs.existsSync(path.join(current, ".git")))
204
- return current;
212
+ if (fs.existsSync(path.join(current, ".git"))) {
213
+ return setUp(current) ? current : (below.find(setUp) ?? current);
214
+ }
215
+ below.push(current);
205
216
  const parent = path.dirname(current);
206
217
  if (parent === current)
207
218
  return start;
@@ -215,8 +226,10 @@ export function daemonCmdRoot(dir) {
215
226
  *
216
227
  * - the stop half reads `<dir>/.vexp/daemon.pid`; with none there it stops
217
228
  * nothing, and the start half then races the daemon that is still up;
218
- * - `daemon-cmd` works on the git root at or above its cwd, so for a
219
- * workspace below one it would start a daemon on the folder above;
229
+ * - `daemon-cmd` works on the git root at or above its cwd unless a folder
230
+ * below an unset-up root was set up on its own (daemonCmdRoot), so for
231
+ * any other workspace below one it would start a daemon on the folder
232
+ * above;
220
233
  * - the engine refuses to start on the home directory, so a daemon an older
221
234
  * build left there would be stopped and not come back.
222
235
  */
package/dist/doctor.js CHANGED
@@ -3492,7 +3492,7 @@ async function doctorChecks(onWorkspace) {
3492
3492
  // the cause can change between starts.
3493
3493
  const why = typeof st.llm_inactive_reason === "string" ? st.llm_inactive_reason : undefined;
3494
3494
  const restartIsPointless = why !== undefined &&
3495
- /vanilla build|model load failed|incomplete|did not survive|worker did not start/i.test(why);
3495
+ /vanilla build|model load failed|incomplete|did not survive|worker did not start|needs a GPU/i.test(why);
3496
3496
  // A daemon still running its startup sync has not REACHED the model
3497
3497
  // yet. Restarting there is worse than useless: it starts the sync
3498
3498
  // over, and the user never arrives.
package/dist/forget.js CHANGED
@@ -32,6 +32,7 @@ export const INDEX_ARTIFACTS = [
32
32
  "manifest.json",
33
33
  "coverage.json",
34
34
  "daemon.pid",
35
+ "daemon.lock",
35
36
  "daemon.pipe",
36
37
  "daemon.sock",
37
38
  "healthy",
package/dist/serve.js CHANGED
@@ -125,7 +125,7 @@ async function resurrectDaemon(workspaceRoot, socketPath) {
125
125
  child = spawn(binary, ["daemon", "--workspace", workspaceRoot, "--socket", socketPath], {
126
126
  detached: true,
127
127
  stdio: ["ignore", log, log],
128
- env: binaryEnv(binary),
128
+ env: serveDaemonEnv(binary, workspaceRoot),
129
129
  });
130
130
  }
131
131
  finally {
@@ -143,6 +143,20 @@ async function resurrectDaemon(workspaceRoot, socketPath) {
143
143
  return false;
144
144
  }
145
145
  }
146
+ /** The environment of a daemon brought back: one that was started for an
147
+ * agent's session stays one, with its idle stop. Without this a crash made it
148
+ * permanent, holding its slot of the plan's daemon cap. The role is in the
149
+ * lock file it left (vexp-core index_lock.rs AGENT_DAEMON_ROLE). */
150
+ export function serveDaemonEnv(binary, workspaceRoot) {
151
+ const env = binaryEnv(binary);
152
+ try {
153
+ const lock = JSON.parse(fs.readFileSync(path.join(workspaceRoot, ".vexp", "daemon.lock"), "utf-8"));
154
+ if (lock?.role === "agent daemon")
155
+ env.VEXP_DAEMON_OWNER = "agent";
156
+ }
157
+ catch { /* no lock, or held and unreadable: no owner */ }
158
+ return env;
159
+ }
146
160
  /** True when `sock` is the canonical socket for `ws` itself (unix path or the
147
161
  * win32 named pipe derived from `ws`). A registry row that FAILS this is a
148
162
  * ROUTING entry: a connected secondary repo mapped to its parent workspace's
@@ -203,8 +217,40 @@ export function resurrectionVeto(ws) {
203
217
  return "no manifest";
204
218
  if (isLinkedWorktree(ws))
205
219
  return "linked git worktree — on-demand only, never resurrected";
220
+ if (indexesNothingUnsetUp(ws)) {
221
+ return "its manifest lists no file and it was never set up — the daemon refuses it unless it now holds sources, so on-demand only";
222
+ }
206
223
  return null;
207
224
  }
225
+ /** The files a person writes to set a folder up on purpose
226
+ * (vexp-core utils::SET_UP_ON_PURPOSE, held equal by a test there). */
227
+ export const SET_UP_ON_PURPOSE = ["vexp.toml", "workspace.json"];
228
+ /** A workspace whose manifest lists no file and that nobody set up on
229
+ * purpose: what older versions of `vexp index` left in folders with no
230
+ * source. The daemon refuses such a folder unless a walk finds a source
231
+ * file in it (vexp-core utils::nothing_to_serve); a resurrection would be
232
+ * a refused spawn a minute. No walk here: a row whose folder gained
233
+ * sources is started again by the next launcher that needs it, and its
234
+ * daemon then writes a manifest that lists them. A manifest that cannot
235
+ * be read, or has no file_hashes at all (every manifest vexp writes has
236
+ * one), is not "no file". */
237
+ function indexesNothingUnsetUp(ws) {
238
+ if (SET_UP_ON_PURPOSE.some((f) => fs.existsSync(path.join(ws, ".vexp", f))))
239
+ return false;
240
+ try {
241
+ const manifest = path.join(ws, ".vexp", "manifest.json");
242
+ // One that lists nothing is a few hundred bytes; a real one is not
243
+ // worth parsing every minute to learn that it lists files.
244
+ if (fs.statSync(manifest).size > 64 * 1024)
245
+ return false;
246
+ const parsed = JSON.parse(fs.readFileSync(manifest, "utf-8"));
247
+ const files = parsed?.file_hashes;
248
+ return typeof files === "object" && files !== null && Object.keys(files).length === 0;
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ }
208
254
  export function resurrectionPlan(reg) {
209
255
  const bySock = new Map();
210
256
  for (const [ws, sock] of Object.entries(reg)) {