@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 +1 -1
- package/src/ai.ts +56 -15
- package/src/conversations.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.22.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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([
|
|
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,
|
|
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)}
|
|
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:
|
|
1348
|
-
` · a new conversation:
|
|
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
|
}
|
package/src/conversations.ts
CHANGED
|
@@ -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
|
-
/**
|
|
318
|
-
|
|
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 ${
|
|
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. */
|