@vodmal/vdx-cli 0.20.1 → 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.1",
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. */
@@ -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);
@@ -1058,7 +1089,6 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1058
1089
  deps.log(` 1–${found.length}, or n`);
1059
1090
  }
1060
1091
  }
1061
- const root = shellQuote(plan.projectRoot);
1062
1092
  const notes = found
1063
1093
  .filter((c) => !idleHere(c))
1064
1094
  .map((c) => {
@@ -1072,12 +1102,12 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1072
1102
  ? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
1073
1103
  : `note: ${which} is of an unknown machine (never typed in here) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1074
1104
  });
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`);
1105
+ notes.push(...silent);
1076
1106
  if (firstIdle) {
1077
1107
  const chosen = take(firstIdle);
1078
1108
  return { ...chosen, lines: [...chosen.lines, ...notes] };
1079
1109
  }
1080
- 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]);
1081
1111
  }
1082
1112
 
1083
1113
  interface Prepared {
@@ -1230,7 +1260,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1230
1260
  let choice: ConversationChoice | null = null;
1231
1261
  if (!current || opts.restart) {
1232
1262
  if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1233
- 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)) {
1234
1266
  const live = liveConversationId(deps.env, deps.home, current.proc.pid);
1235
1267
  choice = live
1236
1268
  ? { args: resumeId(live), lines: [`the agent is in conversation ${live.slice(0, 8)} — resuming it`] }
@@ -1248,6 +1280,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1248
1280
  }
1249
1281
  for (const l of choice?.lines ?? []) log(l);
1250
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
+ }
1251
1290
 
1252
1291
  let askHere = choice?.askHere;
1253
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'],