@vodmal/vdx-cli 0.17.0 → 0.19.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/README.md CHANGED
@@ -48,7 +48,8 @@ vdx publish <patch|minor|major> [--dry-run] [--force]
48
48
  vdx doctor [--format=ansi|markdown|json]
49
49
 
50
50
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
51
- vdx ai[@host] [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
51
+ vdx ai[@host] [project-path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
52
+ vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
52
53
 
53
54
  # What a command does and takes; nothing runs (ai: also where launch flags live)
54
55
  vdx <command> --help
@@ -73,7 +74,8 @@ vdx test # → mise run test (which calls `vitest run` or whatever wa
73
74
  # 3. Ship a new minor release of a Node lib
74
75
  vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no push)
75
76
 
76
- # 4. Start the agent here, continuing its last conversation (--new: a new one);
77
+ # 4. Start the agent here, continuing its last conversation (--new: a new one;
78
+ # another machine's conversation is continued on that machine);
77
79
  # re-run to attach, --restart to apply a changed profile
78
80
  vdx ai
79
81
 
@@ -90,6 +92,11 @@ name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machin
90
92
  profile and tmux, the path taken under its home directory; `--detach` only
91
93
  starts it. The machine's own label (`$VDX_HOST`) runs here. Where git knows no
92
94
  commit author, `vdx ai` proposes one (Enter writes it into `.git/config`).
95
+ For Claude Code `vdx ai` picks the conversation: each one is marked with the
96
+ machine it was last started on (`vdx ai --check`, run by the plugin's hook, writes
97
+ the label in), the running ones are asked of the other machines over ssh, and
98
+ another machine's conversation is continued there. When the choice is not plain
99
+ it lists them — Enter takes the newest; without a terminal, this machine's.
93
100
 
94
101
  Every command takes `--help` (`-h`) and runs nothing with it. An unknown option,
95
102
  a stray argument or a value flag without its value is refused with exit 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
package/src/ai.ts CHANGED
@@ -8,6 +8,20 @@ import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './
8
8
  import { loadManifest } from './manifest.ts';
9
9
  import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
10
10
  import type { Predicate } from './rubric.ts';
11
+ import {
12
+ type Conversation,
13
+ describeConversation,
14
+ describeLive,
15
+ LIVE_SESSIONS_SCRIPT,
16
+ type LiveSession,
17
+ latestPerMachine,
18
+ listConversations,
19
+ liveConversationId,
20
+ liveSessionsHere,
21
+ machineLine,
22
+ machineOf,
23
+ parseLiveSessions,
24
+ } from './conversations.ts';
11
25
 
12
26
  /**
13
27
  * `vdx ai` — start the person's agent in a project, with the flags their
@@ -17,7 +31,9 @@ import type { Predicate } from './rubric.ts';
17
31
  *
18
32
  * A start continues the project's last conversation (the profile's
19
33
  * `resume_args`) — after a reboot `vdx ai` brings the agent back where it was;
20
- * `--new` starts a new conversation.
34
+ * `--new` starts a new conversation. For Claude Code vdx picks the
35
+ * conversation itself, and continues another machine's on that machine
36
+ * (see chooseConversation).
21
37
  *
22
38
  * The profile is personal (which agent, which permissions), so it is never
23
39
  * bundled into the CLI: it is read from `$VDX_ENVIRONMENT` or
@@ -581,6 +597,8 @@ export interface AiOptions {
581
597
  restart: boolean;
582
598
  /** `--new`: start a new conversation — leave out the profile's resume args. */
583
599
  fresh: boolean;
600
+ /** `--conversation <id>`: continue this Claude Code conversation. */
601
+ conversation?: string;
584
602
  detach: boolean;
585
603
  dryRun: boolean;
586
604
  }
@@ -600,6 +618,18 @@ export interface AiDeps {
600
618
  confirmTimeoutMs: number;
601
619
  /** One line from the person at the terminal, or null when there is none. */
602
620
  ask: (prompt: string) => string | null;
621
+ /** This vdx's version, named to the vdx on another machine. */
622
+ version?: string;
623
+ /** Run ssh — capturing its output, or on this terminal; null when ssh is not on PATH. */
624
+ ssh?: (args: string[], capture: boolean) => { status: number; stdout: string } | null;
625
+ /** Claude Code processes alive on this machine; read from its session files when not given. */
626
+ liveHere?: () => LiveSession[];
627
+ }
628
+
629
+ export function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
630
+ const res = spawnSync('ssh', args, { stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'inherit', encoding: 'utf8' });
631
+ if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null;
632
+ return { status: res.status ?? 1, stdout: res.stdout ?? '' };
603
633
  }
604
634
 
605
635
  export function sleepSync(ms: number): void {
@@ -643,6 +673,7 @@ export function defaultDeps(): AiDeps {
643
673
  sleep: sleepSync,
644
674
  confirmTimeoutMs: 30_000,
645
675
  ask: askLine,
676
+ ssh: runSsh,
646
677
  };
647
678
  }
648
679
 
@@ -826,6 +857,7 @@ export function remoteAiArgs(input: {
826
857
  opts.restart && '--restart',
827
858
  opts.detach && '--detach',
828
859
  opts.dryRun && '--dry-run',
860
+ opts.conversation && `--conversation ${shellQuote(opts.conversation)}`,
829
861
  ].filter((f): f is string => Boolean(f));
830
862
  const v = shellQuote(version);
831
863
  const script =
@@ -890,28 +922,176 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
890
922
  }
891
923
  }
892
924
 
