@vodmal/vdx-cli 0.31.0 → 0.31.2

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
@@ -167,6 +167,7 @@ npm run typecheck
167
167
  - `src/mcp-server.ts` — MCP stdio server (9 tools)
168
168
  - `src/defaults.ts` — bundled rubric resolution
169
169
  - `bin/vdx.cjs`, `bin/vdx-mcp.cjs` — Node wrappers (tsx ESM loader in-process)
170
+ - `bin/node-guard.cjs` — under a project's older node (`.nvmrc` with fnm, nvm, mise), runs the wrapper again on the node beside its bin link
170
171
 
171
172
  ## Not implemented yet
172
173
 
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { spawn } = require('node:child_process');
6
+
7
+ // vdx needs Node 22 or newer (engines in package.json), and its bins start with
8
+ // `env node`: in a project that pins an older node (.nvmrc under fnm or nvm,
9
+ // mise) that is the project's node, and tsx stops before vdx starts. Then vdx
10
+ // runs again on the node its bin link sits next to — the one whose npm put it
11
+ // there; the project's node stays on PATH for the commands vdx runs. This file
12
+ // runs on that old node: nothing past Node 14 in it.
13
+ // True — the current node will do; false — the re-run or the error owns the exit.
14
+ module.exports = function newEnoughNode(entry) {
15
+ if (Number(process.versions.node.split('.')[0]) >= 22) return true;
16
+ const node = real(path.join(path.dirname(process.argv[1]), 'node'));
17
+ if (!node || node === real(process.execPath)) {
18
+ console.error(
19
+ `vdx needs Node.js 22 or newer; the node on PATH here is ${process.version}` +
20
+ ' — the project may pin it (.nvmrc, .node-version, mise.toml)',
21
+ );
22
+ process.exitCode = 1;
23
+ return false;
24
+ }
25
+ const child = spawn(node, [entry].concat(process.argv.slice(2)), { stdio: 'inherit' });
26
+ // Ctrl-C reaches the child from the terminal itself; a signal sent to vdx alone is passed on.
27
+ ['SIGINT', 'SIGQUIT'].forEach((sig) => process.on(sig, () => {}));
28
+ ['SIGTERM', 'SIGHUP'].forEach((sig) => process.on(sig, () => child.kill(sig)));
29
+ child.on('error', (err) => {
30
+ console.error(`vdx: ${node}: ${err.message}`);
31
+ process.exit(1);
32
+ });
33
+ child.on('exit', (code, signal) => process.exit(signal ? 128 + os.constants.signals[signal] : code));
34
+ return false;
35
+ };
36
+
37
+ function real(p) {
38
+ try {
39
+ return fs.realpathSync(p);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
package/bin/vdx-mcp.cjs CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- const path = require('node:path');
4
- const { register } = require('tsx/esm/api');
5
- register();
6
- import(path.join(__dirname, '..', 'src', 'mcp-server.ts')).catch((err) => {
7
- console.error(err);
8
- process.exit(1);
9
- });
3
+ if (require('./node-guard.cjs')(__filename)) {
4
+ const path = require('node:path');
5
+ const { register } = require('tsx/esm/api');
6
+ register();
7
+ import(path.join(__dirname, '..', 'src', 'mcp-server.ts')).catch((err) => {
8
+ console.error(err);
9
+ process.exit(1);
10
+ });
11
+ }
package/bin/vdx.cjs CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- const path = require('node:path');
4
- const { register } = require('tsx/esm/api');
5
- register();
6
- import(path.join(__dirname, '..', 'src', 'index.ts')).catch((err) => {
7
- console.error(err);
8
- process.exit(1);
9
- });
3
+ if (require('./node-guard.cjs')(__filename)) {
4
+ const path = require('node:path');
5
+ const { register } = require('tsx/esm/api');
6
+ register();
7
+ import(path.join(__dirname, '..', 'src', 'index.ts')).catch((err) => {
8
+ console.error(err);
9
+ process.exit(1);
10
+ });
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
5
5
  "keywords": [
6
6
  "vdx",
package/src/ai.ts CHANGED
@@ -292,6 +292,20 @@ export function renderSessionName(template: string, vars: Record<string, string>
292
292
  return name.replace(/[.:]/g, '_');
293
293
  }
294
294
 
295
+ /**
296
+ * The session for an agent started next to running ones: `name`, `name-2`, `name-3`… — the
297
+ * first no agent of the project runs in (absent, or a shell left behind). Each agent has a
298
+ * session of its own: tmux keeps one current window per session, and an agent started as a
299
+ * window next to another moved every terminal on the session to it (vdx-ai DL #36).
300
+ */
301
+ export function freeSessionName(name: string, running: { pane: { session: string } }[]): string {
302
+ const busy = new Set(running.map((a) => a.pane.session));
303
+ for (let n = 1; ; n++) {
304
+ const candidate = n === 1 ? name : `${name}-${n}`;
305
+ if (!busy.has(candidate)) return candidate;
306
+ }
307
+ }
308
+
295
309
  export interface LaunchPlan {
296
310
  projectRoot: string;
297
311
  project: string;
@@ -804,7 +818,9 @@ function renderPlan(
804
818
  plan: LaunchPlan,
805
819
  multiplexer: Multiplexer,
806
820
  running: AgentPane[],
807
- focusedOf: (pid: number) => boolean = () => false,
821
+ focusedOf: (pid: number) => boolean,
822
+ /** The session a start would make: next to running agents, one of its own. */
823
+ session: string,
808
824
  ): string {
809
825
  const lines = [
810
826
  `vdx ai — ${plan.projectRoot}`,
@@ -819,7 +835,7 @@ function renderPlan(
819
835
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
820
836
  lines.push(
821
837
  multiplexer === 'tmux'
822
- ? ` session: tmux; a new session is named "${plan.sessionName}"`
838
+ ? ` session: tmux; a new session is named "${session}"`
823
839
  : ' session: this terminal (no tmux)',
824
840
  );
825
841
  if (multiplexer === 'tmux') {
@@ -1618,7 +1634,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1618
1634
  ? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home)
1619
1635
  : [];
1620
1636
  if (opts.dryRun) {
1621
- deps.out(renderPlan(plan, multiplexer, running, focusedOf));
1637
+ deps.out(
1638
+ renderPlan(plan, multiplexer, running, focusedOf, freeSessionName(plan.sessionName, alongside ? running : [])),
1639
+ );
1622
1640
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1623
1641
  for (const l of folders) deps.out(` ${l}\n`);
1624
1642
  ensureAuthor(projectRoot, poolDirs, deps, true);
@@ -1758,10 +1776,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1758
1776
  if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
1759
1777
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1760
1778
  const cmd = tmuxShellCommand(line, deps.shell);
1761
- const reuse = tmux.hasSession(plan.sessionName);
1762
- const paneId = reuse
1763
- ? tmux.newWindow(plan.sessionName, projectRoot, cmd)
1764
- : tmux.newSession(plan.sessionName, projectRoot, cmd);
1765
- log(`▶ ${plan.sessionName}${reuse ? ' (new window)' : ''}: ${line}`);
1766
- return finish(settleResumed(plan, paneId, deps), plan.sessionName, paneId, opts, deps);
1779
+ const session = freeSessionName(plan.sessionName, running);
1780
+ const reuse = tmux.hasSession(session);
1781
+ const paneId = reuse ? tmux.newWindow(session, projectRoot, cmd) : tmux.newSession(session, projectRoot, cmd);
1782
+ log(`▶ ${session}${reuse ? ' (new window)' : ''}: ${line}`);
1783
+ return finish(settleResumed(plan, paneId, deps), session, paneId, opts, deps);
1767
1784
  }
package/src/gate.ts CHANGED
@@ -132,15 +132,18 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
132
132
  d.log(`✓ ${where} is at ${short}`);
133
133
  };
134
134
 
135
- // Pushed once and checked: the branch takes it now.
136
- const before = ask();
137
- if (!isUnknown(before) && passed(before)) {
138
- d.log(`✓ ${short} has passed ${names}`);
139
- toBranch();
140
- return;
135
+ const gate = GATE_PREFIX + t.branch;
136
+ // On the gate branch already (a second vdx push) and checked: the branch takes it now.
137
+ // A commit not pushed yet is not asked about — GitHub would only answer 422.
138
+ if (d.io.git(['rev-parse', '--verify', '--quiet', `refs/remotes/${t.remote}/${gate}`]) === sha) {
139
+ const before = ask();
140
+ if (!isUnknown(before) && passed(before)) {
141
+ d.log(`✓ ${short} has passed ${names}`);
142
+ toBranch();
143
+ return;
144
+ }
141
145
  }
142
146
 
143
- const gate = GATE_PREFIX + t.branch;
144
147
  d.log(`→ git push --force ${t.remote} ${short}:${gate} — ${where} requires ${names}; CI checks the commit there`);
145
148
  d.push(['push', '--force', t.remote, `${sha}:refs/heads/${gate}`]);
146
149
  d.log(`→ waiting for ${names} on ${short} (asking every ${Math.round(d.pollMs / 1000)} s): ${link}`);
package/src/hosting.ts CHANGED
@@ -228,6 +228,11 @@ export function commitChecks(
228
228
  token: string | null,
229
229
  ): CheckState[] | Unknown {
230
230
  const runs = io.get(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`, token);
231
+ // Seconds after a push GitHub may not show the commit yet: 422 «No commit found
232
+ // for SHA» for 106da5f, 15 s after its push to a new branch (2026-10-18).
233
+ if (runs.status === 422 && /^No commit found/.test(str(at(runs.body, 'message')) ?? '')) {
234
+ return names.map((name) => ({ name, state: 'pending', detail: 'not on GitHub yet' }));
235
+ }
231
236
  const refusedRuns = refusal(runs, repo, token);
232
237
  if (refusedRuns) return refusedRuns;
233
238
  // A re-run is a new check run under the same name: the newest one counts.
package/src/index.ts CHANGED
@@ -83,7 +83,7 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
83
83
  @host the same command on <host> over ssh: its vdx, profile and tmux
84
84
  --new a new conversation instead of continuing the last one; an agent
85
85
  already running here keeps running, the new one starts next to
86
- it (a new tmux window)
86
+ it, in a tmux session of its own (<name>-2, -3, …)
87
87
  --focused a new conversation, as --new, in a session nothing wakes: the
88
88
  agent, its hooks and MCP servers get VDX_FOCUSED=1 — for the
89
89
  plugins that wake sessions to leave it alone — and the profile's