vexp-cli 3.3.8 → 3.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/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",
@@ -0,0 +1,136 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { isStateHome } from "./state-home.js";
4
+ // Linked git worktrees, as the CLI sees them.
5
+ //
6
+ // Agent answers (agent-consent.ts) and the setup writers need to know when a
7
+ // folder is a linked worktree of another checkout: the answers given in the
8
+ // main checkout apply to its worktrees, and the files committed there are
9
+ // checked out in every worktree, where a setup must not repin them (field
10
+ // report, 2026-11-07). The rule is the one vexp-core (utils.rs
11
+ // git_project_root / worktree_consent_source), vexp-mcp (daemon-client.ts)
12
+ // and the extension (vexp-vscode src/git-worktree.ts) apply:
13
+ // test/git-worktree.lockstep.test.ts runs the same fixtures through all
14
+ // three ports.
15
+ //
16
+ // Detection is by the content of the worktree's .git FILE, never by a path
17
+ // convention: harnesses put worktrees anywhere (.claude/worktrees/,
18
+ // .worktrees/, a sibling folder).
19
+ /**
20
+ * The main checkout of a linked git worktree, or null when `dir` is not
21
+ * one. A linked worktree has a `.git` FILE whose `gitdir:` line points into
22
+ * `<main>/.git/worktrees/<name>`; git 2.48's `worktree.useRelativePaths`
23
+ * writes that path relative to the worktree. A submodule's gitdir points at
24
+ * `<parent>/.git/modules/<name>`, or, inside a linked worktree, at
25
+ * `<main>/.git/worktrees/<wt>/modules/<name>`, and is its own project: null. Lockstep
26
+ * with vexp-core utils::git_project_root / is_linked_worktree and the
27
+ * extension's linkedWorktreeMain.
28
+ */
29
+ export function linkedWorktreeMain(dir) {
30
+ let content;
31
+ try {
32
+ const dotGit = path.join(dir, ".git");
33
+ if (!fs.statSync(dotGit).isFile())
34
+ return null;
35
+ content = fs.readFileSync(dotGit, "utf-8");
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ const gitdir = content
41
+ .split(/\r?\n/)
42
+ .map((l) => l.trim())
43
+ .find((l) => l.startsWith("gitdir:"))
44
+ ?.slice("gitdir:".length)
45
+ .trim()
46
+ .replace(/\\/g, "/");
47
+ const at = gitdir ? gitdir.indexOf("/.git/worktrees/") : -1;
48
+ if (!gitdir || at <= 0)
49
+ return null;
50
+ // A worktree's admin dir is one folder, <common>/.git/worktrees/<name>. A
51
+ // submodule checked out inside a linked worktree has its git dir under
52
+ // that admin dir (<main>/.git/worktrees/<wt>/modules/<path>): it is its
53
+ // own project, as a submodule of the main checkout is, and reading it as
54
+ // a worktree of <main> lent it <main>'s consent and a daemon. Lockstep
55
+ // with vexp-core utils::git_project_root.
56
+ const name = gitdir.slice(at + "/.git/worktrees/".length).replace(/\/+$/, "");
57
+ if (!name || name.includes("/"))
58
+ return null;
59
+ const main = path.resolve(dir, gitdir.slice(0, at));
60
+ // A gitdir naming the folder itself is not a link to another checkout.
61
+ return path.resolve(main) === path.resolve(dir) ? null : main;
62
+ }
63
+ /**
64
+ * Is `other` a checkout of the same repository as `root`, on this machine?
65
+ * A folder that is there, whose main checkout (the one a linked worktree's
66
+ * .git file names, else the folder itself) is `root`'s: a worktree's main
67
+ * checkout, a sibling worktree, the main checkout's live worktree. Not a
68
+ * folder that is missing here (a teammate's clone, a renamed or moved
69
+ * project, a removed worktree) and not an independent clone with its own
70
+ * .git. A committed file's pin or hook path is kept (W3) only for such a
71
+ * folder: kept for any other, a fresh clone or a moved repository got a
72
+ * vexp server pinned to a folder that does not exist, and a Codex hook that
73
+ * failed at every prompt, where 3.3.9 had pointed the file at the checkout
74
+ * (review, 2026-11-07). Filesystem only, no git. Lockstep with the
75
+ * extension's.
76
+ */
77
+ export function sameRepositoryCheckout(other, root) {
78
+ try {
79
+ if (!fs.statSync(other).isDirectory())
80
+ return false;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ const mainOf = (d) => linkedWorktreeMain(d) ?? d;
86
+ const key = (p) => {
87
+ const r = path.resolve(p).replace(/[\\/]+$/, "");
88
+ const k = r.length > 0 ? r : path.resolve(p);
89
+ return process.platform === "win32" ? k.toLowerCase() : k;
90
+ };
91
+ const a = mainOf(other);
92
+ const b = mainOf(root);
93
+ if (key(a) === key(b))
94
+ return true;
95
+ try {
96
+ return key(fs.realpathSync.native(a)) === key(fs.realpathSync.native(b));
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * The main checkout a linked worktree takes its consent from: set up with
104
+ * vexp (manifest.json or index.db in its .vexp/) and not a folder the engine
105
+ * refuses whatever it holds (the home directory, a filesystem root). null
106
+ * for anything that is not a linked worktree. Lockstep with vexp-core
107
+ * utils::worktree_consent_source, vexp-mcp worktreeConsentSource and the
108
+ * extension's worktreeConsentSource.
109
+ */
110
+ export function worktreeConsentSource(dir) {
111
+ const main = linkedWorktreeMain(dir);
112
+ if (!main)
113
+ return null;
114
+ const resolved = path.resolve(main);
115
+ // VEXP_ALLOW_HOME_WORKSPACE=1 lifts both refusals, as in the extension's
116
+ // refusedWhateverItHolds (isStateHome already answers false then).
117
+ const fsRoot = process.env.VEXP_ALLOW_HOME_WORKSPACE !== "1" && path.dirname(resolved) === resolved;
118
+ if (isStateHome(resolved) || fsRoot)
119
+ return null;
120
+ const vexp = path.join(main, ".vexp");
121
+ return fs.existsSync(path.join(vexp, "manifest.json")) || fs.existsSync(path.join(vexp, "index.db")) ? main : null;
122
+ }
123
+ /**
124
+ * Does the vexp extension start this folder's daemon when a window opens on
125
+ * it? Lockstep with the extension's startsAtActivation (non-null): the
126
+ * folder has an index of its own (.vexp/index.db), or, not on Windows, it
127
+ * is a linked worktree of a checkout set up for vexp (worktreeConsentSource),
128
+ * whose first start builds its index. Not on Windows: a live daemon holds
129
+ * the worktree's index open, and Windows then refuses `git worktree remove`.
130
+ * What `vexp doctor` says about a daemon that is down: a fresh worktree of
131
+ * a set-up repository was told to press "Start Indexing", while opening its
132
+ * window is enough (review, 2026-11-07).
133
+ */
134
+ export function extensionStartsOnOpen(root, platform = process.platform) {
135
+ return fs.existsSync(path.join(root, ".vexp", "index.db")) || (platform !== "win32" && worktreeConsentSource(root) !== null);
136
+ }
@@ -0,0 +1,271 @@
1
+ import * as fs from "fs";
2
+ // Committed hook scripts (K1), as `vexp setup` sees them. Twin:
3
+ // packages/vexp-vscode/src/providers/hook-scripts.ts, the same code and the
4
+ // same line word for word (vexp-vscode test/hook-scripts-lockstep.test.ts).
5
+ //
6
+ // Why: every hook script vexp writes into a project bakes the absolute path
7
+ // of the engine that wrote it (VEXP_BIN), because Claude Code and Codex run
8
+ // the script with no vexp on PATH. A team that commits .claude/hooks/ and
9
+ // .codex/ then saw every checkout rewrite them whenever that path changed:
10
+ // an extension update (a new versioned folder), a teammate's machine, a
11
+ // container with another HOME, and `vexp setup` against the extension's
12
+ // copy (the extension adds an upgrade fallback the CLI does not). git status
13
+ // showed them modified in every clone and worktree, and an agent's `git add
14
+ // -A` committed the churn (field report, 2026-11-07). A committed script
15
+ // whose text is exactly what this vexp writes, for the engine path it
16
+ // bakes, and that still finds a working engine, is left as it is.
17
+ /**
18
+ * The extension's upgrade fallback, inserted after the VEXP_BIN line of
19
+ * every sh hook script it writes (vexp-vscode agent-auto-config.ts
20
+ * bakeVexpBin, where the reasoning lives). The CLI does not write it: its
21
+ * own paths are version-stable. It recognizes it, so a committed script
22
+ * the extension wrote is not rewritten into the CLI's form. Byte for byte
23
+ * the extension's.
24
+ */
25
+ export const VEXP_BIN_FALLBACK = `
26
+ # An extension upgrade removes the versioned folder above; fall back to the
27
+ # newest installed vexp extension binary. Managed by vexp.
28
+ if [ ! -x "$VEXP_BIN" ]; then
29
+ case "$VEXP_BIN" in
30
+ */extensions/vexp.vexp-vscode-*)
31
+ _vexp_ext_root="\${VEXP_BIN%%/vexp.vexp-vscode-*}"
32
+ _vexp_latest="$(ls -d "$_vexp_ext_root"/vexp.vexp-vscode-*/binaries/*/vexp-core* 2>/dev/null | sort -V 2>/dev/null | tail -n 1)"
33
+ [ -n "$_vexp_latest" ] && [ -x "$_vexp_latest" ] && VEXP_BIN="$_vexp_latest"
34
+ ;;
35
+ esac
36
+ fi`;
37
+ /**
38
+ * `plain`, a sh hook script as the CLI bakes it, in the extension's form:
39
+ * VEXP_BIN_FALLBACK right after its `VEXP_BIN="..."` line, which is where
40
+ * bakeVexpBin puts it. undefined for a script with no such line (the
41
+ * Windows .cmd twin, the opencode / Kilo plugins): neither twin adds a
42
+ * fallback there.
43
+ */
44
+ export function withExtensionFallback(plain) {
45
+ const m = /^VEXP_BIN="[^"\r\n]*"$/m.exec(plain);
46
+ if (!m)
47
+ return undefined;
48
+ const at = m.index + m[0].length;
49
+ return plain.slice(0, at) + VEXP_BIN_FALLBACK + plain.slice(at);
50
+ }
51
+ /**
52
+ * The engine path a hook script bakes: the sh scripts' `VEXP_BIN="..."`,
53
+ * the .cmd twin's `set "VEXP_BIN=..."`, the opencode / Kilo plugins'
54
+ * `const VEXP_BIN = "...";` and `const BIN = "...";`. The first such line;
55
+ * undefined when the text has none.
56
+ */
57
+ export function bakedEnginePath(text) {
58
+ const patterns = [
59
+ /^VEXP_BIN="([^"\r\n]*)"\r?$/m,
60
+ /^set "VEXP_BIN=([^"\r\n]*)"\r?$/m,
61
+ /^const (?:VEXP_)?BIN = "([^"\r\n]*)";\r?$/m,
62
+ ];
63
+ for (const re of patterns) {
64
+ const m = re.exec(text);
65
+ if (m && m[1] !== "")
66
+ return m[1];
67
+ }
68
+ return undefined;
69
+ }
70
+ function executable(p) {
71
+ try {
72
+ fs.accessSync(p, fs.constants.X_OK);
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ function isFile(p) {
80
+ try {
81
+ return fs.statSync(p).isFile();
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ function dirNames(dir) {
88
+ try {
89
+ return fs.readdirSync(dir);
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ }
95
+ const isDigit = (c) => c >= "0" && c <= "9";
96
+ const isAlpha = (c) => (c >= "A" && c <= "Z") || (c >= "a" && c <= "z");
97
+ /** gnulib filevercmp's file_prefixlen: the length of `s` without a suffix like `.tar.gz` or `.exe`. */
98
+ function filePrefixLen(s) {
99
+ const n = s.length;
100
+ let prefixlen = 0;
101
+ for (let i = 0;;) {
102
+ if (i === n)
103
+ return prefixlen;
104
+ i++;
105
+ prefixlen = i;
106
+ while (i + 1 < n && s[i] === "." && (isAlpha(s[i + 1]) || s[i + 1] === "~")) {
107
+ for (i += 2; i < n && (isAlpha(s[i]) || isDigit(s[i]) || s[i] === "~"); i++) {
108
+ /* the suffix goes on */
109
+ }
110
+ }
111
+ }
112
+ }
113
+ /** gnulib filevercmp's order(): where the byte at `pos` sorts. */
114
+ function order(s, pos, len) {
115
+ if (pos === len)
116
+ return -1;
117
+ const c = s[pos];
118
+ if (isDigit(c))
119
+ return 0;
120
+ if (isAlpha(c))
121
+ return c.charCodeAt(0);
122
+ if (c === "~")
123
+ return -2;
124
+ return c.charCodeAt(0) + 256;
125
+ }
126
+ /** gnulib filevercmp's verrevcmp, over the first `alen` and `blen` characters. */
127
+ function verrevcmp(a, alen, b, blen) {
128
+ let i = 0;
129
+ let j = 0;
130
+ while (i < alen || j < blen) {
131
+ let firstDiff = 0;
132
+ while ((i < alen && !isDigit(a[i])) || (j < blen && !isDigit(b[j]))) {
133
+ const ac = order(a, i, alen);
134
+ const bc = order(b, j, blen);
135
+ if (ac !== bc)
136
+ return ac - bc;
137
+ i++;
138
+ j++;
139
+ }
140
+ while (i < alen && a[i] === "0")
141
+ i++;
142
+ while (j < blen && b[j] === "0")
143
+ j++;
144
+ while (i < alen && j < blen && isDigit(a[i]) && isDigit(b[j])) {
145
+ if (!firstDiff)
146
+ firstDiff = a.charCodeAt(i) - b.charCodeAt(j);
147
+ i++;
148
+ j++;
149
+ }
150
+ if (i < alen && isDigit(a[i]))
151
+ return 1;
152
+ if (j < blen && isDigit(b[j]))
153
+ return -1;
154
+ if (firstDiff)
155
+ return firstDiff;
156
+ }
157
+ return 0;
158
+ }
159
+ /**
160
+ * `sort -V` as GNU coreutils orders two lines (gnulib filevercmp, then the
161
+ * whole line byte by byte when they tie), which is what the fallback's
162
+ * `sort -V | tail -n 1` uses to pick the newest build: 3.3.10 after 3.3.9.
163
+ * Negative, zero or positive, like a comparator.
164
+ */
165
+ export function versionSortCompare(a, b) {
166
+ const byVersion = (() => {
167
+ if (a === "" || b === "")
168
+ return (b === "" ? 1 : 0) - (a === "" ? 1 : 0);
169
+ if (a[0] === ".") {
170
+ if (b[0] !== ".")
171
+ return -1;
172
+ const adot = a.length === 1;
173
+ const bdot = b.length === 1;
174
+ if (adot || bdot)
175
+ return (bdot ? 1 : 0) - (adot ? 1 : 0);
176
+ const adotdot = a[1] === "." && a.length === 2;
177
+ const bdotdot = b[1] === "." && b.length === 2;
178
+ if (adotdot || bdotdot)
179
+ return (bdotdot ? 1 : 0) - (adotdot ? 1 : 0);
180
+ }
181
+ else if (b[0] === ".") {
182
+ return 1;
183
+ }
184
+ const ap = filePrefixLen(a);
185
+ const bp = filePrefixLen(b);
186
+ const onePass = ap === a.length && bp === b.length;
187
+ const r = verrevcmp(a, ap, b, bp);
188
+ return r || onePass ? r : verrevcmp(a, a.length, b, b.length);
189
+ })();
190
+ if (byVersion !== 0)
191
+ return byVersion;
192
+ return a < b ? -1 : a > b ? 1 : 0;
193
+ }
194
+ /**
195
+ * The build VEXP_BIN_FALLBACK runs for `bin`, by the rules of its sh code:
196
+ * only for a path inside a VS Code extensions folder
197
+ * (`*\/extensions/vexp.vexp-vscode-*`), the newest (`sort -V`) of
198
+ * `<that folder>/vexp.vexp-vscode-*\/binaries/*\/vexp-core*`, and only when
199
+ * it is executable. undefined when the fallback would pick none. The sh
200
+ * code does not run at all while `bin` itself is executable.
201
+ */
202
+ export function extensionFallbackEngine(bin) {
203
+ if (!bin.includes("/extensions/vexp.vexp-vscode-"))
204
+ return undefined;
205
+ // ${VEXP_BIN%%/vexp.vexp-vscode-*}: everything before the first match.
206
+ const root = bin.slice(0, bin.indexOf("/vexp.vexp-vscode-"));
207
+ // The glob, as sh expands it: no name that starts with a dot under a `*`.
208
+ const found = [];
209
+ for (const build of dirNames(root || "/")) {
210
+ if (!build.startsWith("vexp.vexp-vscode-"))
211
+ continue;
212
+ const binaries = `${root}/${build}/binaries`;
213
+ for (const platform of dirNames(binaries)) {
214
+ if (platform.startsWith("."))
215
+ continue;
216
+ for (const name of dirNames(`${binaries}/${platform}`)) {
217
+ if (name.startsWith("vexp-core"))
218
+ found.push(`${binaries}/${platform}/${name}`);
219
+ }
220
+ }
221
+ }
222
+ if (found.length === 0)
223
+ return undefined;
224
+ const newest = found.reduce((a, b) => (versionSortCompare(a, b) >= 0 ? a : b));
225
+ return executable(newest) && isFile(newest) ? newest : undefined;
226
+ }
227
+ /**
228
+ * Would a hook script that bakes `bin` run an engine right now? The sh
229
+ * scripts run `$VEXP_BIN` when it is executable (and an executable that is
230
+ * no file runs nothing); a script with the extension's fallback
231
+ * (`withFallback`) otherwise runs the build extensionFallbackEngine names.
232
+ */
233
+ export function hookEngineWorks(bin, withFallback) {
234
+ if (executable(bin))
235
+ return isFile(bin);
236
+ return withFallback && extensionFallbackEngine(bin) !== undefined;
237
+ }
238
+ /**
239
+ * K1: may the hook script `text` (what is on disk) stay as it is? Yes when
240
+ * it is exactly what this vexp writes for the engine path it bakes
241
+ * (`plainFor(bin)`, the CLI's form, or that with the extension's fallback),
242
+ * that script would find a working engine now, and `tracked()` (git, asked
243
+ * last) says the file is committed. A script whose template changed, whose
244
+ * engine is gone with no fallback that finds one, or that git does not
245
+ * track is written as before.
246
+ */
247
+ export function committedHookScriptStays(text, plainFor, tracked) {
248
+ const bin = bakedEnginePath(text);
249
+ if (bin === undefined)
250
+ return false;
251
+ const plain = plainFor(bin);
252
+ const extension = withExtensionFallback(plain);
253
+ if (text !== plain && text !== extension)
254
+ return false;
255
+ if (!hookEngineWorks(bin, text === extension))
256
+ return false;
257
+ return tracked();
258
+ }
259
+ /** Why a committed hook script was left as it is (K1). ASCII, word for word the extension's. */
260
+ export function committedHookScriptLine(file) {
261
+ return `${file} is committed to this repository and its vexp engine path still works here; vexp left it as it is`;
262
+ }
263
+ /** The script on disk at `file`, or undefined when there is none to read. */
264
+ export function readHookScript(file) {
265
+ try {
266
+ return fs.readFileSync(file, "utf-8");
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
@@ -11,6 +11,39 @@ export function planLabel(plan) {
11
11
  export function isLifetimePlan(plan) {
12
12
  return /^tier[1-4]$/.test(plan);
13
13
  }
14
+ /**
15
+ * The tool counts `vexp license` states, as the servers define them. The
16
+ * line said "no (7/10)" from v1.2.16 on, long after the surface grew, and
17
+ * "shows 4 by default" for the native server alone. Held to the sources by
18
+ * test/license.tool-counts.test.ts: vexp-core's build_all_tool_defs,
19
+ * FREE_TOOLS and CORE_LISTED_TOOLS (mcp_stdio.rs), and the Node server's
20
+ * MINIMAL_TOOL_DEFINITIONS (vexp-mcp/src/index.ts).
21
+ */
22
+ export const TOOL_COUNTS = {
23
+ /** Every tool vexp-core's MCP server defines. */
24
+ native: 15,
25
+ /** Those its tools/call serves on a plan without all_tools. */
26
+ nativeFree: 9,
27
+ /** What vexp-core lists by default. */
28
+ nativeListed: 4,
29
+ /** What the Node MCP server (mcp-server.cjs) lists by default: the same
30
+ * four since it lists expand_vexp_ref too. */
31
+ nodeListed: 4,
32
+ };
33
+ /** The "All tools" lines of `vexp license` and the menu's status, unindented. */
34
+ export function allToolsLines(allTools) {
35
+ const c = TOOL_COUNTS;
36
+ if (!allTools)
37
+ return [`All tools: no (the Free set: ${c.nativeFree} of vexp-core's ${c.native} tools)`];
38
+ // One number while both servers list as many; both, should they part again.
39
+ const shown = c.nodeListed === c.nativeListed
40
+ ? `${c.nativeListed} by default`
41
+ : `${c.nativeListed} by default, ${c.nodeListed} through the Node MCP server`;
42
+ return [
43
+ "All tools: yes",
44
+ `(the MCP tool list shows ${shown}, to keep the catalog small; every tool stays callable - VEXP_ALL_TOOLS=1 lists them all)`,
45
+ ];
46
+ }
14
47
  /** "Last check-in: <date>" / "never", for `vexp license` and the menu. */
15
48
  export function lastCheckInText() {
16
49
  const at = lastCheckIn();
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
@@ -170,7 +184,8 @@ export function isOwnerEntry(ws, sock) {
170
184
  * and took a 16-core host down: ~1,585% CPU, 2 GB/s of reads, hard reset
171
185
  * (field incident, 2026-09-02, tier 3). Worktree daemons run on demand
172
186
  * only; resurrection is for canonical workspaces. A submodule's gitdir
173
- * points at `/.git/modules/` and is left alone. Mirrors vexp-core
187
+ * points at `/.git/modules/` (or, inside a linked worktree,
188
+ * `/.git/worktrees/<wt>/modules/`) and is left alone. Mirrors vexp-core
174
189
  * utils::git_project_root. */
175
190
  export function isLinkedWorktree(ws) {
176
191
  try {
@@ -183,7 +198,11 @@ export function isLinkedWorktree(ws) {
183
198
  .find((l) => l.trim().startsWith("gitdir:"));
184
199
  if (!gitdir)
185
200
  return false;
186
- return gitdir.replace(/\\/g, "/").includes("/.git/worktrees/");
201
+ // Only `<common>/.git/worktrees/<name>`, one folder deep: a submodule
202
+ // checked out inside a linked worktree (`.git/worktrees/<wt>/modules/
203
+ // <path>`) is its own project. Lockstep with linkedWorktreeMain
204
+ // (git-worktree.ts) and vexp-core utils::git_project_root.
205
+ return /\/\.git\/worktrees\/[^/]+\/?$/.test(gitdir.slice(gitdir.indexOf("gitdir:") + "gitdir:".length).replace(/\\/g, "/").trim());
187
206
  }
188
207
  catch {
189
208
  return false;
@@ -203,8 +222,40 @@ export function resurrectionVeto(ws) {
203
222
  return "no manifest";
204
223
  if (isLinkedWorktree(ws))
205
224
  return "linked git worktree — on-demand only, never resurrected";
225
+ if (indexesNothingUnsetUp(ws)) {
226
+ 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";
227
+ }
206
228
  return null;
207
229
  }
230
+ /** The files a person writes to set a folder up on purpose
231
+ * (vexp-core utils::SET_UP_ON_PURPOSE, held equal by a test there). */
232
+ export const SET_UP_ON_PURPOSE = ["vexp.toml", "workspace.json"];
233
+ /** A workspace whose manifest lists no file and that nobody set up on
234
+ * purpose: what older versions of `vexp index` left in folders with no
235
+ * source. The daemon refuses such a folder unless a walk finds a source
236
+ * file in it (vexp-core utils::nothing_to_serve); a resurrection would be
237
+ * a refused spawn a minute. No walk here: a row whose folder gained
238
+ * sources is started again by the next launcher that needs it, and its
239
+ * daemon then writes a manifest that lists them. A manifest that cannot
240
+ * be read, or has no file_hashes at all (every manifest vexp writes has
241
+ * one), is not "no file". */
242
+ function indexesNothingUnsetUp(ws) {
243
+ if (SET_UP_ON_PURPOSE.some((f) => fs.existsSync(path.join(ws, ".vexp", f))))
244
+ return false;
245
+ try {
246
+ const manifest = path.join(ws, ".vexp", "manifest.json");
247
+ // One that lists nothing is a few hundred bytes; a real one is not
248
+ // worth parsing every minute to learn that it lists files.
249
+ if (fs.statSync(manifest).size > 64 * 1024)
250
+ return false;
251
+ const parsed = JSON.parse(fs.readFileSync(manifest, "utf-8"));
252
+ const files = parsed?.file_hashes;
253
+ return typeof files === "object" && files !== null && Object.keys(files).length === 0;
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ }
208
259
  export function resurrectionPlan(reg) {
209
260
  const bySock = new Map();
210
261
  for (const [ws, sock] of Object.entries(reg)) {