@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 +1 -1
- package/rubric/vdx-rubric.yaml +1 -1
- package/src/ai.ts +65 -21
- package/src/conversations.ts +42 -0
- package/src/index.ts +7 -5
- package/src/init.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/rubric/vdx-rubric.yaml
CHANGED
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
|
-
|
|
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):
|
|
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 ?
|
|
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
|
-
*
|
|
980
|
-
*
|
|
981
|
-
* its own session files. A running
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
1000
|
-
for (const
|
|
1001
|
-
if (
|
|
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,
|
|
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))
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/conversations.ts
CHANGED
|
@@ -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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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'],
|