@vodmal/vdx-cli 0.20.1 → 0.21.1

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.1",
3
+ "version": "0.21.1",
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. */
@@ -959,6 +973,8 @@ export interface ConversationChoice {
959
973
  askHere?: { id: string; why: string };
960
974
  /** The person ended the question (Ctrl-D): start nothing. */
961
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[];
962
978
  /** For the person: what was found and what is taken. */
963
979
  lines: string[];
964
980
  }
@@ -976,10 +992,15 @@ const resumeId = (id: string) => ['--resume', id];
976
992
  * newest one in the folder, and with the folder synced between machines that
977
993
  * can be a conversation another machine is still writing to.
978
994
  *
979
- * Every other machine this project's conversations name is asked over ssh
980
- * which of them run there right now; this machine's running ones come from
981
- * its own session files. A running conversation is listed even when it is not
982
- * 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.
983
1004
  *
984
1005
  * Another machine's conversation is continued on that machine, so two
985
1006
  * machines never write to one conversation; one whose machine is unknown, or
@@ -989,24 +1010,34 @@ const resumeId = (id: string) => ['--resume', id];
989
1010
  * idle one, never another's.
990
1011
  */
991
1012
  export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
992
- if (plan.resumeArgs.length === 0 || !isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1013
+ if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
993
1014
  const host = plan.host;
994
1015
  const same = (a: string | null, b: string | null) => (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
995
- const all = listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
996
- 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);
997
1018
 
998
1019
  // Who runs what right now: this machine from its session files, the others over ssh.
999
- const reach = new Map<string, LiveSession[] | null>();
1000
- for (const c of all) {
1001
- if (c.machine && !same(c.machine, host) && ![...reach.keys()].some((m) => same(m, c.machine))) {
1002
- reach.set(c.machine, askMachine(c.machine, deps));
1003
- }
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));
1004
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
+
1005
1036
  const live = new Map<string, { machine: string; session: LiveSession; here: boolean }[]>();
1006
1037
  const addLive = (machine: string, session: LiveSession, here: boolean) =>
1007
1038
  live.set(session.conversation, [...(live.get(session.conversation) ?? []), { machine, session, here }]);
1008
1039
  for (const l of (deps.liveHere ?? (() => liveSessionsHere(deps.env, deps.home)))()) addLive(host, l, true);
1009
- 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);
1010
1041
 
1011
1042
  const machine = (c: Conversation) => live.get(c.id)?.find((l) => !l.here)?.machine ?? machineOf(c, host);
1012
1043
  const runsHere = (c: Conversation) => live.get(c.id)?.find((l) => l.here);
@@ -1036,7 +1067,10 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1036
1067
  lines: [`chosen: ${show(c)}`],
1037
1068
  };
1038
1069
  };
1039
- if (found.every(idleHere)) return take(found[0]!);
1070
+ if (found.every(idleHere)) {
1071
+ const chosen = take(found[0]!);
1072
+ return { ...chosen, lines: [...chosen.lines, ...silent] };
1073
+ }
1040
1074
 
1041
1075
  const firstIdle = found.find(idleHere);
1042
1076
  if (canAsk && deps.interactive) {
@@ -1047,6 +1081,8 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1047
1081
  deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
1048
1082
  });
1049
1083
  deps.log(' n) a new conversation');
1084
+ // A machine that did not answer may run any of these: say so before the person picks.
1085
+ for (const n of silent) deps.log(` ${n}`);
1050
1086
  for (let tries = 0; tries < 2; tries++) {
1051
1087
  const answer = deps.ask(`Which one to continue? [1]: `);
1052
1088
  // Ctrl-D at the question is a cancel, not "no terminal": start nothing.
@@ -1058,7 +1094,6 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1058
1094
  deps.log(` 1–${found.length}, or n`);
1059
1095
  }
1060
1096
  }
1061
- const root = shellQuote(plan.projectRoot);
1062
1097
  const notes = found
1063
1098
  .filter((c) => !idleHere(c))
1064
1099
  .map((c) => {
@@ -1072,12 +1107,12 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1072
1107
  ? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
1073
1108
  : `note: ${which} is of an unknown machine (never typed in here) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1074
1109
  });
1075
- for (const [m, sessions] of reach) if (sessions === null) notes.push(`note: ${m} does not answer over ssh — what runs there is not known`);
1110
+ notes.push(...silent);
1076
1111
  if (firstIdle) {
1077
1112
  const chosen = take(firstIdle);
1078
1113
  return { ...chosen, lines: [...chosen.lines, ...notes] };
1079
1114
  }
1080
- return { args: [], lines: ['no idle conversation of this machine — starting a new one', ...notes] };
1115
+ return fresh(['no idle conversation of this machine — starting a new one', ...notes]);
1081
1116
  }
1082
1117
 
1083
1118
  interface Prepared {
@@ -1230,7 +1265,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1230
1265
  let choice: ConversationChoice | null = null;
1231
1266
  if (!current || opts.restart) {
1232
1267
  if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1233
- else if (current && plan.resumeArgs.length > 0 && isClaude(plan.command)) {
1268
+ // A restart into a new conversation replaces the agent here: no second agent, nothing to ask.
1269
+ else if (current && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
1270
+ else if (current && isClaude(plan.command)) {
1234
1271
  const live = liveConversationId(deps.env, deps.home, current.proc.pid);
1235
1272
  choice = live
1236
1273
  ? { args: resumeId(live), lines: [`the agent is in conversation ${live.slice(0, 8)} — resuming it`] }
@@ -1248,6 +1285,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1248
1285
  }
1249
1286
  for (const l of choice?.lines ?? []) log(l);
1250
1287
  if (choice?.abort) return EXIT_LAUNCH_FAILED;
1288
+ if (choice?.elsewhere && !current) {
1289
+ const answer = deps.interactive ? deps.ask(`Start a new conversation here, on ${plan.host}, anyway? [y/N]: `) : null;
1290
+ if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1291
+ log('nothing started');
1292
+ return EXIT_LAUNCH_FAILED;
1293
+ }
1294
+ }
1251
1295
 
1252
1296
  let askHere = choice?.askHere;
1253
1297
  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'],