893
- export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string): number {
925
+ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string = deps.version ?? ''): number {
894
926
  const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
895
927
  const tty = deps.interactive && !opts.detach && !opts.dryRun;
896
928
  const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
897
- deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}`);
898
- const res = spawnSync('ssh', args, { stdio: 'inherit' });
899
- if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
929
+ deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`);
930
+ const res = (deps.ssh ?? runSsh)(args, false);
931
+ if (res === null) {
900
932
  deps.log('vdx ai: `ssh` not found on PATH');
901
933
  return 127;
902
934
  }
903
935
  if (opts.detach && res.status === EXIT_OK) {
904
936
  deps.log(`attach from here: vdx ai@${host} ${shellQuote(projectPath)}`);
905
937
  }
906
- return res.status ?? 1;
938
+ return res.status;
907
939
  }
908
940
 
909
- export function runAi(opts: AiOptions, deps: AiDeps): number {
910
- const { log, tmux } = deps;
941
+ /**
942
+ * The Claude Code sessions alive on `host` right now; null when it does not
943
+ * answer over ssh (BatchMode: a password prompt counts as no answer).
944
+ */
945
+ export function askMachine(host: string, deps: AiDeps): LiveSession[] | null {
946
+ const res = (deps.ssh ?? runSsh)(
947
+ ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, `sh -c ${shellQuote(LIVE_SESSIONS_SCRIPT)}`],
948
+ true,
949
+ );
950
+ return res && res.status === 0 ? parseLiveSessions(res.stdout) : null;
951
+ }
952
+
953
+ /** What a start continues: resume args here, or a conversation on another machine. */
954
+ export interface ConversationChoice {
955
+ args: string[];
956
+ /** Continue it on that machine (`vdx ai@<host>`); `reachable` — it answered over ssh just now. */
957
+ remote?: { host: string; id: string; reachable: boolean };
958
+ /** Continue it here only after a yes: another machine's that cannot be asked, or one running here already. */
959
+ askHere?: { id: string; why: string };
960
+ /** For the person: what was found and what is taken. */
961
+ lines: string[];
962
+ }
963
+
964
+ export function isClaude(command: string): boolean {
965
+ return path.basename(command) === 'claude';
966
+ }
967
+
968
+ const resumeId = (id: string) => ['--resume', id];
969
+
970
+ /**
971
+ * Which conversation a start continues. Other agents keep the profile's
972
+ * resume_args. For Claude Code vdx reads the project's conversations and the
973
+ * machine each belongs to (conversations.ts): `--continue` would take the
974
+ * newest one in the folder, and with the folder synced between machines that
975
+ * can be a conversation another machine is still writing to.
976
+ *
977
+ * Every other machine this project's conversations name is asked over ssh
978
+ * which of them run there right now; this machine's running ones come from
979
+ * its own session files. A running conversation is listed even when it is not
980
+ * its machine's newest.
981
+ *
982
+ * Another machine's conversation is continued on that machine, so two
983
+ * machines never write to one conversation; one whose machine is unknown, or
984
+ * cannot be reached, or that already runs here, is continued here only after a
985
+ * yes. When the choice is not plain, a person at a terminal picks — Enter
986
+ * takes the newest overall; without a terminal it is this machine's newest
987
+ * idle one, never another's.
988
+ */
989
+ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
990
+ if (plan.resumeArgs.length === 0 || !isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
991
+ const host = plan.host;
992
+ const same = (a: string | null, b: string | null) => (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
993
+ const all = listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
994
+ if (all.length === 0) return { args: [], lines: ['no conversation of this project yet — starting a new one'] };
995
+
996
+ // Who runs what right now: this machine from its session files, the others over ssh.
997
+ const reach = new Map<string, LiveSession[] | null>();
998
+ for (const c of all) {
999
+ if (c.machine && !same(c.machine, host) && ![...reach.keys()].some((m) => same(m, c.machine))) {
1000
+ reach.set(c.machine, askMachine(c.machine, deps));
1001
+ }
1002
+ }
1003
+ const live = new Map<string, { machine: string; session: LiveSession; here: boolean }[]>();
1004
+ const addLive = (machine: string, session: LiveSession, here: boolean) =>
1005
+ live.set(session.conversation, [...(live.get(session.conversation) ?? []), { machine, session, here }]);
1006
+ for (const l of (deps.liveHere ?? (() => liveSessionsHere(deps.env, deps.home)))()) addLive(host, l, true);
1007
+ for (const [m, sessions] of reach) for (const l of sessions ?? []) addLive(m, l, false);
1008
+
1009
+ const machine = (c: Conversation) => live.get(c.id)?.find((l) => !l.here)?.machine ?? machineOf(c, host);
1010
+ const runsHere = (c: Conversation) => live.get(c.id)?.find((l) => l.here);
1011
+ const isOurs = (c: Conversation) => same(machine(c), host);
1012
+ const idleHere = (c: Conversation) => isOurs(c) && !runsHere(c);
1013
+ const found = [...new Set([...latestPerMachine(all, machine), ...all.filter((c) => live.has(c.id))])].sort(
1014
+ (a, b) => b.updated.getTime() - a.updated.getTime(),
1015
+ );
1016
+ const show = (c: Conversation) => describeConversation(c, machine(c));
1017
+ const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(l.machine, l.session, l.here));
1018
+
1019
+ const take = (c: Conversation): ConversationChoice => {
1020
+ const m = machine(c);
1021
+ const here = runsHere(c);
1022
+ if (here) {
1023
+ return {
1024
+ args: [],
1025
+ askHere: { id: c.id, why: `It runs here right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
1026
+ lines: [`chosen: ${show(c)}`],
1027
+ };
1028
+ }
1029
+ if (isOurs(c)) return { args: resumeId(c.id), lines: [`continuing ${show(c)}`] };
1030
+ if (m) return { args: [], remote: { host: m, id: c.id, reachable: reach.get(m) != null }, lines: [`continuing on ${m}: ${show(c)}`] };
1031
+ return {
1032
+ args: [],
1033
+ askHere: { id: c.id, why: 'Its machine is unknown (never typed in here); if it runs there, both machines will write to it' },
1034
+ lines: [`chosen: ${show(c)}`],
1035
+ };
1036
+ };
1037
+ if (found.every(idleHere)) return take(found[0]!);
1038
+
1039
+ const firstIdle = found.find(idleHere);
1040
+ if (canAsk && deps.interactive) {
1041
+ deps.log('conversations of this project:');
1042
+ found.forEach((c, i) => {
1043
+ const marks = [i === 0 && 'newest', c === firstIdle && 'this machine', !machine(c) && 'machine unknown', ...running(c)];
1044
+ const m = marks.filter(Boolean);
1045
+ deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
1046
+ });
1047
+ deps.log(' n) a new conversation');
1048
+ for (let tries = 0; tries < 2; tries++) {
1049
+ const answer = deps.ask(`Which one to continue? [1]: `);
1050
+ if (answer === null) break;
1051
+ const a = answer.trim();
1052
+ if (/^n(ew)?$/i.test(a)) return { args: [], lines: ['a new conversation'] };
1053
+ const c = a === '' ? found[0] : /^\d+$/.test(a) ? found[Number(a) - 1] : undefined;
1054
+ if (c) return take(c);
1055
+ deps.log(` 1–${found.length}, or n`);
1056
+ }
1057
+ }
1058
+ const root = shellQuote(plan.projectRoot);
1059
+ const notes = found
1060
+ .filter((c) => !idleHere(c))
1061
+ .map((c) => {
1062
+ const m = machine(c);
1063
+ const now = running(c);
1064
+ const which =
1065
+ `${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
1066
+ (now.length ? `, ${now.join(', ')}` : '');
1067
+ if (runsHere(c)) return `note: ${which} — not taken: it would have two sessions`;
1068
+ return m
1069
+ ? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
1070
+ : `note: ${which} is of an unknown machine (never typed in here) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1071
+ });
1072
+ for (const [m, sessions] of reach) if (sessions === null) notes.push(`note: ${m} does not answer over ssh — what runs there is not known`);
1073
+ if (firstIdle) {
1074
+ const chosen = take(firstIdle);
1075
+ return { ...chosen, lines: [...chosen.lines, ...notes] };
1076
+ }
1077
+ return { args: [], lines: ['no idle conversation of this machine — starting a new one', ...notes] };
1078
+ }
1079
+
1080
+ interface Prepared {
1081
+ projectRoot: string;
1082
+ environment: Environment;
1083
+ profilePath: string | null;
1084
+ profileDir: string;
1085
+ plan: LaunchPlan;
1086
+ }
911
1087
 
912
- const projectRoot = resolveProjectRoot(opts.path);
1088
+ /** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
1089
+ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1090
+ const { log } = deps;
1091
+
1092
+ const projectRoot = resolveProjectRoot(pathArg);
913
1093
  if (!projectRoot) {
914
- log(`vdx ai: ${path.resolve(opts.path)} is not a directory`);
1094
+ log(`vdx ai: ${path.resolve(pathArg)} is not a directory`);
915
1095
  return EXIT_USAGE;
916
1096
  }
917
1097
 
@@ -959,6 +1139,75 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
959
1139
  log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
960
1140
  return EXIT_USAGE;
961
1141
  }
1142
+ return { projectRoot, environment, profilePath, profileDir, plan };
1143
+ }
1144
+
1145
+ /** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
1146
+ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): ProcInfo | null {
1147
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
1148
+ let cur = byPid.get(pid);
1149
+ for (let hops = 0; cur && cur.ppid > 1 && hops < 64; hops++) {
1150
+ cur = byPid.get(cur.ppid);
1151
+ if (cur && isAgentProcess(cur, command)) return cur;
1152
+ }
1153
+ return null;
1154
+ }
1155
+
1156
+ /**
1157
+ * `vdx ai --check`: what an agent should know about how it is launched here,
1158
+ * and whether the session it runs in carries the profile's flags. Prints
1159
+ * nothing without a profile — on such a machine `vdx ai` decides nothing.
1160
+ */
1161
+ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
1162
+ if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
1163
+ const prepared = prepare(pathArg, deps);
1164
+ if (typeof prepared === 'number') return prepared;
1165
+ const { projectRoot, profilePath, plan } = prepared;
1166
+
1167
+ const lines = [
1168
+ `${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
1169
+ `${profilePath} — agent.args for every project, agent.when[] for a class of projects. ` +
1170
+ `Hand the user \`vdx ai\`, not \`${plan.command} --<flag>\`; a flag a class of projects needs is a ` +
1171
+ `condition in the profile, not an instruction to type. A project's agent on another machine: ` +
1172
+ `\`vdx ai@<machine>\`. More: \`vdx ai --help\`.`,
1173
+ ];
1174
+ const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1175
+ if (!own) {
1176
+ lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
1177
+ deps.out(lines.join('\n') + '\n');
1178
+ return EXIT_OK;
1179
+ }
1180
+ const missing = missingArgs(own.args, plan.args);
1181
+ const why = plan.matched.length ? ` (profile conditions met here: ${plan.matched.join(', ')})` : '';
1182
+ if (missing.length === 0) {
1183
+ lines.push(`✓ this session carries the profile's flags${why}.`);
1184
+ deps.out(lines.join('\n') + '\n');
1185
+ return EXIT_OK;
1186
+ }
1187
+ const root = shellQuote(projectRoot);
1188
+ lines.push(
1189
+ `✗ this session runs without ${missing.join(' ')}${why}. Hand the user the fix — ` +
1190
+ (deps.env['TMUX']
1191
+ ? `\`vdx ai --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1192
+ (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1193
+ `. Do not run it yourself: it ends this session.`
1194
+ : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`vdx ai ${root}\`.`),
1195
+ );
1196
+ deps.out(lines.join('\n') + '\n');
1197
+ return EXIT_DRIFT;
1198
+ }
1199
+
1200
+ export function runAi(opts: AiOptions, deps: AiDeps): number {
1201
+ const { log, tmux } = deps;
1202
+
1203
+ const prepared = prepare(opts.path, deps);
1204
+ if (typeof prepared === 'number') return prepared;
1205
+ const { projectRoot, environment, profileDir } = prepared;
1206
+ let { plan } = prepared;
1207
+ if (opts.conversation && !isClaude(plan.command)) {
1208
+ log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
1209
+ return EXIT_USAGE;
1210
+ }
962
1211
  // A start continues the last conversation unless asked for a new one.
963
1212
  if (opts.fresh) plan = { ...plan, resumeArgs: [] };
964
1213
 
@@ -972,13 +1221,49 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
972
1221
  multiplexer === 'tmux'
973
1222
  ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
974
1223
  : [];
1224
+ const current = running[0];
1225
+
1226
+ // What a start continues. A restart keeps the conversation the agent is in.
1227
+ let choice: ConversationChoice | null = null;
1228
+ if (!current || opts.restart) {
1229
+ if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1230
+ else if (current && plan.resumeArgs.length > 0 && isClaude(plan.command)) {
1231
+ const live = liveConversationId(deps.env, deps.home, current.proc.pid);
1232
+ choice = live
1233
+ ? { args: resumeId(live), lines: [`the agent is in conversation ${live.slice(0, 8)} — resuming it`] }
1234
+ : chooseConversation(plan, deps, false);
1235
+ } else choice = chooseConversation(plan, deps, !opts.dryRun);
1236
+ plan = { ...plan, resumeArgs: choice.args };
1237
+ }
975
1238
 
976
1239
  const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
977
1240
  if (opts.dryRun) {
978
1241
  deps.out(renderPlan(plan, multiplexer, running));
1242
+ for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
979
1243
  ensureAuthor(projectRoot, poolDirs, deps, true);
980
1244
  return EXIT_OK;
981
1245
  }
1246
+ for (const l of choice?.lines ?? []) log(l);
1247
+
1248
+ let askHere = choice?.askHere;
1249
+ if (choice?.remote) {
1250
+ const { host, id, reachable } = choice.remote;
1251
+ if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps);
1252
+ log(`✗ ${host} does not answer over ssh — its conversation ${id.slice(0, 8)} cannot be continued there`);
1253
+ askHere = { id, why: `If it still runs on ${host}, both machines will write to it` };
1254
+ }
1255
+ if (askHere) {
1256
+ const { id, why } = askHere;
1257
+ const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)} here, on ${plan.host}? ${why}. [y/N]: `) : null;
1258
+ if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1259
+ log(
1260
+ `nothing started. Here: vdx ai ${shellQuote(projectRoot)} --conversation ${id}` +
1261
+ ` · a new conversation: vdx ai --new ${shellQuote(projectRoot)}`,
1262
+ );
1263
+ return EXIT_LAUNCH_FAILED;
1264
+ }
1265
+ plan = { ...plan, resumeArgs: resumeId(id) };
1266
+ }
982
1267
  ensureAuthor(projectRoot, poolDirs, deps, false);
983
1268
 
984
1269
  if (multiplexer === 'none') {
@@ -1005,7 +1290,6 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1005
1290
  return res.status ?? 1;
1006
1291
  }
1007
1292
 
1008
- const current = running[0];
1009
1293
  if (running.length > 1) {
1010
1294
  log(
1011
1295
  `note: ${running.length} panes run ${plan.command} in this project: ` +
@@ -1015,13 +1299,22 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1015
1299
 
1016
1300
  if (current && !opts.restart) {
1017
1301
  if (opts.fresh) log(`note: ${current.pane.session} already runs ${plan.command} — --new replaces it only with --restart`);
1302
+ if (opts.conversation) {
1303
+ const live = liveConversationId(deps.env, deps.home, current.proc.pid);
1304
+ if (live !== opts.conversation) {
1305
+ log(
1306
+ `note: ${current.pane.session} is in ${live ? `conversation ${live.slice(0, 8)}` : 'another conversation'} — ` +
1307
+ `to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
1308
+ );
1309
+ }
1310
+ }
1018
1311
  const missing = missingArgs(current.proc.args, plan.args);
1019
1312
  if (missing.length > 0) {
1020
1313
  log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
1021
1314
  log(
1022
1315
  ` to apply the profile: vdx ai --restart${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1023
1316
  (plan.resumeArgs.length
1024
- ? ` (stops the running agent, then resumes the conversation with ${plan.resumeArgs.join(' ')})`
1317
+ ? ` (stops the running agent, then resumes its conversation)`
1025
1318
  : opts.fresh
1026
1319
  ? ' (stops the running agent and starts a new conversation)'
1027
1320
  : ' (stops the running agent; the conversation is not resumed — the profile has no resume_args)'),
@@ -0,0 +1,299 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ /**
5
+ * Claude Code conversations of a project, and the machine each one was last
6
+ * started on.
7
+ *
8
+ * Claude Code keeps a conversation as `<config>/projects/<dir>/<id>.jsonl`,
9
+ * where `<dir>` is the working directory with every character other than a
10
+ * letter or a digit turned into `-`. That folder may be synced between
11
+ * machines (it is on the owner's: Syncthing), so the newest conversation of a
12
+ * project can be another machine's — and `claude --continue` would take it
13
+ * there, while that machine still writes to it.
14
+ *
15
+ * The transcript has no field for the machine. The machine comes from vdx
16
+ * itself: `vdx ai --check`, run by the vdx plugin's SessionStart hook, names
17
+ * it, and Claude Code keeps the hook's text in the transcript — on every
18
+ * start, resume, /clear and compact. The last such line is where the
19
+ * conversation was last started. Derived from the synced transcript, never
20
+ * stored apart from it.
21
+ *
22
+ * A conversation from before vdx named the machine has no such line. For it
23
+ * the machine's own `<config>/history.jsonl` answers half the question: it
24
+ * records the conversation of every prompt typed here, and it is not synced
25
+ * (the owner's Syncthing ignores it; unsynced setups have nothing to sync). A
26
+ * conversation typed in here is this machine's; one never typed in here is
27
+ * another machine's — which one, nothing says.
28
+ */
29
+
30
+ /** The opening of `vdx ai --check`'s text; the label is an ssh destination from the other machines. */
31
+ export const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
32
+ const MACHINE_LINE_RE = /^vdx ai: this machine is `([^`]+)`/m;
33
+ /** A label read from a synced file goes to ssh: it must not be able to read as an option. */
34
+ const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
35
+ const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
36
+
37
+ export function machineLine(label: string): string {
38
+ return `${MACHINE_LINE_PREFIX} \`${label}\``;
39
+ }
40
+
41
+ export function isConversationId(s: string): boolean {
42
+ return ID_RE.test(s);
43
+ }
44
+
45
+ export interface Conversation {
46
+ id: string;
47
+ /** Last write — the file's mtime, which Syncthing carries over from the writer. */
48
+ updated: Date;
49
+ /** Where it was last started; null for a conversation from before vdx named the machine. */
50
+ machine: string | null;
51
+ /** A prompt was typed in it on this machine (history.jsonl). */
52
+ typedHere: boolean;
53
+ /** Claude's title for it, else the last prompt. */
54
+ title: string | null;
55
+ }
56
+
57
+ export function claudeConfigDir(env: NodeJS.ProcessEnv, home: string): string {
58
+ const dir = env['CLAUDE_CONFIG_DIR']?.trim();
59
+ return dir ? path.resolve(dir.replace(/^~(?=$|\/)/, home)) : path.join(home, '.claude');
60
+ }
61
+
62
+ export function projectDirName(projectRoot: string): string {
63
+ return projectRoot.replace(/[^A-Za-z0-9]/g, '-');
64
+ }
65
+
66
+ /** Read one transcript; null when nobody ever wrote in it — there is nothing to continue. */
67
+ export function readConversation(file: string, updated: Date): Conversation | null {
68
+ let text: string;
69
+ try {
70
+ text = fs.readFileSync(file, 'utf8');
71
+ } catch {
72
+ return null;
73
+ }
74
+ let machine: string | null = null;
75
+ let aiTitle: string | null = null;
76
+ let lastPrompt: string | null = null;
77
+ let turns = false;
78
+ for (const line of text.split('\n')) {
79
+ // Cheap filters first: transcripts run to megabytes, and only these lines matter.
80
+ const isHook = line.includes('"hook_additional_context"') && line.includes(MACHINE_LINE_PREFIX);
81
+ const isTitle = line.includes('"ai-title"') || line.includes('"last-prompt"');
82
+ if (!turns && line.includes('"type":"user"')) turns = true;
83
+ if (!isHook && !isTitle) continue;
84
+ let entry: any;
85
+ try {
86
+ entry = JSON.parse(line);
87
+ } catch {
88
+ continue;
89
+ }
90
+ if (entry?.type === 'ai-title' && typeof entry.aiTitle === 'string') aiTitle = entry.aiTitle;
91
+ else if (entry?.type === 'last-prompt' && typeof entry.lastPrompt === 'string') lastPrompt = entry.lastPrompt;
92
+ else if (entry?.type === 'attachment' && entry.attachment?.type === 'hook_additional_context') {
93
+ const content = entry.attachment.content;
94
+ for (const part of Array.isArray(content) ? content : [content]) {
95
+ const label = typeof part === 'string' ? part.match(MACHINE_LINE_RE)?.[1] : undefined;
96
+ if (label && LABEL_RE.test(label)) machine = label;
97
+ }
98
+ }
99
+ }
100
+ if (!turns) return null;
101
+ const id = path.basename(file, '.jsonl');
102
+ return { id, updated, machine, typedHere: false, title: aiTitle ?? lastPrompt };
103
+ }
104
+
105
+ /** Which of `ids` had a prompt typed in them on this machine. */
106
+ export function typedHere(env: NodeJS.ProcessEnv, home: string, ids: Set<string>): Set<string> {
107
+ const found = new Set<string>();
108
+ let text: string;
109
+ try {
110
+ text = fs.readFileSync(path.join(claudeConfigDir(env, home), 'history.jsonl'), 'utf8');
111
+ } catch {
112
+ return found;
113
+ }
114
+ for (const m of text.matchAll(/"sessionId":"([0-9a-f-]{36})"/g)) {
115
+ if (ids.has(m[1]!)) found.add(m[1]!);
116
+ }
117
+ return found;
118
+ }
119
+
120
+ /** The project's conversations, newest first; at most `limit` files are read. */
121
+ export function listConversations(input: {
122
+ env: NodeJS.ProcessEnv;
123
+ home: string;
124
+ projectRoot: string;
125
+ limit?: number;
126
+ }): Conversation[] {
127
+ const roots = new Set([input.projectRoot]);
128
+ try {
129
+ roots.add(fs.realpathSync(input.projectRoot));
130
+ } catch {
131
+ /* the plain path is enough */
132
+ }
133
+ const files: { file: string; updated: Date }[] = [];
134
+ for (const root of roots) {
135
+ const dir = path.join(claudeConfigDir(input.env, input.home), 'projects', projectDirName(root));
136
+ let names: string[];
137
+ try {
138
+ names = fs.readdirSync(dir);
139
+ } catch {
140
+ continue;
141
+ }
142
+ for (const name of names) {
143
+ // `<id>.sync-conflict-….jsonl` is Syncthing's copy of a losing side, not a conversation.
144
+ if (!name.endsWith('.jsonl') || !isConversationId(name.slice(0, -'.jsonl'.length))) continue;
145
+ const file = path.join(dir, name);
146
+ try {
147
+ files.push({ file, updated: fs.statSync(file).mtime });
148
+ } catch {
149
+ /* gone since the listing */
150
+ }
151
+ }
152
+ }
153
+ files.sort((a, b) => b.updated.getTime() - a.updated.getTime());
154
+ const seen = new Set<string>();
155
+ const out: Conversation[] = [];
156
+ for (const f of files.slice(0, input.limit ?? 30)) {
157
+ const c = readConversation(f.file, f.updated);
158
+ if (c && !seen.has(c.id)) {
159
+ seen.add(c.id);
160
+ out.push(c);
161
+ }
162
+ }
163
+ const typed = typedHere(input.env, input.home, seen);
164
+ return out.map((c) => ({ ...c, typedHere: typed.has(c.id) }));
165
+ }
166
+
167
+ function sameMachine(a: string | null, b: string | null): boolean {
168
+ return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
169
+ }
170
+
171
+ /** The machine a conversation belongs to: its last start, else this one if typed in here; null — unknown. */
172
+ export function machineOf(c: Conversation, host: string): string | null {
173
+ return c.machine ?? (c.typedHere ? host : null);
174
+ }
175
+
176
+ /** The newest conversation of each machine (unknown counts as one), newest first. */
177
+ export function latestPerMachine(conversations: Conversation[], machine: (c: Conversation) => string | null): Conversation[] {
178
+ const out: Conversation[] = [];
179
+ for (const c of [...conversations].sort((a, b) => b.updated.getTime() - a.updated.getTime())) {
180
+ if (!out.some((o) => sameMachine(machine(o), machine(c)))) out.push(c);
181
+ }
182
+ return out;
183
+ }
184
+
185
+
186
+ /**
187
+ * The conversation a running Claude Code process is in, from its session file
188
+ * `<config>/sessions/<pid>.json` — it follows /clear. That folder is per
189
+ * machine, so the pid is this machine's.
190
+ */
191
+ export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: number): string | null {
192
+ try {
193
+ const data = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'));
194
+ const id = data?.sessionId;
195
+ return typeof id === 'string' && isConversationId(id) ? id : null;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ /** A Claude Code process running right now, and the conversation it is in. */
202
+ export interface LiveSession {
203
+ pid: number;
204
+ conversation: string;
205
+ /** `session:@window.%pane`, when it runs in tmux. */
206
+ tmux: string | null;
207
+ /** Claude Code's own word for it: `busy`, `waiting`, … */
208
+ status: string | null;
209
+ }
210
+
211
+ /** Session files, one JSON object per line — a file read here, or the output of LIVE_SESSIONS_SCRIPT. */
212
+ export function parseLiveSessions(text: string): LiveSession[] {
213
+ const out: LiveSession[] = [];
214
+ for (const line of text.split('\n')) {
215
+ if (!line.trim().startsWith('{')) continue;
216
+ let data: any;
217
+ try {
218
+ data = JSON.parse(line);
219
+ } catch {
220
+ continue;
221
+ }
222
+ if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId)) continue;
223
+ out.push({
224
+ pid: data.pid,
225
+ conversation: data.sessionId,
226
+ tmux: typeof data.tmux === 'string' ? data.tmux : null,
227
+ status: typeof data.status === 'string' ? data.status : null,
228
+ });
229
+ }
230
+ return out;
231
+ }
232
+
233
+ function pidAlive(pid: number): boolean {
234
+ try {
235
+ process.kill(pid, 0);
236
+ return true;
237
+ } catch (e: any) {
238
+ return e?.code === 'EPERM';
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Prints the session file of every Claude Code process alive on the machine it
244
+ * runs on, one per line — what `vdx ai` asks another machine over ssh. Claude
245
+ * removes the file of a process that ended, but not after a crash or a reboot:
246
+ * the pid is checked. `<pid>.sync-conflict-….json` is not a session file.
247
+ */
248
+ export const LIVE_SESSIONS_SCRIPT =
249
+ 'd="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions"; for f in "$d"/*.json; do ' +
250
+ 'p=${f##*/}; p=${p%.json}; case $p in ""|*[!0-9]*) continue;; esac; ' +
251
+ 'kill -0 "$p" 2>/dev/null && tr -d "\\n" < "$f" && echo; done; exit 0';
252
+
253
+ /** Claude Code processes alive on this machine. */
254
+ export function liveSessionsHere(env: NodeJS.ProcessEnv, home: string, alive: (pid: number) => boolean = pidAlive): LiveSession[] {
255
+ const dir = path.join(claudeConfigDir(env, home), 'sessions');
256
+ let names: string[];
257
+ try {
258
+ names = fs.readdirSync(dir);
259
+ } catch {
260
+ return [];
261
+ }
262
+ const lines: string[] = [];
263
+ for (const name of names) {
264
+ const pid = name.match(/^(\d+)\.json$/)?.[1];
265
+ if (!pid || !alive(Number(pid))) continue;
266
+ try {
267
+ lines.push(fs.readFileSync(path.join(dir, name), 'utf8').replace(/\n/g, ''));
268
+ } catch {
269
+ /* ended since the listing */
270
+ }
271
+ }
272
+ return parseLiveSessions(lines.join('\n'));
273
+ }
274
+
275
+ /** "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for a person choosing. */
276
+ export function describeLive(machine: string, live: LiveSession, here: boolean): string {
277
+ const where = live.tmux ? live.tmux.replace(/:.*$/, '') : `pid ${live.pid}`;
278
+ return `running ${here ? 'here' : `on ${machine}`} now (${where}${live.status ? `, ${live.status}` : ''})`;
279
+ }
280
+
281
+ /** "3 min ago", "yesterday 14:05", "2026-09-30 14:05" — for a person choosing. */
282
+ export function describeAge(when: Date, now: Date = new Date()): string {
283
+ const min = Math.round((now.getTime() - when.getTime()) / 60_000);
284
+ if (min < 1) return 'just now';
285
+ if (min < 60) return `${min} min ago`;
286
+ const hh = String(when.getHours()).padStart(2, '0');
287
+ const mm = String(when.getMinutes()).padStart(2, '0');
288
+ const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
289
+ const days = Math.round((day(now) - day(when)) / 86_400_000);
290
+ if (days === 0) return `today ${hh}:${mm}`;
291
+ if (days === 1) return `yesterday ${hh}:${mm}`;
292
+ const date = `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, '0')}-${String(when.getDate()).padStart(2, '0')}`;
293
+ return `${date} ${hh}:${mm}`;
294
+ }
295
+
296
+ export function describeConversation(c: Conversation, machine: string | null, now: Date = new Date()): string {
297
+ const title = c.title ? ` «${c.title.replace(/\s+/g, ' ').trim().slice(0, 60)}»` : '';
298
+ return `${machine ?? '?'} ${describeAge(c.updated, now)} ${c.id.slice(0, 8)}${title}`;
299
+ }
package/src/doctor.ts CHANGED
@@ -339,42 +339,45 @@ function checkClaudeCode(): CheckResult {
339
339
  };
340
340
  }
341
341
 
342
- function checkClaudeCodePlugin(): CheckResult {
343
- const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
344
- if (!fs.existsSync(settingsPath)) {
345
- return {
346
- id: 'claude-plugin',
347
- label: 'Claude Code vdx plugin',
348
- status: 'warning',
349
- message: '~/.claude/settings.json not found',
350
- remedy: 'Install Claude Code, then add vdx marketplace (see README)',
351
- };
352
- }
353
- try {
354
- const raw = fs.readFileSync(settingsPath, 'utf8');
355
- if (raw.includes('VoDmAl/vdx') || raw.includes('vdx/marketplace')) {
356
- return {
357
- id: 'claude-plugin',
358
- label: 'Claude Code vdx plugin',
359
- status: 'ok',
360
- message: 'vdx marketplace registered',
361
- };
342
+ const PLUGIN_INSTALL_HINT =
343
+ 'in Claude Code: /plugin marketplace add VoDmAl/ai-dev-plugins, then /plugin install vdx from it';
344
+
345
+ /**
346
+ * The vdx plugin as Claude Code records it: `vdx@<marketplace>` in
347
+ * plugins/installed_plugins.json, and not switched off in settings.json
348
+ * enabledPlugins. Whichever marketplace brought it — the plugin ships through
349
+ * the owner's marketplace (VoDmAl/ai-dev-plugins), not a vdx one of its own.
350
+ */
351
+ export function checkClaudeCodePlugin(
352
+ claudeDir: string = process.env['CLAUDE_CONFIG_DIR'] || path.join(os.homedir(), '.claude'),
353
+ ): CheckResult {
354
+ const base = { id: 'claude-plugin', label: 'Claude Code vdx plugin' };
355
+ const readJson = (file: string): any => {
356
+ try {
357
+ return JSON.parse(fs.readFileSync(path.join(claudeDir, file), 'utf8'));
358
+ } catch {
359
+ return null;
362
360
  }
361
+ };
362
+ const installed = readJson(path.join('plugins', 'installed_plugins.json'));
363
+ if (!installed) {
363
364
  return {
364
- id: 'claude-plugin',
365
- label: 'Claude Code vdx plugin',
366
- status: 'warning',
367
- message: 'vdx marketplace not registered',
368
- remedy: 'Add github.com/VoDmAl/vdx/marketplace to extraKnownMarketplaces',
369
- };
370
- } catch (e: any) {
371
- return {
372
- id: 'claude-plugin',
373
- label: 'Claude Code vdx plugin',
365
+ ...base,
374
366
  status: 'warning',
375
- message: `cannot read settings.json: ${e?.message ?? e}`,
367
+ message: `no plugin records in ${claudeDir}/plugins/installed_plugins.json`,
368
+ remedy: PLUGIN_INSTALL_HINT,
376
369
  };
377
370
  }
371
+ const key = Object.keys(installed.plugins ?? {}).find((k) => k.startsWith('vdx@'));
372
+ if (!key) {
373
+ return { ...base, status: 'warning', message: 'not installed', remedy: PLUGIN_INSTALL_HINT };
374
+ }
375
+ const version = installed.plugins[key]?.[0]?.version;
376
+ const label = `${key}${typeof version === 'string' ? ` ${version}` : ''}`;
377
+ if (readJson('settings.json')?.enabledPlugins?.[key] === false) {
378
+ return { ...base, status: 'warning', message: `${label} installed but disabled`, remedy: `/plugin enable ${key}` };
379
+ }
380
+ return { ...base, status: 'ok', message: label };
378
381
  }
379
382
 
380
383
  /**
@@ -543,7 +546,7 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
543
546
  checkVdxVersion,
544
547
  checkVdx,
545
548
  checkClaudeCode,
546
- checkClaudeCodePlugin,
549
+ () => checkClaudeCodePlugin(),
547
550
  checkNode,
548
551
  checkGit,
549
552
  checkMise,
package/src/index.ts CHANGED
@@ -29,7 +29,8 @@ import {
29
29
  type PublishOptions,
30
30
  } from './publish.ts';
31
31
  import { resolveDefaultRubric } from './defaults.ts';
32
- import { runAi, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
32
+ import { runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
33
+ import { isConversationId } from './conversations.ts';
33
34
 
34
35
  const DEFAULT_RUBRIC = resolveDefaultRubric();
35
36
 
@@ -39,7 +40,7 @@ const USAGE = `Usage:
39
40
  vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
40
41
  vdx publish <patch|minor|major> [--dry-run] [--force]
41
42
  vdx doctor [--format=ansi|markdown|json] [--json]
42
- vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
43
+ vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
43
44
  vdx <command> --help what the command does; an unknown option is refused (exit 2)
44
45
  vdx --version
45
46
  `;
@@ -73,11 +74,24 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
73
74
  project_path the project (default: cwd); its git toplevel is used
74
75
  @host the same command on <host> over ssh: its vdx, profile and tmux
75
76
  --new a new conversation instead of continuing the last one
77
+ --conversation <id>
78
+ continue this Claude Code conversation
76
79
  --restart restart the running agent in its pane — applies a changed profile
77
80
  --detach start without attaching; print the command that attaches
78
81
  --dry-run print the plan and the project's running agents; start nothing
82
+ --check for the agent's own session: how the agent here is launched, and
83
+ whether this session carries the profile's flags (the vdx plugin's
84
+ SessionStart hook); prints nothing without a profile; exit 3 on drift
79
85
  -h, --help this help
80
86
 
87
+ Which conversation a start continues (Claude Code): each one is marked with the
88
+ machine it was last started on (the vdx plugin's hook writes it in), and the
89
+ other machines are asked over ssh which ones run there now. Another machine's
90
+ conversation is continued on that machine; if it does not answer, vdx asks
91
+ before continuing it here. When the choice is not plain, vdx lists them, the
92
+ running ones marked — Enter takes the newest; without a terminal it takes this
93
+ machine's.
94
+
81
95
  Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
82
96
  3 the running agent lacks profile flags (rerun with --restart); 4 the start
83
97
  was not confirmed.
@@ -124,10 +138,11 @@ Exit 2 when something is missing.
124
138
  positionals: 0,
125
139
  },
126
140
  ai: {
127
- usage: 'vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run]',
141
+ usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run]',
128
142
  help: AI_HELP,
129
143
  // `--resume` asked to continue in 0.12; continuing is the default now.
130
- bools: ['new', 'restart', 'resume', 'detach', 'dry-run'],
144
+ bools: ['new', 'restart', 'resume', 'detach', 'dry-run', 'check'],
145
+ values: ['conversation'],
131
146
  positionals: 1,
132
147
  },
133
148
  ...Object.fromEntries(
@@ -401,13 +416,35 @@ function cmdDoctor(opts: ParsedArgs): void {
401
416
  }
402
417
 
403
418
  function cmdAi(opts: ParsedArgs): void {
419
+ if (opts.flags.check === true) {
420
+ // About the session this runs in — there is nothing to start, and no other machine.
421
+ const others = Object.keys(opts.flags).filter((f) => f !== 'check');
422
+ if (others.length > 0 || opts.cmd !== 'ai') {
423
+ process.stderr.write(
424
+ `vdx ai: --check takes only the project path (not ${opts.cmd !== 'ai' ? opts.cmd : `--${others.join(', --')}`})\n`,
425
+ );
426
+ process.exit(2);
427
+ }
428
+ process.exit(runAiCheck(opts.positionals[0] ?? '.', defaultDeps()));
429
+ }
430
+ const conversation = typeof opts.flags.conversation === 'string' ? opts.flags.conversation : undefined;
431
+ if (conversation !== undefined && !isConversationId(conversation)) {
432
+ process.stderr.write(`vdx ai: --conversation takes a conversation id (a UUID), not "${conversation}"\n`);
433
+ process.exit(2);
434
+ }
435
+ if (conversation !== undefined && opts.flags.new === true) {
436
+ process.stderr.write('vdx ai: --new and --conversation ask for different conversations — pick one\n');
437
+ process.exit(2);
438
+ }
404
439
  const options = {
405
440
  path: opts.positionals[0] ?? '.',
406
441
  restart: opts.flags.restart === true,
407
442
  fresh: opts.flags.new === true,
443
+ conversation,
408
444
  detach: opts.flags.detach === true,
409
445
  dryRun: opts.flags['dry-run'] === true,
410
446
  };
447
+ const deps = { ...defaultDeps(), version: readCliVersion() };
411
448
  // `ai@m3`: the same command on another machine. Its own label runs here.
412
449
  const host = opts.cmd.startsWith('ai@') ? opts.cmd.slice(3) : null;
413
450
  if (host !== null) {
@@ -416,10 +453,10 @@ function cmdAi(opts: ParsedArgs): void {
416
453
  process.exit(2);
417
454
  }
418
455
  if (host.toLowerCase() !== hostLabel().toLowerCase()) {
419
- process.exit(runAiRemote(options, host, defaultDeps(), readCliVersion()));
456
+ process.exit(runAiRemote(options, host, deps));
420
457
  }
421
458
  }
422
- process.exit(runAi(options, defaultDeps()));
459
+ process.exit(runAi(options, deps));
423
460
  }
424
461
 
425
462
  const parsed = parseArgs(process.argv);