@vodmal/vdx-cli 0.20.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
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",
@@ -11,7 +11,7 @@ schema_version: "0.2"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "0.6.0"
14
+ version: "0.7.0"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
package/src/ai.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type Conversation,
13
13
  describeConversation,
14
14
  describeLive,
15
+ isMachineLabel,
15
16
  LIVE_SESSIONS_SCRIPT,
16
17
  type LiveSession,
17
18
  latestPerMachine,
@@ -19,8 +20,10 @@ import {
19
20
  liveConversationId,
20
21
  liveSessionsHere,
21
22
  machineLine,
23
+ type MachineReport,
22
24
  machineOf,
23
- parseLiveSessions,
25
+ parseMachineReport,
26
+ runsInProject,
24
27
  } from './conversations.ts';
25
28
 
26
29
  /**
@@ -76,6 +79,8 @@ export interface SessionProfile {
76
79
  name?: string;
77
80
  /** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
78
81
  project_names?: NameSource[];
82
+ /** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
83
+ machines?: string[];
79
84
  }
80
85
 
81
86
  export interface GitProfile {
@@ -183,6 +188,12 @@ export function parseEnvironment(text: string, source: string): Environment {
183
188
  }
184
189
  });
185
190
  }
191
+ if (session.machines !== undefined) {
192
+ if (!isStringList(session.machines)) fail('session.machines', 'must be a list of machine names');
193
+ session.machines.forEach((m, i) => {
194
+ if (!isMachineLabel(m)) fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
195
+ });
196
+ }
186
197
  }
187
198
 
188
199
  const gitDoc = envDoc.git;
@@ -238,6 +249,8 @@ export interface LaunchPlan {
238
249
  confirm: ConfirmRule[];
239
250
  multiplexer: Multiplexer;
240
251
  sessionName: string;
252
+ /** The profile's other machines — asked over ssh what runs there before a start. */
253
+ machines: string[];
241
254
  }
242
255
 
243
256
  export function planLaunch(input: {
@@ -276,6 +289,7 @@ export function planLaunch(input: {
276
289
  project: input.project,
277
290
  host: input.host,
278
291
  }),
292
+ machines: (environment.session?.machines ?? []).filter((m) => m.toLowerCase() !== input.host.toLowerCase()),
279
293
  };
280
294
  }
281
295
 
@@ -942,12 +956,12 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version
942
956
  * The Claude Code sessions alive on `host` right now; null when it does not
943
957
  * answer over ssh (BatchMode: a password prompt counts as no answer).
944
958
  */
945
- export function askMachine(host: string, deps: AiDeps): LiveSession[] | null {
959
+ export function askMachine(host: string, deps: AiDeps): MachineReport | null {
946
960
  const res = (deps.ssh ?? runSsh)(
947
961
  ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, `sh -c ${shellQuote(LIVE_SESSIONS_SCRIPT)}`],
948
962
  true,
949
963
  );
950
- return res && res.status === 0 ? parseLiveSessions(res.stdout) : null;
964
+ return res && res.status === 0 ? parseMachineReport(res.stdout) : null;
951
965
  }
952
966
 
953
967
  /** What a start continues: resume args here, or a conversation on another machine. */
@@ -957,6 +971,10 @@ export interface ConversationChoice {
957
971
  remote?: { host: string; id: string; reachable: boolean };
958
972
  /** Continue it here only after a yes: another machine's that cannot be asked, or one running here already. */
959
973
  askHere?: { id: string; why: string };
974
+ /** The person ended the question (Ctrl-D): start nothing. */
975
+ abort?: boolean;
976
+ /** A new conversation nobody picked, while an agent of the project runs on these machines: only after a yes. */
977
+ elsewhere?: string[];
960
978
  /** For the person: what was found and what is taken. */
961
979
  lines: string[];
962
980
  }
@@ -974,10 +992,15 @@ const resumeId = (id: string) => ['--resume', id];
974
992
  * newest one in the folder, and with the folder synced between machines that
975
993
  * can be a conversation another machine is still writing to.
976
994
  *
