@vodmal/vdx-cli 0.24.0 → 0.26.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 +5 -4
- package/package.json +10 -1
- package/rubric/vdx-rubric.yaml +76 -32
- package/src/ai.ts +221 -76
- package/src/audit.ts +19 -9
- package/src/author.ts +27 -8
- package/src/clone-checks.ts +14 -9
- package/src/conversations.ts +48 -22
- package/src/doctor.ts +14 -15
- package/src/evaluator.ts +21 -20
- package/src/facts.ts +71 -56
- package/src/gha.ts +38 -27
- package/src/hooks.ts +78 -43
- package/src/index.ts +56 -41
- package/src/init.ts +22 -61
- package/src/manifest.ts +25 -4
- package/src/mcp-server.ts +40 -39
- package/src/narrow.ts +56 -0
- package/src/personal-hooks.ts +16 -5
- package/src/predicates.ts +12 -16
- package/src/publish.ts +38 -31
- package/src/report.ts +1 -3
- package/src/rubric.ts +2 -2
- package/src/run.ts +5 -8
- package/src/scoring.ts +2 -8
- package/src/vocabulary.ts +13 -12
package/src/ai.ts
CHANGED
|
@@ -6,12 +6,13 @@ import YAML from 'js-yaml';
|
|
|
6
6
|
import { evalPredicate } from './evaluator.ts';
|
|
7
7
|
import { GIT_HOOK_EVENTS } from './hooks.ts';
|
|
8
8
|
import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { loadManifestAt } from './manifest.ts';
|
|
10
10
|
import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
|
|
11
11
|
import type { Predicate } from './rubric.ts';
|
|
12
12
|
import {
|
|
13
13
|
type Conversation,
|
|
14
14
|
describeConversation,
|
|
15
|
+
describeAge,
|
|
15
16
|
describeLive,
|
|
16
17
|
isMachineLabel,
|
|
17
18
|
LIVE_SESSIONS_SCRIPT,
|
|
@@ -20,12 +21,14 @@ import {
|
|
|
20
21
|
listConversations,
|
|
21
22
|
liveConversationId,
|
|
22
23
|
liveSessionsHere,
|
|
24
|
+
liveStartedAt,
|
|
23
25
|
machineLine,
|
|
24
26
|
type MachineReport,
|
|
25
27
|
machineOf,
|
|
26
28
|
parseMachineReport,
|
|
27
29
|
runsInProject,
|
|
28
30
|
} from './conversations.ts';
|
|
31
|
+
import { at, errorMessage, isStringList, str } from './narrow.ts';
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* `vdx ai` — start the person's agent in a project, with the flags their
|
|
@@ -80,7 +83,7 @@ export interface NameSource {
|
|
|
80
83
|
export interface SessionProfile {
|
|
81
84
|
multiplexer?: Multiplexer;
|
|
82
85
|
name?: string;
|
|
83
|
-
/** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
|
|
86
|
+
/** Tried after `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml; the first non-empty string wins. */
|
|
84
87
|
project_names?: NameSource[];
|
|
85
88
|
/** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
|
|
86
89
|
machines?: string[];
|
|
@@ -142,13 +145,9 @@ export function resolveEnvironmentPath(
|
|
|
142
145
|
return fs.existsSync(user) ? user : null;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
function isStringList(v: unknown): v is string[] {
|
|
146
|
-
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
147
|
-
}
|
|
148
|
-
|
|
149
148
|
/** Parse and validate a profile. Throws with the offending key named. */
|
|
150
149
|
export function parseEnvironment(text: string, source: string): Environment {
|
|
151
|
-
const data = YAML.load(text)
|
|
150
|
+
const data: unknown = YAML.load(text);
|
|
152
151
|
if (data === null || data === undefined) return {};
|
|
153
152
|
if (typeof data !== 'object' || Array.isArray(data)) {
|
|
154
153
|
throw new Error(`${source}: expected a YAML mapping at the top level`);
|
|
@@ -221,10 +220,14 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
221
220
|
if (session.machines !== undefined) {
|
|
222
221
|
if (!isStringList(session.machines)) fail('session.machines', 'must be a list of machine names');
|
|
223
222
|
session.machines.forEach((m, i) => {
|
|
224
|
-
if (!isMachineLabel(m))
|
|
223
|
+
if (!isMachineLabel(m))
|
|
224
|
+
fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
|
|
225
225
|
});
|
|
226
226
|
}
|
|
227
|
-
if (
|
|
227
|
+
if (
|
|
228
|
+
session.folders_fix !== undefined &&
|
|
229
|
+
(typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')
|
|
230
|
+
) {
|
|
228
231
|
fail('session.folders_fix', 'must be a non-empty string');
|
|
229
232
|
}
|
|
230
233
|
}
|
|
@@ -250,7 +253,8 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
250
253
|
if (events.length === 0 || !events.every((e) => typeof e === 'string' && GIT_HOOK_EVENTS.has(e))) {
|
|
251
254
|
fail(`${at}.event`, 'must be a git hook event (pre-commit, pre-push, …) or a list of them');
|
|
252
255
|
}
|
|
253
|
-
if (typeof h.command !== 'string' || h.command.trim() === '')
|
|
256
|
+
if (typeof h.command !== 'string' || h.command.trim() === '')
|
|
257
|
+
fail(`${at}.command`, 'must be a non-empty string');
|
|
254
258
|
if (h.when_exists !== undefined && (typeof h.when_exists !== 'string' || h.when_exists === '')) {
|
|
255
259
|
fail(`${at}.when_exists`, 'must be a path');
|
|
256
260
|
}
|
|
@@ -264,8 +268,8 @@ export function loadEnvironment(file: string): Environment {
|
|
|
264
268
|
let text: string;
|
|
265
269
|
try {
|
|
266
270
|
text = fs.readFileSync(file, 'utf8');
|
|
267
|
-
} catch (e
|
|
268
|
-
throw new Error(`cannot read profile ${file}: ${e
|
|
271
|
+
} catch (e) {
|
|
272
|
+
throw new Error(`cannot read profile ${file}: ${str(at(e, 'code')) ?? errorMessage(e)}`, { cause: e });
|
|
269
273
|
}
|
|
270
274
|
return parseEnvironment(text, file);
|
|
271
275
|
}
|
|
@@ -365,7 +369,7 @@ export function planLaunch(input: {
|
|
|
365
369
|
}
|
|
366
370
|
|
|
367
371
|
export function shellQuote(s: string): string {
|
|
368
|
-
if (s !== '' && /^[A-Za-z0-9_
|
|
372
|
+
if (s !== '' && /^[A-Za-z0-9_/.:@%+=,-]+$/.test(s)) return s;
|
|
369
373
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
370
374
|
}
|
|
371
375
|
|
|
@@ -519,7 +523,11 @@ export function projectIdentity(projectRoot: string): string {
|
|
|
519
523
|
encoding: 'utf8',
|
|
520
524
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
521
525
|
}).trim();
|
|
522
|
-
const slug = url
|
|
526
|
+
const slug = url
|
|
527
|
+
.replace(/\/+$/, '')
|
|
528
|
+
.replace(/\.git$/, '')
|
|
529
|
+
.split(/[/:]/)
|
|
530
|
+
.pop();
|
|
523
531
|
if (slug) return slug.toLowerCase();
|
|
524
532
|
} catch {
|
|
525
533
|
/* no git or no origin */
|
|
@@ -533,7 +541,7 @@ export interface ProjectName {
|
|
|
533
541
|
}
|
|
534
542
|
|
|
535
543
|
/**
|
|
536
|
-
* `{project}`: `[vdx] name` in the project's mise.toml, else the first profile
|
|
544
|
+
* `{project}`: `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml, else the first profile
|
|
537
545
|
* source holding a non-empty string, else the repository name. A source that is
|
|
538
546
|
* missing or unreadable is skipped — a name is a label, not a reason to fail.
|
|
539
547
|
*/
|
|
@@ -544,15 +552,19 @@ export function resolveProjectName(input: {
|
|
|
544
552
|
home: string;
|
|
545
553
|
profileDir: string;
|
|
546
554
|
}): ProjectName {
|
|
547
|
-
const
|
|
548
|
-
|
|
555
|
+
const found = loadManifestAt(input.projectRoot);
|
|
556
|
+
const own = found?.manifest.name;
|
|
557
|
+
if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: `mise.toml [${found!.place}] name` };
|
|
549
558
|
for (const src of input.sources) {
|
|
550
559
|
const file = path.resolve(
|
|
551
560
|
input.profileDir,
|
|
552
561
|
src.file.replace(/^~(?=$|\/)/, input.home).replace(/\{repo\}/g, input.repo),
|
|
553
562
|
);
|
|
554
563
|
// Readers take paths relative to a project root; from "/" an absolute path stays itself.
|
|
555
|
-
const value = resolveConfigPath(
|
|
564
|
+
const value = resolveConfigPath(
|
|
565
|
+
readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file),
|
|
566
|
+
src.jsonpath,
|
|
567
|
+
);
|
|
556
568
|
if (typeof value === 'string' && value.trim()) {
|
|
557
569
|
const shown = file.startsWith(input.home + path.sep) ? `~${file.slice(input.home.length)}` : file;
|
|
558
570
|
return { name: value.trim(), source: `${shown} ${src.jsonpath}` };
|
|
@@ -675,9 +687,7 @@ export class Tmux {
|
|
|
675
687
|
}
|
|
676
688
|
|
|
677
689
|
attach(session: string, insideTmux: boolean): number {
|
|
678
|
-
const args = insideTmux
|
|
679
|
-
? ['switch-client', '-t', `=${session}`]
|
|
680
|
-
: ['attach-session', '-t', `=${session}`];
|
|
690
|
+
const args = insideTmux ? ['switch-client', '-t', `=${session}`] : ['attach-session', '-t', `=${session}`];
|
|
681
691
|
return spawnSync('tmux', this.argv(args), { stdio: 'inherit' }).status ?? 1;
|
|
682
692
|
}
|
|
683
693
|
|
|
@@ -729,7 +739,7 @@ export interface AiDeps {
|
|
|
729
739
|
|
|
730
740
|
export function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
|
|
731
741
|
const res = spawnSync('ssh', args, { stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'inherit', encoding: 'utf8' });
|
|
732
|
-
if ((res.error
|
|
742
|
+
if (at(res.error, 'code') === 'ENOENT') return null;
|
|
733
743
|
return { status: res.status ?? 1, stdout: res.stdout ?? '' };
|
|
734
744
|
}
|
|
735
745
|
|
|
@@ -746,8 +756,8 @@ export function askLine(prompt: string): string | null {
|
|
|
746
756
|
let n: number;
|
|
747
757
|
try {
|
|
748
758
|
n = fs.readSync(0, buf, 0, buf.length, null);
|
|
749
|
-
} catch (e
|
|
750
|
-
if (e
|
|
759
|
+
} catch (e) {
|
|
760
|
+
if (at(e, 'code') === 'EAGAIN') {
|
|
751
761
|
sleepSync(50);
|
|
752
762
|
continue;
|
|
753
763
|
}
|
|
@@ -804,7 +814,8 @@ export function renderPlan(
|
|
|
804
814
|
` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
|
|
805
815
|
` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
|
|
806
816
|
];
|
|
807
|
-
if (plan.focused)
|
|
817
|
+
if (plan.focused)
|
|
818
|
+
lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
|
|
808
819
|
for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
|
|
809
820
|
lines.push(
|
|
810
821
|
multiplexer === 'tmux'
|
|
@@ -828,7 +839,10 @@ export function renderPlan(
|
|
|
828
839
|
|
|
829
840
|
function lastLines(screen: string | null, n: number): string {
|
|
830
841
|
const lines = (screen ?? '').split('\n').filter((l) => l.trim() !== '');
|
|
831
|
-
return lines
|
|
842
|
+
return lines
|
|
843
|
+
.slice(-n)
|
|
844
|
+
.map((l) => ` | ${l}`)
|
|
845
|
+
.join('\n');
|
|
832
846
|
}
|
|
833
847
|
|
|
834
848
|
function findAgentInPane(plan: LaunchPlan, paneId: string, deps: AiDeps): ProcInfo | null {
|
|
@@ -981,7 +995,12 @@ export function remoteAiArgs(input: {
|
|
|
981
995
|
}
|
|
982
996
|
|
|
983
997
|
/** Where neighbour repos live: the profile's `git.author_pool`, else the project's parent directory. */
|
|
984
|
-
export function authorPoolDirs(
|
|
998
|
+
export function authorPoolDirs(
|
|
999
|
+
environment: Environment,
|
|
1000
|
+
projectRoot: string,
|
|
1001
|
+
home: string,
|
|
1002
|
+
profileDir: string,
|
|
1003
|
+
): string[] {
|
|
985
1004
|
const listed = environment.git?.author_pool;
|
|
986
1005
|
if (!listed?.length) return [path.dirname(projectRoot)];
|
|
987
1006
|
return listed.map((d) => path.resolve(profileDir, d.replace(/^~(?=$|\/)/, home)));
|
|
@@ -1025,7 +1044,13 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
|
|
|
1025
1044
|
return;
|
|
1026
1045
|
}
|
|
1027
1046
|
const pick: Author | undefined =
|
|
1028
|
-
a === ''
|
|
1047
|
+
a === ''
|
|
1048
|
+
? top[0]
|
|
1049
|
+
: /^\d+$/.test(a)
|
|
1050
|
+
? top[Number(a) - 1]
|
|
1051
|
+
: a.includes('@')
|
|
1052
|
+
? { name: top[0]!.name, email: a }
|
|
1053
|
+
: undefined;
|
|
1029
1054
|
if (pick) {
|
|
1030
1055
|
if (writeAuthor(projectRoot, pick)) deps.log(`✓ ${pick.name} <${pick.email}> → .git/config`);
|
|
1031
1056
|
else deps.log(`✗ could not write .git/config — ${command(pick)}`);
|
|
@@ -1040,7 +1065,9 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: s
|
|
|
1040
1065
|
const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
|
|
1041
1066
|
const tty = deps.interactive && !opts.detach && !opts.dryRun;
|
|
1042
1067
|
const args = remoteAiArgs({ host, from, projectPath, home: deps.home, version: deps.version ?? '', opts, tty });
|
|
1043
|
-
deps.log(
|
|
1068
|
+
deps.log(
|
|
1069
|
+
`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`,
|
|
1070
|
+
);
|
|
1044
1071
|
const res = (deps.ssh ?? runSsh)(args, false);
|
|
1045
1072
|
if (res === null) {
|
|
1046
1073
|
deps.log('vdx ai: `ssh` not found on PATH');
|
|
@@ -1113,7 +1140,10 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1113
1140
|
if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
1114
1141
|
const host = plan.host;
|
|
1115
1142
|
const same = sameMachine;
|
|
1116
|
-
const all =
|
|
1143
|
+
const all =
|
|
1144
|
+
plan.resumeArgs.length === 0
|
|
1145
|
+
? []
|
|
1146
|
+
: listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
|
|
1117
1147
|
const root = shellQuote(plan.projectRoot);
|
|
1118
1148
|
// Said from where the person sits: under `vdx ai@<host>` that is not this machine.
|
|
1119
1149
|
const atSeat = same(plan.seat, host);
|
|
@@ -1124,7 +1154,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1124
1154
|
for (const m of [...plan.machines, ...all.map((c) => c.machine)]) {
|
|
1125
1155
|
if (m && !same(m, host) && ![...reach.keys()].some((k) => same(k, m))) reach.set(m, askMachine(m, deps));
|
|
1126
1156
|
}
|
|
1127
|
-
const silent = [...reach]
|
|
1157
|
+
const silent = [...reach]
|
|
1158
|
+
.filter(([, r]) => r === null)
|
|
1159
|
+
.map(([m]) => `note: ${m} does not answer over ssh — what runs there is not known`);
|
|
1128
1160
|
const elsewhere = [...reach].flatMap(([m, r]) =>
|
|
1129
1161
|
(r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
|
|
1130
1162
|
);
|
|
@@ -1139,7 +1171,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1139
1171
|
!l.tmux ? '' : same(m, plan.seat) ? ` — attach: vdx ai ${rootOn(m)}` : ` — there: vdx ai@${m} ${root}`;
|
|
1140
1172
|
const fresh = (lines: string[]): ConversationChoice => {
|
|
1141
1173
|
if (elsewhere.length === 0) return { args: [], lines };
|
|
1142
|
-
const agents = elsewhere.map(
|
|
1174
|
+
const agents = elsewhere.map(
|
|
1175
|
+
({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`,
|
|
1176
|
+
);
|
|
1143
1177
|
return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
|
|
1144
1178
|
};
|
|
1145
1179
|
if (plan.resumeArgs.length === 0) return fresh(silent);
|
|
@@ -1167,15 +1201,26 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1167
1201
|
if (here) {
|
|
1168
1202
|
return {
|
|
1169
1203
|
args: [],
|
|
1170
|
-
askHere: {
|
|
1204
|
+
askHere: {
|
|
1205
|
+
id: c.id,
|
|
1206
|
+
why: `It runs ${on(host)} right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it`,
|
|
1207
|
+
},
|
|
1171
1208
|
lines: [`chosen: ${show(c)}`],
|
|
1172
1209
|
};
|
|
1173
1210
|
}
|
|
1174
1211
|
if (isOurs(c)) return { args: resumeId(c.id), lines: [`continuing ${show(c)}`] };
|
|
1175
|
-
if (m)
|
|
1212
|
+
if (m)
|
|
1213
|
+
return {
|
|
1214
|
+
args: [],
|
|
1215
|
+
remote: { host: m, id: c.id, reachable: reach.get(m) != null },
|
|
1216
|
+
lines: [`continuing on ${m}: ${show(c)}`],
|
|
1217
|
+
};
|
|
1176
1218
|
return {
|
|
1177
1219
|
args: [],
|
|
1178
|
-
askHere: {
|
|
1220
|
+
askHere: {
|
|
1221
|
+
id: c.id,
|
|
1222
|
+
why: `Its machine is unknown (never typed in ${on(host)}); if it runs there, both machines will write to it`,
|
|
1223
|
+
},
|
|
1179
1224
|
lines: [`chosen: ${show(c)}`],
|
|
1180
1225
|
};
|
|
1181
1226
|
};
|
|
@@ -1188,7 +1233,12 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1188
1233
|
if (canAsk && deps.interactive) {
|
|
1189
1234
|
deps.log('conversations of this project:');
|
|
1190
1235
|
found.forEach((c, i) => {
|
|
1191
|
-
const marks = [
|
|
1236
|
+
const marks = [
|
|
1237
|
+
i === 0 && 'newest',
|
|
1238
|
+
c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`),
|
|
1239
|
+
!machine(c) && 'machine unknown',
|
|
1240
|
+
...running(c),
|
|
1241
|
+
];
|
|
1192
1242
|
const m = marks.filter(Boolean);
|
|
1193
1243
|
deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
|
|
1194
1244
|
});
|
|
@@ -1215,7 +1265,8 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1215
1265
|
`${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
|
|
1216
1266
|
(now.length ? `, ${now.join(', ')}` : '');
|
|
1217
1267
|
if (runsHere(c)) return `note: ${which} — not taken: it would have two sessions`;
|
|
1218
|
-
if (m && same(m, plan.seat))
|
|
1268
|
+
if (m && same(m, plan.seat))
|
|
1269
|
+
return `note: ${m} has ${which} — continue it where you are: vdx ai ${rootOn(m)} --conversation ${c.id}`;
|
|
1219
1270
|
return m
|
|
1220
1271
|
? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
|
|
1221
1272
|
: `note: ${which} is of an unknown machine (never typed in ${on(host)}) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
|
|
@@ -1228,6 +1279,51 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1228
1279
|
return fresh([`no idle conversation ${atSeat ? 'of this machine' : `on ${host}`} — starting a new one`, ...notes]);
|
|
1229
1280
|
}
|
|
1230
1281
|
|
|
1282
|
+
/**
|
|
1283
|
+
* Several agents of the project run here and the start is about one of them: a person
|
|
1284
|
+
* picks — Enter takes the newest; without a terminal, or under --dry-run, it is the newest.
|
|
1285
|
+
* null — the person left it.
|
|
1286
|
+
*/
|
|
1287
|
+
function pickAgent(
|
|
1288
|
+
running: AgentPane[],
|
|
1289
|
+
focusedOf: (pid: number) => boolean,
|
|
1290
|
+
deps: AiDeps,
|
|
1291
|
+
canAsk: boolean,
|
|
1292
|
+
): AgentPane | null {
|
|
1293
|
+
if (running.length === 1) return running[0]!;
|
|
1294
|
+
const startedAt = (a: AgentPane) => liveStartedAt(deps.env, deps.home, a.proc.pid);
|
|
1295
|
+
const sorted = [...running].sort((a, b) => (startedAt(b) ?? 0) - (startedAt(a) ?? 0));
|
|
1296
|
+
const show = (a: AgentPane) => {
|
|
1297
|
+
const id = liveConversationId(deps.env, deps.home, a.proc.pid);
|
|
1298
|
+
const at = startedAt(a);
|
|
1299
|
+
const marks = [
|
|
1300
|
+
id && `conversation ${id.slice(0, 8)}`,
|
|
1301
|
+
focusedOf(a.proc.pid) && 'focused',
|
|
1302
|
+
at !== null && `started ${describeAge(new Date(at))}`,
|
|
1303
|
+
].filter(Boolean);
|
|
1304
|
+
return `${a.pane.session} ${a.pane.paneId}${marks.length ? ` — ${marks.join(', ')}` : ''}`;
|
|
1305
|
+
};
|
|
1306
|
+
// Start times come from Claude Code's session files; without them, tmux's order.
|
|
1307
|
+
const newest = startedAt(sorted[0]!) !== null ? 'the newest' : 'the first';
|
|
1308
|
+
if (!canAsk || !deps.interactive) {
|
|
1309
|
+
deps.log(`note: ${sorted.length} agents of this project run here — taking ${newest}, ${show(sorted[0]!)}`);
|
|
1310
|
+
return sorted[0]!;
|
|
1311
|
+
}
|
|
1312
|
+
deps.log('agents of this project running here:');
|
|
1313
|
+
sorted.forEach((a, i) =>
|
|
1314
|
+
deps.log(` ${i + 1}) ${show(a)}${i === 0 && newest === 'the newest' ? ' ← newest' : ''}`),
|
|
1315
|
+
);
|
|
1316
|
+
for (let tries = 0; tries < 2; tries++) {
|
|
1317
|
+
const answer = deps.ask('Which one? [1]: ');
|
|
1318
|
+
if (answer === null) return null;
|
|
1319
|
+
const a = answer.trim();
|
|
1320
|
+
const agent = a === '' ? sorted[0] : /^\d+$/.test(a) ? sorted[Number(a) - 1] : undefined;
|
|
1321
|
+
if (agent) return agent;
|
|
1322
|
+
deps.log(` 1–${sorted.length}`);
|
|
1323
|
+
}
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1231
1327
|
interface Prepared {
|
|
1232
1328
|
projectRoot: string;
|
|
1233
1329
|
environment: Environment;
|
|
@@ -1253,8 +1349,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1253
1349
|
if (profilePath) {
|
|
1254
1350
|
try {
|
|
1255
1351
|
environment = loadEnvironment(profilePath);
|
|
1256
|
-
} catch (e
|
|
1257
|
-
log(`vdx ai: ${e
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
log(`vdx ai: ${errorMessage(e)}`);
|
|
1258
1354
|
return EXIT_USAGE;
|
|
1259
1355
|
}
|
|
1260
1356
|
}
|
|
@@ -1292,8 +1388,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1292
1388
|
let plan: LaunchPlan;
|
|
1293
1389
|
try {
|
|
1294
1390
|
plan = replan(focused);
|
|
1295
|
-
} catch (e
|
|
1296
|
-
log(`vdx ai: ${profilePath ?? 'profile'}: ${e
|
|
1391
|
+
} catch (e) {
|
|
1392
|
+
log(`vdx ai: ${profilePath ?? 'profile'}: ${errorMessage(e)}`);
|
|
1297
1393
|
return EXIT_USAGE;
|
|
1298
1394
|
}
|
|
1299
1395
|
return { projectRoot, environment, profilePath, profileDir, plan, replan };
|
|
@@ -1326,16 +1422,30 @@ export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
|
|
|
1326
1422
|
*/
|
|
1327
1423
|
export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
|
|
1328
1424
|
const dirs = GUARDED_FOLDERS.map((f) => path.join(home, f));
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1425
|
+
if (tmux) {
|
|
1426
|
+
// `ls`, not `[ -d ]`: a denied folder still passes -d. The answer comes back in a
|
|
1427
|
+
// file: tmux before 3.5 prints run-shell's output into a pane, not to the caller.
|
|
1428
|
+
const file = path.join(os.tmpdir(), `vdx-folders-${process.pid}-${Date.now()}`);
|
|
1429
|
+
const checks = dirs.map(
|
|
1430
|
+
(d) => `{ [ ! -d ${shellQuote(d)} ] || ls ${shellQuote(d)} >/dev/null 2>&1 || echo ${shellQuote(d)}; }`,
|
|
1431
|
+
);
|
|
1432
|
+
try {
|
|
1433
|
+
if (tmux.tryRun(['run-shell', `{ ${checks.join('; ')}; } > ${shellQuote(file)}`]) !== null) {
|
|
1434
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
1435
|
+
}
|
|
1436
|
+
} catch {
|
|
1437
|
+
// no answer from the server: this process's own check
|
|
1438
|
+
} finally {
|
|
1439
|
+
fs.rmSync(file, { force: true });
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1333
1442
|
return dirs.filter((d) => {
|
|
1334
1443
|
try {
|
|
1335
1444
|
fs.readdirSync(d);
|
|
1336
1445
|
return false;
|
|
1337
|
-
} catch (e
|
|
1338
|
-
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
const code = at(e, 'code');
|
|
1448
|
+
return code === 'EPERM' || code === 'EACCES';
|
|
1339
1449
|
}
|
|
1340
1450
|
});
|
|
1341
1451
|
}
|
|
@@ -1445,8 +1555,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1445
1555
|
log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
|
|
1446
1556
|
return EXIT_USAGE;
|
|
1447
1557
|
}
|
|
1448
|
-
// A start continues the last conversation unless asked for a new one.
|
|
1449
|
-
|
|
1558
|
+
// A start continues the last conversation unless asked for a new one; a focused session is a new one.
|
|
1559
|
+
const wantsNew = (opts.fresh || opts.focused === true) && !opts.conversation;
|
|
1560
|
+
if (wantsNew) plan = { ...plan, resumeArgs: [] };
|
|
1450
1561
|
|
|
1451
1562
|
let multiplexer = plan.multiplexer;
|
|
1452
1563
|
if (multiplexer === 'tmux' && !tmux.available()) {
|
|
@@ -1455,13 +1566,27 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1455
1566
|
}
|
|
1456
1567
|
|
|
1457
1568
|
const running =
|
|
1458
|
-
multiplexer === 'tmux'
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1569
|
+
multiplexer === 'tmux' ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command) : [];
|
|
1570
|
+
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1571
|
+
const conversationOf = (a: AgentPane) => liveConversationId(deps.env, deps.home, a.proc.pid);
|
|
1572
|
+
// Which running agent the start is about. A new conversation, or one no agent here is in, starts
|
|
1573
|
+
// next to them; --restart replaces one. An agent whose conversation vdx cannot read may be in the
|
|
1574
|
+
// one asked for: no second agent in it blindly.
|
|
1575
|
+
const inConversation = opts.conversation ? running.find((a) => conversationOf(a) === opts.conversation) : undefined;
|
|
1576
|
+
const unreadable = opts.conversation && !inConversation ? running.find((a) => conversationOf(a) === null) : undefined;
|
|
1577
|
+
const alongside =
|
|
1578
|
+
running.length > 0 && !opts.restart && !inConversation && !unreadable && (wantsNew || !!opts.conversation);
|
|
1579
|
+
let current = inConversation ?? unreadable;
|
|
1580
|
+
if (!current && !alongside && running.length > 0) {
|
|
1581
|
+
const picked = pickAgent(running, focusedOf, deps, !opts.dryRun);
|
|
1582
|
+
if (picked === null) {
|
|
1583
|
+
log('nothing done');
|
|
1584
|
+
return EXIT_LAUNCH_FAILED;
|
|
1585
|
+
}
|
|
1586
|
+
current = picked;
|
|
1587
|
+
}
|
|
1462
1588
|
// A running agent keeps its mode: plain `vdx ai` takes a focused one as it is,
|
|
1463
1589
|
// and the rules it leaves out are not missing from it.
|
|
1464
|
-
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1465
1590
|
const runningFocused = current ? focusedOf(current.proc.pid) : false;
|
|
1466
1591
|
if (current && !opts.restart && runningFocused && !plan.focused) {
|
|
1467
1592
|
plan = { ...prepared.replan(true), resumeArgs: plan.resumeArgs };
|
|
@@ -1471,8 +1596,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1471
1596
|
let choice: ConversationChoice | null = null;
|
|
1472
1597
|
if (!current || opts.restart) {
|
|
1473
1598
|
if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
|
|
1474
|
-
// A restart into a new conversation replaces the agent here
|
|
1475
|
-
|
|
1599
|
+
// A restart into a new conversation replaces the agent here, and a new one next to an agent here
|
|
1600
|
+
// is what was asked for: the other machines are not asked.
|
|
1601
|
+
else if ((current || alongside) && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
|
|
1476
1602
|
else if (current && isClaude(plan.command)) {
|
|
1477
1603
|
const live = liveConversationId(deps.env, deps.home, current.proc.pid);
|
|
1478
1604
|
choice = live
|
|
@@ -1484,8 +1610,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1484
1610
|
|
|
1485
1611
|
const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
|
|
1486
1612
|
// Said before a start or an attach, never in the way: the agent runs, without those files.
|
|
1487
|
-
const denied =
|
|
1488
|
-
|
|
1613
|
+
const denied =
|
|
1614
|
+
(deps.platform ?? process.platform) === 'darwin'
|
|
1615
|
+
? unreadableFolders(multiplexer === 'tmux' ? tmux : null, deps.home)
|
|
1616
|
+
: [];
|
|
1617
|
+
const folders = denied.length
|
|
1618
|
+
? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home)
|
|
1619
|
+
: [];
|
|
1489
1620
|
if (opts.dryRun) {
|
|
1490
1621
|
deps.out(renderPlan(plan, multiplexer, running, focusedOf));
|
|
1491
1622
|
for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
|
|
@@ -1497,7 +1628,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1497
1628
|
for (const l of folders) log(l);
|
|
1498
1629
|
if (choice?.abort) return EXIT_LAUNCH_FAILED;
|
|
1499
1630
|
if (choice?.elsewhere && !current) {
|
|
1500
|
-
const answer = deps.interactive
|
|
1631
|
+
const answer = deps.interactive
|
|
1632
|
+
? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `)
|
|
1633
|
+
: null;
|
|
1501
1634
|
if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
|
|
1502
1635
|
log('nothing started');
|
|
1503
1636
|
return EXIT_LAUNCH_FAILED;
|
|
@@ -1538,7 +1671,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1538
1671
|
const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
|
|
1539
1672
|
const startedAt = Date.now();
|
|
1540
1673
|
let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
|
|
1541
|
-
if ((res.error
|
|
1674
|
+
if (at(res.error, 'code') === 'ENOENT') {
|
|
1542
1675
|
log(`vdx ai: \`${plan.command}\` not found on PATH`);
|
|
1543
1676
|
return 127;
|
|
1544
1677
|
}
|
|
@@ -1550,34 +1683,35 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1550
1683
|
return res.status ?? 1;
|
|
1551
1684
|
}
|
|
1552
1685
|
|
|
1553
|
-
if (
|
|
1686
|
+
if (alongside) {
|
|
1687
|
+
const others = running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ');
|
|
1554
1688
|
log(
|
|
1555
|
-
`note: ${running.length}
|
|
1556
|
-
|
|
1689
|
+
`note: ${others} keep${running.length === 1 ? 's' : ''} running ${plan.command} — starting another one next to it, ` +
|
|
1690
|
+
`in ${opts.conversation ? `conversation ${opts.conversation.slice(0, 8)}` : 'a new conversation'}`,
|
|
1557
1691
|
);
|
|
1558
1692
|
}
|
|
1559
1693
|
|
|
1560
1694
|
if (current && !opts.restart) {
|
|
1561
|
-
if (
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
`note: ${current.pane.session} is in ${live ? `conversation ${live.slice(0, 8)}` : 'another conversation'} — ` +
|
|
1567
|
-
`to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
|
|
1568
|
-
);
|
|
1569
|
-
}
|
|
1695
|
+
if (current === unreadable) {
|
|
1696
|
+
log(
|
|
1697
|
+
`note: ${current.pane.session} runs ${plan.command} in a conversation vdx cannot read — attaching to it; ` +
|
|
1698
|
+
`to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
|
|
1699
|
+
);
|
|
1570
1700
|
}
|
|
1571
1701
|
const unfocused = plan.focused && !runningFocused;
|
|
1572
1702
|
const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
|
|
1573
1703
|
if (!unfocused && missing.length === 0) {
|
|
1574
|
-
log(
|
|
1704
|
+
log(
|
|
1705
|
+
`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`,
|
|
1706
|
+
);
|
|
1575
1707
|
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1576
1708
|
}
|
|
1577
1709
|
// Drift. In a terminal the person picks: the agent as it runs, or a restart
|
|
1578
1710
|
// with the profile; without one nothing is touched, and the fix is named.
|
|
1579
1711
|
const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
|
|
1580
|
-
const fix =
|
|
1712
|
+
const fix =
|
|
1713
|
+
`vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
|
|
1714
|
+
(opts.conversation ? ` --conversation ${opts.conversation}` : '');
|
|
1581
1715
|
const effect = plan.resumeArgs.length
|
|
1582
1716
|
? 'stops the running agent, then resumes its conversation'
|
|
1583
1717
|
: opts.fresh
|
|
@@ -1589,7 +1723,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1589
1723
|
return EXIT_DRIFT;
|
|
1590
1724
|
}
|
|
1591
1725
|
log(`⚠ ${drift}`);
|
|
1592
|
-
log(
|
|
1726
|
+
log(
|
|
1727
|
+
` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`,
|
|
1728
|
+
);
|
|
1593
1729
|
log(` r restart it ${unfocused ? 'focused' : 'with the profile'}: ${effect}`);
|
|
1594
1730
|
log(` q leave it; later: ${fix}`);
|
|
1595
1731
|
const answer = deps.ask(`${opts.detach ? 'Leave it as is' : 'Attach as is'}? [Enter/r/q]: `);
|
|
@@ -1601,13 +1737,22 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1601
1737
|
log('nothing done');
|
|
1602
1738
|
return EXIT_DRIFT;
|
|
1603
1739
|
}
|
|
1740
|
+
// As --restart: the conversation the agent is in, not the folder's newest — another agent's, maybe.
|
|
1741
|
+
const live = isClaude(plan.command) && plan.resumeArgs.length > 0 ? conversationOf(current) : null;
|
|
1742
|
+
if (live) plan = { ...plan, resumeArgs: resumeId(live) };
|
|
1604
1743
|
}
|
|
1605
1744
|
|
|
1606
1745
|
if (current) {
|
|
1607
1746
|
const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
|
|
1608
1747
|
log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
|
|
1609
1748
|
tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
|
|
1610
|
-
return finish(
|
|
1749
|
+
return finish(
|
|
1750
|
+
settleResumed(plan, current.pane.paneId, deps),
|
|
1751
|
+
current.pane.session,
|
|
1752
|
+
current.pane.paneId,
|
|
1753
|
+
opts,
|
|
1754
|
+
deps,
|
|
1755
|
+
);
|
|
1611
1756
|
}
|
|
1612
1757
|
|
|
1613
1758
|
if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
|