@vodmal/vdx-cli 0.22.0 → 0.22.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.22.0",
3
+ "version": "0.22.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",
package/src/ai.ts CHANGED
@@ -116,6 +116,8 @@ export interface Environment {
116
116
 
117
117
  export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
118
118
  export const HOST_ENV_VAR = 'VDX_HOST';
119
+ /** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
120
+ export const SEAT_ENV_VAR = 'VDX_AI_FROM';
119
121
  export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
120
122
  /** Tried in order when no profile names an agent. Started plain, with no flags. */
121
123
  export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
@@ -279,6 +281,8 @@ export interface LaunchPlan {
279
281
  /** Where `project` came from: mise.toml, a profile source, or the repository name. */
280
282
  projectSource: string;
281
283
  host: string;
284
+ /** The machine the person sits at: `host`, or the one that ran `vdx ai@<host>`. */
285
+ seat: string;
282
286
  profilePath: string | null;
283
287
  command: string;
284
288
  /** agent.args plus the args of every matched `when` rule — what a running agent must carry. */
@@ -300,6 +304,7 @@ export function planLaunch(input: {
300
304
  project: string;
301
305
  projectSource?: string;
302
306
  host: string;
307
+ seat?: string;
303
308
  }): LaunchPlan {
304
309
  const { environment, agent, projectRoot } = input;
305
310
  const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
@@ -317,6 +322,7 @@ export function planLaunch(input: {
317
322
  project: input.project,
318
323
  projectSource: input.projectSource ?? 'repository name',
319
324
  host: input.host,
325
+ seat: input.seat ?? input.host,
320
326
  profilePath: input.profilePath,
321
327
  command: agent.command,
322
328
  args,
@@ -535,6 +541,16 @@ export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
535
541
  return label || os.hostname().split('.')[0]!;
536
542
  }
537
543
 
544
+ /** The machine the person sits at: the one whose `vdx ai@<host>` started this vdx, else this one. */
545
+ export function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
546
+ return env[SEAT_ENV_VAR]?.trim() || hostLabel(env);
547
+ }
548
+
549
+ /** Machine labels compare without case: `M3` in the profile is `m3` in a transcript. */
550
+ export function sameMachine(a: string | null, b: string | null): boolean {
551
+ return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
552
+ }
553
+
538
554
  export function onPath(bin: string, env: NodeJS.ProcessEnv = process.env): boolean {
539
555
  for (const dir of (env['PATH'] ?? '').split(path.delimiter)) {
540
556
  if (!dir) continue;
@@ -895,9 +911,12 @@ export function remotePathWord(abs: string, home: string): string {
895
911
  * The ssh argv for `vdx ai@<host>`: the same `vdx ai` on the host, in its own
896
912
  * tmux there. The path goes first: an older vdx hands the token after a flag it
897
913
  * does not know to that flag. A different vdx version there is named, not fatal.
914
+ * `from` — the machine the person sits at, so the vdx there speaks from it; an
915
+ * older vdx leaves the variable alone.
898
916
  */
899
917
  export function remoteAiArgs(input: {
900
918
  host: string;
919
+ from: string;
901
920
  projectPath: string;
902
921
  home: string;
903
922
  version: string;
@@ -916,6 +935,7 @@ export function remoteAiArgs(input: {
916
935
  const script =
917
936
  `v=$(vdx --version 2>/dev/null) || v='older than 0.13.1'; ` +
918
937
  `[ "$v" = ${v} ] || echo "note: vdx on ${host} is $v, here "${v} >&2; ` +
938
+ `export ${SEAT_ENV_VAR}=${shellQuote(input.from)}; ` +
919
939
  `exec vdx ai ${[remotePathWord(input.projectPath, input.home), ...flags].join(' ')}`;
920
940
  return [input.tty ? '-t' : '-T', host, script];
921
941
  }
@@ -975,10 +995,11 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
975
995
  }
976
996
  }
977
997
 
978
- export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string = deps.version ?? ''): number {
998
+ /** `from` — where the person sits; a vdx that `vdx ai@<host>` started passes its own on. */
999
+ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: string = seatLabel(deps.env)): number {
979
1000
  const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
980
1001
  const tty = deps.interactive && !opts.detach && !opts.dryRun;
981
- const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
1002
+ const args = remoteAiArgs({ host, from, projectPath, home: deps.home, version: deps.version ?? '', opts, tty });
982
1003
  deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`);
983
1004
  const res = (deps.ssh ?? runSsh)(args, false);
984
1005
  if (res === null) {
@@ -1051,9 +1072,12 @@ const resumeId = (id: string) => ['--resume', id];
1051
1072
  export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
1052
1073
  if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1053
1074
  const host = plan.host;
1054
- const same = (a: string | null, b: string | null) => (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
1075
+ const same = sameMachine;
1055
1076
  const all = plan.resumeArgs.length === 0 ? [] : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
1056
1077
  const root = shellQuote(plan.projectRoot);
1078
+ // Said from where the person sits: under `vdx ai@<host>` that is not this machine.
1079
+ const atSeat = same(plan.seat, host);
1080
+ const on = (m: string) => (same(m, plan.seat) ? (atSeat ? 'here' : `on ${m}, where you are,`) : `on ${m}`);
1057
1081
 
1058
1082
  // Who runs what right now: this machine from its session files, the others over ssh.
1059
1083
  const reach = new Map<string, MachineReport | null>();
@@ -1064,9 +1088,18 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1064
1088
  const elsewhere = [...reach].flatMap(([m, r]) =>
1065
1089
  (r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
1066
1090
  );
1091
+ // The project's path on the machine `m`, by its home: the person types it there.
1092
+ const rootOn = (m: string) => {
1093
+ const theirs = [...reach].find(([k]) => same(k, m))?.[1]?.home;
1094
+ const rel = path.relative(deps.home, plan.projectRoot);
1095
+ return theirs && !rel.startsWith('..') && !path.isAbsolute(rel) ? shellQuote(path.join(theirs, rel)) : root;
1096
+ };
1097
+ // `vdx ai` attaches to an agent in tmux; one in a terminal of its own has no command to reach it.
1098
+ const reachAgent = (m: string, l: LiveSession) =>
1099
+ !l.tmux ? '' : same(m, plan.seat) ? ` — attach: vdx ai ${rootOn(m)}` : ` — there: vdx ai@${m} ${root}`;
1067
1100
  const fresh = (lines: string[]): ConversationChoice => {
1068
1101
  if (elsewhere.length === 0) return { args: [], lines };
1069
- const agents = elsewhere.map(({ m, l }) => `an agent of this project is ${describeLive(m, l, false)} — there: vdx ai@${m} ${root}`);
1102
+ const agents = elsewhere.map(({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`);
1070
1103
  return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
1071
1104
  };
1072
1105
  if (plan.resumeArgs.length === 0) return fresh(silent);
@@ -1086,7 +1119,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1086
1119
  (a, b) => b.updated.getTime() - a.updated.getTime(),
1087
1120
  );
1088
1121
  const show = (c: Conversation) => describeConversation(c, machine(c));
1089
- const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(l.machine, l.session, l.here));
1122
+ const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(on(l.machine), l.session));
1090
1123
 
1091
1124
  const take = (c: Conversation): ConversationChoice => {
1092
1125
  const m = machine(c);
@@ -1094,7 +1127,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1094
1127
  if (here) {
1095
1128
  return {
1096
1129
  args: [],
1097
- askHere: { id: c.id, why: `It runs here right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
1130
+ askHere: { id: c.id, why: `It runs ${on(host)} right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
1098
1131
  lines: [`chosen: ${show(c)}`],
1099
1132
  };
1100
1133
  }
@@ -1102,7 +1135,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1102
1135
  if (m) return { args: [], remote: { host: m, id: c.id, reachable: reach.get(m) != null }, lines: [`continuing on ${m}: ${show(c)}`] };
1103
1136
  return {
1104
1137
  args: [],
1105
- askHere: { id: c.id, why: 'Its machine is unknown (never typed in here); if it runs there, both machines will write to it' },
1138
+ askHere: { id: c.id, why: `Its machine is unknown (never typed in ${on(host)}); if it runs there, both machines will write to it` },
1106
1139
  lines: [`chosen: ${show(c)}`],
1107
1140
  };
1108
1141
  };
@@ -1115,7 +1148,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1115
1148
  if (canAsk && deps.interactive) {
1116
1149
  deps.log('conversations of this project:');
1117
1150
  found.forEach((c, i) => {
1118
- const marks = [i === 0 && 'newest', c === firstIdle && 'this machine', !machine(c) && 'machine unknown', ...running(c)];
1151
+ const marks = [i === 0 && 'newest', c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`), !machine(c) && 'machine unknown', ...running(c)];
1119
1152
  const m = marks.filter(Boolean);
1120
1153
  deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
1121
1154
  });
@@ -1142,16 +1175,17 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1142
1175
  `${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
1143
1176
  (now.length ? `, ${now.join(', ')}` : '');
1144
1177
  if (runsHere(c)) return `note: ${which} — not taken: it would have two sessions`;
1178
+ if (m && same(m, plan.seat)) return `note: ${m} has ${which} — continue it where you are: vdx ai ${rootOn(m)} --conversation ${c.id}`;
1145
1179
  return m
1146
1180
  ? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
1147
- : `note: ${which} is of an unknown machine (never typed in here) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1181
+ : `note: ${which} is of an unknown machine (never typed in ${on(host)}) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1148
1182
  });
1149
1183
  notes.push(...silent);
1150
1184
  if (firstIdle) {
1151
1185
  const chosen = take(firstIdle);
1152
1186
  return { ...chosen, lines: [...chosen.lines, ...notes] };
1153
1187
  }
1154
- return fresh(['no idle conversation of this machine — starting a new one', ...notes]);
1188
+ return fresh([`no idle conversation ${atSeat ? 'of this machine' : `on ${host}`} — starting a new one`, ...notes]);
1155
1189
  }
1156
1190
 
1157
1191
  interface Prepared {
@@ -1211,6 +1245,7 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1211
1245
  project: named.name,
1212
1246
  projectSource: named.source,
1213
1247
  host: hostLabel(deps.env),
1248
+ seat: seatLabel(deps.env),
1214
1249
  });
1215
1250
  } catch (e: any) {
1216
1251
  log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
@@ -1281,6 +1316,12 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1281
1316
  if (typeof prepared === 'number') return prepared;
1282
1317
  const { projectRoot, environment, profileDir } = prepared;
1283
1318
  let { plan } = prepared;
1319
+ // The agent started here runs on this machine: it does not inherit where the person sat.
1320
+ delete deps.env[SEAT_ENV_VAR];
1321
+ // Under `vdx ai@<host>` the person is not here: this machine goes by its name.
1322
+ const atSeat = sameMachine(plan.seat, plan.host);
1323
+ const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
1324
+ const vdxAiHere = atSeat ? 'vdx ai' : `vdx ai@${plan.host}`;
1284
1325
  if (opts.conversation && !isClaude(plan.command)) {
1285
1326
  log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
1286
1327
  return EXIT_USAGE;
@@ -1325,7 +1366,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1325
1366
  for (const l of choice?.lines ?? []) log(l);
1326
1367
  if (choice?.abort) return EXIT_LAUNCH_FAILED;
1327
1368
  if (choice?.elsewhere && !current) {
1328
- const answer = deps.interactive ? deps.ask(`Start a new conversation here, on ${plan.host}, anyway? [y/N]: `) : null;
1369
+ const answer = deps.interactive ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
1329
1370
  if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1330
1371
  log('nothing started');
1331
1372
  return EXIT_LAUNCH_FAILED;
@@ -1335,17 +1376,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1335
1376
  let askHere = choice?.askHere;
1336
1377
  if (choice?.remote) {
1337
1378
  const { host, id, reachable } = choice.remote;
1338
- if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps);
1379
+ if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps, plan.seat);
1339
1380
  log(`✗ ${host} does not answer over ssh — its conversation ${id.slice(0, 8)} cannot be continued there`);
1340
1381
  askHere = { id, why: `If it still runs on ${host}, both machines will write to it` };
1341
1382
  }
1342
1383
  if (askHere) {
1343
1384
  const { id, why } = askHere;
1344
- const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)} here, on ${plan.host}? ${why}. [y/N]: `) : null;
1385
+ const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)} ${here}? ${why}. [y/N]: `) : null;
1345
1386
  if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1346
1387
  log(
1347
- `nothing started. Here: vdx ai ${shellQuote(projectRoot)} --conversation ${id}` +
1348
- ` · a new conversation: vdx ai --new ${shellQuote(projectRoot)}`,
1388
+ `nothing started. ${atSeat ? 'Here' : `On ${plan.host}`}: ${vdxAiHere} ${shellQuote(projectRoot)} --conversation ${id}` +
1389
+ ` · a new conversation: ${vdxAiHere} --new ${shellQuote(projectRoot)}`,
1349
1390
  );
1350
1391
  return EXIT_LAUNCH_FAILED;
1351
1392
  }
@@ -314,10 +314,13 @@ export function parseMachineReport(text: string): MachineReport {
314
314
  return { home: text.match(/^home=(.+)$/m)?.[1] ?? null, sessions: parseLiveSessions(text) };
315
315
  }
316
316
 
317
- /** "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for a person choosing. */
318
- export function describeLive(machine: string, live: LiveSession, here: boolean): string {
317
+ /**
318
+ * "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for
319
+ * a person choosing; `place` is the machine as they see it: "here", "on m3".
320
+ */
321
+ export function describeLive(place: string, live: LiveSession): string {
319
322
  const where = live.tmux ? live.tmux.replace(/:.*$/, '') : `pid ${live.pid}`;
320
- return `running ${here ? 'here' : `on ${machine}`} now (${where}${live.status ? `, ${live.status}` : ''})`;
323
+ return `running ${place} now (${where}${live.status ? `, ${live.status}` : ''})`;
321
324
  }
322
325
 
323
326
  /** "3 min ago", "yesterday 14:05", "2026-09-30 14:05" — for a person choosing. */