977
- * Every other machine this project's conversations name is asked over ssh
978
- * which of them run there right now; this machine's running ones come from
979
- * its own session files. A running conversation is listed even when it is not
980
- * its machine's newest.
995
+ * The profile's other machines, and every other machine this project's
996
+ * conversations name, are asked over ssh what runs there right now; this
997
+ * machine's running ones come from its own session files. A running
998
+ * conversation is listed even when it is not its machine's newest.
999
+ *
1000
+ * A new conversation the person did not pick from the list — `--new`, a
1001
+ * project with none yet, none of this machine's idle — while an agent of this
1002
+ * project runs on another machine (by the directory it runs in) is started
1003
+ * only after a yes: two agents would work in one repository from two machines.
981
1004
  *
982
1005
  * Another machine's conversation is continued on that machine, so two
983
1006
  * machines never write to one conversation; one whose machine is unknown, or
@@ -987,24 +1010,34 @@ const resumeId = (id: string) => ['--resume', id];
987
1010
  * idle one, never another's.
988
1011
  */
989
1012
  export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
990
- if (plan.resumeArgs.length === 0 || !isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1013
+ if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
991
1014
  const host = plan.host;
992
1015
  const same = (a: string | null, b: string | null) => (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
993
- const all = listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
994
- if (all.length === 0) return { args: [], lines: ['no conversation of this project yet — starting a new one'] };
1016
+ const all = plan.resumeArgs.length === 0 ? [] : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
1017
+ const root = shellQuote(plan.projectRoot);
995
1018
 
996
1019
  // Who runs what right now: this machine from its session files, the others over ssh.
997
- const reach = new Map<string, LiveSession[] | null>();
998
- for (const c of all) {
999
- if (c.machine && !same(c.machine, host) && ![...reach.keys()].some((m) => same(m, c.machine))) {
1000
- reach.set(c.machine, askMachine(c.machine, deps));
1001
- }
1020
+ const reach = new Map<string, MachineReport | null>();
1021
+ for (const m of [...plan.machines, ...all.map((c) => c.machine)]) {
1022
+ if (m && !same(m, host) && ![...reach.keys()].some((k) => same(k, m))) reach.set(m, askMachine(m, deps));
1002
1023
  }
1024
+ const silent = [...reach].filter(([, r]) => r === null).map(([m]) => `note: ${m} does not answer over ssh — what runs there is not known`);
1025
+ const elsewhere = [...reach].flatMap(([m, r]) =>
1026
+ (r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
1027
+ );
1028
+ const fresh = (lines: string[]): ConversationChoice => {
1029
+ if (elsewhere.length === 0) return { args: [], lines };
1030
+ const agents = elsewhere.map(({ m, l }) => `an agent of this project is ${describeLive(m, l, false)} — there: vdx ai@${m} ${root}`);
1031
+ return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
1032
+ };
1033
+ if (plan.resumeArgs.length === 0) return fresh(silent);
1034
+ if (all.length === 0) return fresh(['no conversation of this project yet — starting a new one', ...silent]);
1035
+
1003
1036
  const live = new Map<string, { machine: string; session: LiveSession; here: boolean }[]>();
1004
1037
  const addLive = (machine: string, session: LiveSession, here: boolean) =>
1005
1038
  live.set(session.conversation, [...(live.get(session.conversation) ?? []), { machine, session, here }]);
1006
1039
  for (const l of (deps.liveHere ?? (() => liveSessionsHere(deps.env, deps.home)))()) addLive(host, l, true);
1007
- for (const [m, sessions] of reach) for (const l of sessions ?? []) addLive(m, l, false);
1040
+ for (const [m, r] of reach) for (const l of r?.sessions ?? []) addLive(m, l, false);
1008
1041
 
1009
1042
  const machine = (c: Conversation) => live.get(c.id)?.find((l) => !l.here)?.machine ?? machineOf(c, host);
1010
1043
  const runsHere = (c: Conversation) => live.get(c.id)?.find((l) => l.here);
@@ -1047,7 +1080,8 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1047
1080
  deps.log(' n) a new conversation');
1048
1081
  for (let tries = 0; tries < 2; tries++) {
1049
1082
  const answer = deps.ask(`Which one to continue? [1]: `);
1050
- if (answer === null) break;
1083
+ // Ctrl-D at the question is a cancel, not "no terminal": start nothing.
1084
+ if (answer === null) return { args: [], abort: true, lines: ['no answer — nothing started'] };
1051
1085
  const a = answer.trim();
1052
1086
  if (/^n(ew)?$/i.test(a)) return { args: [], lines: ['a new conversation'] };
1053
1087
  const c = a === '' ? found[0] : /^\d+$/.test(a) ? found[Number(a) - 1] : undefined;
@@ -1055,7 +1089,6 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1055
1089
  deps.log(` 1–${found.length}, or n`);
1056
1090
  }
1057
1091
  }
1058
- const root = shellQuote(plan.projectRoot);
1059
1092
  const notes = found
1060
1093
  .filter((c) => !idleHere(c))
1061
1094
  .map((c) => {
@@ -1069,12 +1102,12 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1069
1102
  ? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
1070
1103
  : `note: ${which} is of an unknown machine (never typed in here) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1071
1104
  });
1072
- for (const [m, sessions] of reach) if (sessions === null) notes.push(`note: ${m} does not answer over ssh — what runs there is not known`);
1105
+ notes.push(...silent);
1073
1106
  if (firstIdle) {
1074
1107
  const chosen = take(firstIdle);
1075
1108
  return { ...chosen, lines: [...chosen.lines, ...notes] };
1076
1109
  }
1077
- return { args: [], lines: ['no idle conversation of this machine — starting a new one', ...notes] };
1110
+ return fresh(['no idle conversation of this machine — starting a new one', ...notes]);
1078
1111
  }
1079
1112
 
1080
1113
  interface Prepared {
@@ -1227,7 +1260,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1227
1260
  let choice: ConversationChoice | null = null;
1228
1261
  if (!current || opts.restart) {
1229
1262
  if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1230
- else if (current && plan.resumeArgs.length > 0 && isClaude(plan.command)) {
1263
+ // A restart into a new conversation replaces the agent here: no second agent, nothing to ask.
1264
+ else if (current && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
1265
+ else if (current && isClaude(plan.command)) {
1231
1266
  const live = liveConversationId(deps.env, deps.home, current.proc.pid);
1232
1267
  choice = live
1233
1268
  ? { args: resumeId(live), lines: [`the agent is in conversation ${live.slice(0, 8)} — resuming it`] }
@@ -1244,6 +1279,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1244
1279
  return EXIT_OK;
1245
1280
  }
1246
1281
  for (const l of choice?.lines ?? []) log(l);
1282
+ if (choice?.abort) return EXIT_LAUNCH_FAILED;
1283
+ if (choice?.elsewhere && !current) {
1284
+ const answer = deps.interactive ? deps.ask(`Start a new conversation here, on ${plan.host}, anyway? [y/N]: `) : null;
1285
+ if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1286
+ log('nothing started');
1287
+ return EXIT_LAUNCH_FAILED;
1288
+ }
1289
+ }
1247
1290
 
1248
1291
  let askHere = choice?.askHere;
1249
1292
  if (choice?.remote) {
@@ -38,6 +38,11 @@ export function machineLine(label: string): string {
38
38
  return `${MACHINE_LINE_PREFIX} \`${label}\``;
39
39
  }
40
40
 
41
+ /** A machine label vdx may hand to ssh as the destination. */
42
+ export function isMachineLabel(s: string): boolean {
43
+ return LABEL_RE.test(s);
44
+ }
45
+
41
46
  export function isConversationId(s: string): boolean {
42
47
  return ID_RE.test(s);
43
48
  }
@@ -206,6 +211,8 @@ export interface LiveSession {
206
211
  tmux: string | null;
207
212
  /** Claude Code's own word for it: `busy`, `waiting`, … */
208
213
  status: string | null;
214
+ /** The directory it was started in — on its own machine. */
215
+ cwd: string | null;
209
216
  }
210
217
 
211
218
  /** Session files, one JSON object per line — a file read here, or the output of LIVE_SESSIONS_SCRIPT. */
@@ -225,11 +232,32 @@ export function parseLiveSessions(text: string): LiveSession[] {
225
232
  conversation: data.sessionId,
226
233
  tmux: typeof data.tmux === 'string' ? data.tmux : null,
227
234
  status: typeof data.status === 'string' ? data.status : null,
235
+ cwd: typeof data.cwd === 'string' ? data.cwd : null,
228
236
  });
229
237
  }
230
238
  return out;
231
239
  }
232
240
 
241
+ /** `~/AI Projects/x` for a path under `home`; any other path as is. */
242
+ function fromHome(p: string, home: string): string {
243
+ const rel = path.relative(home, p);
244
+ if (rel === '') return '~';
245
+ return rel.startsWith('..') || path.isAbsolute(rel) ? p : `~/${rel}`;
246
+ }
247
+
248
+ /**
249
+ * The session runs in the project: its directory is the project's root or
250
+ * below it. Paths of two machines are compared from their homes — `vdx
251
+ * ai@<host>` puts a project under the same place in the other home; with that
252
+ * home unknown, as they are.
253
+ */
254
+ export function runsInProject(live: LiveSession, liveHome: string | null, projectRoot: string, home: string): boolean {
255
+ if (!live.cwd) return false;
256
+ const cwd = liveHome ? fromHome(live.cwd, liveHome) : live.cwd;
257
+ const root = liveHome ? fromHome(projectRoot, home) : projectRoot;
258
+ return cwd === root || cwd.startsWith(root.endsWith('/') ? root : `${root}/`);
259
+ }
260
+
233
261
  function pidAlive(pid: number): boolean {
234
262
  try {
235
263
  process.kill(pid, 0);
@@ -244,8 +272,11 @@ function pidAlive(pid: number): boolean {
244
272
  * runs on, one per line — what `vdx ai` asks another machine over ssh. Claude
245
273
  * removes the file of a process that ended, but not after a crash or a reboot:
246
274
  * the pid is checked. `<pid>.sync-conflict-….json` is not a session file.
275
+ * The first line, `home=<dir>`, places the sessions' directories in that
276
+ * machine's home (see runsInProject).
247
277
  */
248
278
  export const LIVE_SESSIONS_SCRIPT =
279
+ 'printf "home=%s\\n" "$HOME"; ' +
249
280
  'd="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions"; for f in "$d"/*.json; do ' +
250
281
  'p=${f##*/}; p=${p%.json}; case $p in ""|*[!0-9]*) continue;; esac; ' +
251
282
  'kill -0 "$p" 2>/dev/null && tr -d "\\n" < "$f" && echo; done; exit 0';
@@ -272,6 +303,17 @@ export function liveSessionsHere(env: NodeJS.ProcessEnv, home: string, alive: (p
272
303
  return parseLiveSessions(lines.join('\n'));
273
304
  }
274
305
 
306
+ /** What another machine runs right now: its home and the Claude Code sessions alive there. */
307
+ export interface MachineReport {
308
+ home: string | null;
309
+ sessions: LiveSession[];
310
+ }
311
+
312
+ /** The output of LIVE_SESSIONS_SCRIPT. */
313
+ export function parseMachineReport(text: string): MachineReport {
314
+ return { home: text.match(/^home=(.+)$/m)?.[1] ?? null, sessions: parseLiveSessions(text) };
315
+ }
316
+
275
317
  /** "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for a person choosing. */
276
318
  export function describeLive(machine: string, live: LiveSession, here: boolean): string {
277
319
  const where = live.tmux ? live.tmux.replace(/:.*$/, '') : `pid ${live.pid}`;
package/src/index.ts CHANGED
@@ -88,11 +88,13 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
88
88
 
89
89
  Which conversation a start continues (Claude Code): each one is marked with the
90
90
  machine it was last started on (the vdx plugin's hook writes it in), and the
91
- other machines are asked over ssh which ones run there now. Another machine's
92
- conversation is continued on that machine; if it does not answer, vdx asks
93
- before continuing it here. When the choice is not plain, vdx lists them, the
94
- running ones marked — Enter takes the newest; without a terminal it takes this
95
- machine's.
91
+ other machines (session.machines in the profile, and those the conversations
92
+ name) are asked over ssh what runs there now. Another machine's conversation
93
+ is continued on that machine; if it does not answer, vdx asks before
94
+ continuing it here. When the choice is not plain, vdx lists them, the running
95
+ ones marked — Enter takes the newest; without a terminal it takes this
96
+ machine's. A new conversation (--new, or none to continue) while an agent of
97
+ the project runs on another machine starts only after a yes.
96
98
 
97
99
  Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
98
100
  3 the running agent lacks profile flags (rerun with --restart); 4 the start
package/src/init.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
13
13
  export type Verb = (typeof STANDARD_VERBS)[number];
14
14
 
15
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.6.0';
15
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.7.0';
16
16
 
17
17
  const VERB_ALIASES: Record<Verb, string[]> = {
18
18
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
package/src/publish.ts CHANGED
@@ -392,6 +392,8 @@ export interface PublishDeps {
392
392
  sleep: (ms: number) => void;
393
393
  /** How long to wait for the registry to show the new version. */
394
394
  registryWaitMs: number;
395
+ /** Rewrites one status line in place (a terminal); without it the wait logs a line now and then. */
396
+ progress?: (text: string) => void;
395
397
  }
396
398
 
397
399
  function sleepSync(ms: number): void {
@@ -407,6 +409,7 @@ export function defaultPublishDeps(): PublishDeps {
407
409
  log: (line) => process.stderr.write(line + '\n'),
408
410
  sleep: sleepSync,
409
411
  registryWaitMs: 180_000,
412
+ progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
410
413
  };
411
414
  }
412
415
 
@@ -427,19 +430,38 @@ export function bumpLockText(text: string, version: string): string {
427
430
  export function waitForRegistry(name: string, version: string, deps: PublishDeps): boolean {
428
431
  const started = Date.now();
429
432
  const deadline = started + deps.registryWaitMs;
430
- deps.log(`→ waiting for the registry to show ${name}@${version}...`);
431
- for (;;) {
433
+ const limit = Math.round(deps.registryWaitMs / 1000);
434
+ const seconds = () => Math.round((Date.now() - started) / 1000);
435
+ deps.log(
436
+ `→ waiting for the registry to show ${name}@${version} — npm has taken it; ` +
437
+ `the registry usually shows it within ~2 min (giving it ${limit} s)`,
438
+ );
439
+ const shown = () => {
432
440
  try {
433
- if (deps.npm(['view', `${name}@${version}`, 'version', '--prefer-online']).trim() === version) {
434
- deps.log(`✓ ${name}@${version} is on the registry (${Math.round((Date.now() - started) / 1000)} s after publish)`);
435
- return true;
436
- }
441
+ return deps.npm(['view', `${name}@${version}`, 'version', '--prefer-online']).trim() === version;
437
442
  } catch {
438
- /* E404 until it is processed */
443
+ return false; // E404 until it is processed
444
+ }
445
+ };
446
+ // Ask npm every 10 s; between the asks, count the seconds on one line.
447
+ let lastLogged = 0;
448
+ for (let found = shown(); ; found = shown()) {
449
+ if (found) {
450
+ deps.progress?.('');
451
+ deps.log(`✓ ${name}@${version} is on the registry (${seconds()} s after publish)`);
452
+ return true;
439
453
  }
440
454
  if (Date.now() >= deadline) break;
441
- deps.sleep(10_000);
455
+ for (let i = 0; i < 10 && Date.now() < deadline; i++) {
456
+ if (deps.progress) deps.progress(` … ${seconds()} s of ${limit} s`);
457
+ else if (seconds() - lastLogged >= 30) {
458
+ lastLogged = seconds();
459
+ deps.log(` … ${lastLogged} s`);
460
+ }
461
+ deps.sleep(1_000);
462
+ }
442
463
  }
464
+ deps.progress?.('');
443
465
  deps.log(
444
466
  `! ${name}@${version} not visible on the registry after ${Math.round(deps.registryWaitMs / 60_000)} min — ` +
445
467
  `npm accepted it; check later: npm view ${name}@${version} version`,