@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 +9 -2
- package/package.json +1 -1
- package/src/ai.ts +305 -12
- package/src/conversations.ts +299 -0
- package/src/doctor.ts +35 -32
- package/src/index.ts +43 -6
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.
|
|
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 =
|
|
899
|
-
if (
|
|
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
|
|
938
|
+
return res.status;
|
|
907
939
|
}
|
|
908
940
|
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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: `
|
|
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]
|
|
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,
|
|
456
|
+
process.exit(runAiRemote(options, host, deps));
|
|
420
457
|
}
|
|
421
458
|
}
|
|
422
|
-
process.exit(runAi(options,
|
|
459
|
+
process.exit(runAi(options, deps));
|
|
423
460
|
}
|
|
424
461
|
|
|
425
462
|
const parsed = parseArgs(process.argv);
|