@vodmal/vdx-cli 0.18.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,7 @@ 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
52
  vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
53
53
 
54
54
  # What a command does and takes; nothing runs (ai: also where launch flags live)
@@ -74,7 +74,8 @@ vdx test # → mise run test (which calls `vitest run` or whatever wa
74
74
  # 3. Ship a new minor release of a Node lib
75
75
  vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no push)
76
76
 
77
- # 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);
78
79
  # re-run to attach, --restart to apply a changed profile
79
80
  vdx ai
80
81
 
@@ -91,6 +92,11 @@ name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machin
91
92
  profile and tmux, the path taken under its home directory; `--detach` only
92
93
  starts it. The machine's own label (`$VDX_HOST`) runs here. Where git knows no
93
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.
94
100
 
95
101
  Every command takes `--help` (`-h`) and runs nothing with it. An unknown option,
96
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.18.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,20 +922,159 @@ 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;
939
+ }
940
+
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] };
907
1078
  }
908
1079
 
909
1080
  interface Prepared {
@@ -994,10 +1165,11 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
994
1165
  const { projectRoot, profilePath, plan } = prepared;
995
1166
 
996
1167
  const lines = [
997
- `vdx ai: the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
1168
+ `${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
998
1169
  `${profilePath} — agent.args for every project, agent.when[] for a class of projects. ` +
999
1170
  `Hand the user \`vdx ai\`, not \`${plan.command} --<flag>\`; a flag a class of projects needs is a ` +
1000
- `condition in the profile, not an instruction to type. More: \`vdx ai --help\`.`,
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\`.`,
1001
1173
  ];
1002
1174
  const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1003
1175
  if (!own) {
@@ -1032,6 +1204,10 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1032
1204
  if (typeof prepared === 'number') return prepared;
1033
1205
  const { projectRoot, environment, profileDir } = prepared;
1034
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
+ }
1035
1211
  // A start continues the last conversation unless asked for a new one.
1036
1212
  if (opts.fresh) plan = { ...plan, resumeArgs: [] };
1037
1213
 
@@ -1045,13 +1221,49 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1045
1221
  multiplexer === 'tmux'
1046
1222
  ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
1047
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
+ }
1048
1238
 
1049
1239
  const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
1050
1240
  if (opts.dryRun) {
1051
1241
  deps.out(renderPlan(plan, multiplexer, running));
1242
+ for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1052
1243
  ensureAuthor(projectRoot, poolDirs, deps, true);
1053
1244
  return EXIT_OK;
1054
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
+ }
1055
1267
  ensureAuthor(projectRoot, poolDirs, deps, false);
1056
1268
 
1057
1269
  if (multiplexer === 'none') {
@@ -1078,7 +1290,6 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1078
1290
  return res.status ?? 1;
1079
1291
  }
1080
1292
 
1081
- const current = running[0];
1082
1293
  if (running.length > 1) {
1083
1294
  log(
1084
1295
  `note: ${running.length} panes run ${plan.command} in this project: ` +
@@ -1088,13 +1299,22 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1088
1299
 
1089
1300
  if (current && !opts.restart) {
1090
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
+ }
1091
1311
  const missing = missingArgs(current.proc.args, plan.args);
1092
1312
  if (missing.length > 0) {
1093
1313
  log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
1094
1314
  log(
1095
1315
  ` to apply the profile: vdx ai --restart${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1096
1316
  (plan.resumeArgs.length
1097
- ? ` (stops the running agent, then resumes the conversation with ${plan.resumeArgs.join(' ')})`
1317
+ ? ` (stops the running agent, then resumes its conversation)`
1098
1318
  : opts.fresh
1099
1319
  ? ' (stops the running agent and starts a new conversation)'
1100
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/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from './publish.ts';
31
31
  import { resolveDefaultRubric } from './defaults.ts';
32
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,6 +74,8 @@ 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
@@ -81,6 +84,14 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
81
84
  SessionStart hook); prints nothing without a profile; exit 3 on drift
82
85
  -h, --help this help
83
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
+
84
95
  Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
85
96
  3 the running agent lacks profile flags (rerun with --restart); 4 the start
86
97
  was not confirmed.
@@ -127,10 +138,11 @@ Exit 2 when something is missing.
127
138
  positionals: 0,
128
139
  },
129
140
  ai: {
130
- 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]',
131
142
  help: AI_HELP,
132
143
  // `--resume` asked to continue in 0.12; continuing is the default now.
133
144
  bools: ['new', 'restart', 'resume', 'detach', 'dry-run', 'check'],
145
+ values: ['conversation'],
134
146
  positionals: 1,
135
147
  },
136
148
  ...Object.fromEntries(
@@ -415,13 +427,24 @@ function cmdAi(opts: ParsedArgs): void {
415
427
  }
416
428
  process.exit(runAiCheck(opts.positionals[0] ?? '.', defaultDeps()));
417
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
+ }
418
439
  const options = {
419
440
  path: opts.positionals[0] ?? '.',
420
441
  restart: opts.flags.restart === true,
421
442
  fresh: opts.flags.new === true,
443
+ conversation,
422
444
  detach: opts.flags.detach === true,
423
445
  dryRun: opts.flags['dry-run'] === true,
424
446
  };
447
+ const deps = { ...defaultDeps(), version: readCliVersion() };
425
448
  // `ai@m3`: the same command on another machine. Its own label runs here.
426
449
  const host = opts.cmd.startsWith('ai@') ? opts.cmd.slice(3) : null;
427
450
  if (host !== null) {
@@ -430,10 +453,10 @@ function cmdAi(opts: ParsedArgs): void {
430
453
  process.exit(2);
431
454
  }
432
455
  if (host.toLowerCase() !== hostLabel().toLowerCase()) {
433
- process.exit(runAiRemote(options, host, defaultDeps(), readCliVersion()));
456
+ process.exit(runAiRemote(options, host, deps));
434
457
  }
435
458
  }
436
- process.exit(runAi(options, defaultDeps()));
459
+ process.exit(runAi(options, deps));
437
460
  }
438
461
 
439
462
  const parsed = parseArgs(process.argv);