@vodmal/vdx-cli 0.23.0 → 0.25.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 +6 -4
- package/package.json +5 -1
- package/rubric/vdx-rubric.yaml +32 -24
- package/src/ai.ts +268 -60
- package/src/audit.ts +19 -9
- package/src/author.ts +27 -8
- package/src/clone-checks.ts +13 -8
- package/src/conversations.ts +23 -4
- package/src/doctor.ts +9 -11
- package/src/evaluator.ts +1 -1
- package/src/facts.ts +51 -11
- package/src/gha.ts +4 -1
- package/src/hooks.ts +47 -12
- package/src/index.ts +41 -31
- package/src/init.ts +14 -50
- package/src/mcp-server.ts +2 -5
- package/src/personal-hooks.ts +16 -5
- package/src/predicates.ts +2 -5
- package/src/publish.ts +24 -11
- package/src/report.ts +1 -3
- package/src/run.ts +2 -6
- package/src/scoring.ts +2 -8
- package/src/vocabulary.ts +7 -2
package/src/ai.ts
CHANGED
|
@@ -12,6 +12,7 @@ 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,6 +21,7 @@ import {
|
|
|
20
21
|
listConversations,
|
|
21
22
|
liveConversationId,
|
|
22
23
|
liveSessionsHere,
|
|
24
|
+
liveStartedAt,
|
|
23
25
|
machineLine,
|
|
24
26
|
type MachineReport,
|
|
25
27
|
machineOf,
|
|
@@ -84,6 +86,8 @@ export interface SessionProfile {
|
|
|
84
86
|
project_names?: NameSource[];
|
|
85
87
|
/** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
|
|
86
88
|
machines?: string[];
|
|
89
|
+
/** What to do when a session cannot read ~/Downloads, ~/Desktop or ~/Documents (macOS): shown after the warning. */
|
|
90
|
+
folders_fix?: string;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/**
|
|
@@ -219,9 +223,16 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
219
223
|
if (session.machines !== undefined) {
|
|
220
224
|
if (!isStringList(session.machines)) fail('session.machines', 'must be a list of machine names');
|
|
221
225
|
session.machines.forEach((m, i) => {
|
|
222
|
-
if (!isMachineLabel(m))
|
|
226
|
+
if (!isMachineLabel(m))
|
|
227
|
+
fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
|
|
223
228
|
});
|
|
224
229
|
}
|
|
230
|
+
if (
|
|
231
|
+
session.folders_fix !== undefined &&
|
|
232
|
+
(typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')
|
|
233
|
+
) {
|
|
234
|
+
fail('session.folders_fix', 'must be a non-empty string');
|
|
235
|
+
}
|
|
225
236
|
}
|
|
226
237
|
|
|
227
238
|
const gitDoc = envDoc.git;
|
|
@@ -245,7 +256,8 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
245
256
|
if (events.length === 0 || !events.every((e) => typeof e === 'string' && GIT_HOOK_EVENTS.has(e))) {
|
|
246
257
|
fail(`${at}.event`, 'must be a git hook event (pre-commit, pre-push, …) or a list of them');
|
|
247
258
|
}
|
|
248
|
-
if (typeof h.command !== 'string' || h.command.trim() === '')
|
|
259
|
+
if (typeof h.command !== 'string' || h.command.trim() === '')
|
|
260
|
+
fail(`${at}.command`, 'must be a non-empty string');
|
|
249
261
|
if (h.when_exists !== undefined && (typeof h.when_exists !== 'string' || h.when_exists === '')) {
|
|
250
262
|
fail(`${at}.when_exists`, 'must be a path');
|
|
251
263
|
}
|
|
@@ -514,7 +526,11 @@ export function projectIdentity(projectRoot: string): string {
|
|
|
514
526
|
encoding: 'utf8',
|
|
515
527
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
516
528
|
}).trim();
|
|
517
|
-
const slug = url
|
|
529
|
+
const slug = url
|
|
530
|
+
.replace(/\/+$/, '')
|
|
531
|
+
.replace(/\.git$/, '')
|
|
532
|
+
.split(/[/:]/)
|
|
533
|
+
.pop();
|
|
518
534
|
if (slug) return slug.toLowerCase();
|
|
519
535
|
} catch {
|
|
520
536
|
/* no git or no origin */
|
|
@@ -547,7 +563,10 @@ export function resolveProjectName(input: {
|
|
|
547
563
|
src.file.replace(/^~(?=$|\/)/, input.home).replace(/\{repo\}/g, input.repo),
|
|
548
564
|
);
|
|
549
565
|
// Readers take paths relative to a project root; from "/" an absolute path stays itself.
|
|
550
|
-
const value = resolveConfigPath(
|
|
566
|
+
const value = resolveConfigPath(
|
|
567
|
+
readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file),
|
|
568
|
+
src.jsonpath,
|
|
569
|
+
);
|
|
551
570
|
if (typeof value === 'string' && value.trim()) {
|
|
552
571
|
const shown = file.startsWith(input.home + path.sep) ? `~${file.slice(input.home.length)}` : file;
|
|
553
572
|
return { name: value.trim(), source: `${shown} ${src.jsonpath}` };
|
|
@@ -670,9 +689,7 @@ export class Tmux {
|
|
|
670
689
|
}
|
|
671
690
|
|
|
672
691
|
attach(session: string, insideTmux: boolean): number {
|
|
673
|
-
const args = insideTmux
|
|
674
|
-
? ['switch-client', '-t', `=${session}`]
|
|
675
|
-
: ['attach-session', '-t', `=${session}`];
|
|
692
|
+
const args = insideTmux ? ['switch-client', '-t', `=${session}`] : ['attach-session', '-t', `=${session}`];
|
|
676
693
|
return spawnSync('tmux', this.argv(args), { stdio: 'inherit' }).status ?? 1;
|
|
677
694
|
}
|
|
678
695
|
|
|
@@ -708,6 +725,8 @@ export interface AiDeps {
|
|
|
708
725
|
processes: () => ProcInfo[];
|
|
709
726
|
/** Was this agent process started focused; default: its environment (`processFocused`). */
|
|
710
727
|
agentFocused?: (pid: number) => boolean;
|
|
728
|
+
/** Default `process.platform`: the folder check is for macOS. */
|
|
729
|
+
platform?: NodeJS.Platform;
|
|
711
730
|
sleep: (ms: number) => void;
|
|
712
731
|
confirmTimeoutMs: number;
|
|
713
732
|
/** One line from the person at the terminal, or null when there is none. */
|
|
@@ -797,7 +816,8 @@ export function renderPlan(
|
|
|
797
816
|
` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
|
|
798
817
|
` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
|
|
799
818
|
];
|
|
800
|
-
if (plan.focused)
|
|
819
|
+
if (plan.focused)
|
|
820
|
+
lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
|
|
801
821
|
for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
|
|
802
822
|
lines.push(
|
|
803
823
|
multiplexer === 'tmux'
|
|
@@ -821,7 +841,10 @@ export function renderPlan(
|
|
|
821
841
|
|
|
822
842
|
function lastLines(screen: string | null, n: number): string {
|
|
823
843
|
const lines = (screen ?? '').split('\n').filter((l) => l.trim() !== '');
|
|
824
|
-
return lines
|
|
844
|
+
return lines
|
|
845
|
+
.slice(-n)
|
|
846
|
+
.map((l) => ` | ${l}`)
|
|
847
|
+
.join('\n');
|
|
825
848
|
}
|
|
826
849
|
|
|
827
850
|
function findAgentInPane(plan: LaunchPlan, paneId: string, deps: AiDeps): ProcInfo | null {
|
|
@@ -974,7 +997,12 @@ export function remoteAiArgs(input: {
|
|
|
974
997
|
}
|
|
975
998
|
|
|
976
999
|
/** Where neighbour repos live: the profile's `git.author_pool`, else the project's parent directory. */
|
|
977
|
-
export function authorPoolDirs(
|
|
1000
|
+
export function authorPoolDirs(
|
|
1001
|
+
environment: Environment,
|
|
1002
|
+
projectRoot: string,
|
|
1003
|
+
home: string,
|
|
1004
|
+
profileDir: string,
|
|
1005
|
+
): string[] {
|
|
978
1006
|
const listed = environment.git?.author_pool;
|
|
979
1007
|
if (!listed?.length) return [path.dirname(projectRoot)];
|
|
980
1008
|
return listed.map((d) => path.resolve(profileDir, d.replace(/^~(?=$|\/)/, home)));
|
|
@@ -1018,7 +1046,13 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
|
|
|
1018
1046
|
return;
|
|
1019
1047
|
}
|
|
1020
1048
|
const pick: Author | undefined =
|
|
1021
|
-
a === ''
|
|
1049
|
+
a === ''
|
|
1050
|
+
? top[0]
|
|
1051
|
+
: /^\d+$/.test(a)
|
|
1052
|
+
? top[Number(a) - 1]
|
|
1053
|
+
: a.includes('@')
|
|
1054
|
+
? { name: top[0]!.name, email: a }
|
|
1055
|
+
: undefined;
|
|
1022
1056
|
if (pick) {
|
|
1023
1057
|
if (writeAuthor(projectRoot, pick)) deps.log(`✓ ${pick.name} <${pick.email}> → .git/config`);
|
|
1024
1058
|
else deps.log(`✗ could not write .git/config — ${command(pick)}`);
|
|
@@ -1033,7 +1067,9 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: s
|
|
|
1033
1067
|
const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
|
|
1034
1068
|
const tty = deps.interactive && !opts.detach && !opts.dryRun;
|
|
1035
1069
|
const args = remoteAiArgs({ host, from, projectPath, home: deps.home, version: deps.version ?? '', opts, tty });
|
|
1036
|
-
deps.log(
|
|
1070
|
+
deps.log(
|
|
1071
|
+
`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`,
|
|
1072
|
+
);
|
|
1037
1073
|
const res = (deps.ssh ?? runSsh)(args, false);
|
|
1038
1074
|
if (res === null) {
|
|
1039
1075
|
deps.log('vdx ai: `ssh` not found on PATH');
|
|
@@ -1106,7 +1142,10 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1106
1142
|
if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
1107
1143
|
const host = plan.host;
|
|
1108
1144
|
const same = sameMachine;
|
|
1109
|
-
const all =
|
|
1145
|
+
const all =
|
|
1146
|
+
plan.resumeArgs.length === 0
|
|
1147
|
+
? []
|
|
1148
|
+
: listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
|
|
1110
1149
|
const root = shellQuote(plan.projectRoot);
|
|
1111
1150
|
// Said from where the person sits: under `vdx ai@<host>` that is not this machine.
|
|
1112
1151
|
const atSeat = same(plan.seat, host);
|
|
@@ -1117,7 +1156,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1117
1156
|
for (const m of [...plan.machines, ...all.map((c) => c.machine)]) {
|
|
1118
1157
|
if (m && !same(m, host) && ![...reach.keys()].some((k) => same(k, m))) reach.set(m, askMachine(m, deps));
|
|
1119
1158
|
}
|
|
1120
|
-
const silent = [...reach]
|
|
1159
|
+
const silent = [...reach]
|
|
1160
|
+
.filter(([, r]) => r === null)
|
|
1161
|
+
.map(([m]) => `note: ${m} does not answer over ssh — what runs there is not known`);
|
|
1121
1162
|
const elsewhere = [...reach].flatMap(([m, r]) =>
|
|
1122
1163
|
(r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
|
|
1123
1164
|
);
|
|
@@ -1132,7 +1173,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1132
1173
|
!l.tmux ? '' : same(m, plan.seat) ? ` — attach: vdx ai ${rootOn(m)}` : ` — there: vdx ai@${m} ${root}`;
|
|
1133
1174
|
const fresh = (lines: string[]): ConversationChoice => {
|
|
1134
1175
|
if (elsewhere.length === 0) return { args: [], lines };
|
|
1135
|
-
const agents = elsewhere.map(
|
|
1176
|
+
const agents = elsewhere.map(
|
|
1177
|
+
({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`,
|
|
1178
|
+
);
|
|
1136
1179
|
return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
|
|
1137
1180
|
};
|
|
1138
1181
|
if (plan.resumeArgs.length === 0) return fresh(silent);
|
|
@@ -1160,15 +1203,26 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1160
1203
|
if (here) {
|
|
1161
1204
|
return {
|
|
1162
1205
|
args: [],
|
|
1163
|
-
askHere: {
|
|
1206
|
+
askHere: {
|
|
1207
|
+
id: c.id,
|
|
1208
|
+
why: `It runs ${on(host)} right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it`,
|
|
1209
|
+
},
|
|
1164
1210
|
lines: [`chosen: ${show(c)}`],
|
|
1165
1211
|
};
|
|
1166
1212
|
}
|
|
1167
1213
|
if (isOurs(c)) return { args: resumeId(c.id), lines: [`continuing ${show(c)}`] };
|
|
1168
|
-
if (m)
|
|
1214
|
+
if (m)
|
|
1215
|
+
return {
|
|
1216
|
+
args: [],
|
|
1217
|
+
remote: { host: m, id: c.id, reachable: reach.get(m) != null },
|
|
1218
|
+
lines: [`continuing on ${m}: ${show(c)}`],
|
|
1219
|
+
};
|
|
1169
1220
|
return {
|
|
1170
1221
|
args: [],
|
|
1171
|
-
askHere: {
|
|
1222
|
+
askHere: {
|
|
1223
|
+
id: c.id,
|
|
1224
|
+
why: `Its machine is unknown (never typed in ${on(host)}); if it runs there, both machines will write to it`,
|
|
1225
|
+
},
|
|
1172
1226
|
lines: [`chosen: ${show(c)}`],
|
|
1173
1227
|
};
|
|
1174
1228
|
};
|
|
@@ -1181,7 +1235,12 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1181
1235
|
if (canAsk && deps.interactive) {
|
|
1182
1236
|
deps.log('conversations of this project:');
|
|
1183
1237
|
found.forEach((c, i) => {
|
|
1184
|
-
const marks = [
|
|
1238
|
+
const marks = [
|
|
1239
|
+
i === 0 && 'newest',
|
|
1240
|
+
c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`),
|
|
1241
|
+
!machine(c) && 'machine unknown',
|
|
1242
|
+
...running(c),
|
|
1243
|
+
];
|
|
1185
1244
|
const m = marks.filter(Boolean);
|
|
1186
1245
|
deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
|
|
1187
1246
|
});
|
|
@@ -1208,7 +1267,8 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1208
1267
|
`${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
|
|
1209
1268
|
(now.length ? `, ${now.join(', ')}` : '');
|
|
1210
1269
|
if (runsHere(c)) return `note: ${which} — not taken: it would have two sessions`;
|
|
1211
|
-
if (m && same(m, plan.seat))
|
|
1270
|
+
if (m && same(m, plan.seat))
|
|
1271
|
+
return `note: ${m} has ${which} — continue it where you are: vdx ai ${rootOn(m)} --conversation ${c.id}`;
|
|
1212
1272
|
return m
|
|
1213
1273
|
? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
|
|
1214
1274
|
: `note: ${which} is of an unknown machine (never typed in ${on(host)}) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
|
|
@@ -1221,6 +1281,51 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1221
1281
|
return fresh([`no idle conversation ${atSeat ? 'of this machine' : `on ${host}`} — starting a new one`, ...notes]);
|
|
1222
1282
|
}
|
|
1223
1283
|
|
|
1284
|
+
/**
|
|
1285
|
+
* Several agents of the project run here and the start is about one of them: a person
|
|
1286
|
+
* picks — Enter takes the newest; without a terminal, or under --dry-run, it is the newest.
|
|
1287
|
+
* null — the person left it.
|
|
1288
|
+
*/
|
|
1289
|
+
function pickAgent(
|
|
1290
|
+
running: AgentPane[],
|
|
1291
|
+
focusedOf: (pid: number) => boolean,
|
|
1292
|
+
deps: AiDeps,
|
|
1293
|
+
canAsk: boolean,
|
|
1294
|
+
): AgentPane | null {
|
|
1295
|
+
if (running.length === 1) return running[0]!;
|
|
1296
|
+
const startedAt = (a: AgentPane) => liveStartedAt(deps.env, deps.home, a.proc.pid);
|
|
1297
|
+
const sorted = [...running].sort((a, b) => (startedAt(b) ?? 0) - (startedAt(a) ?? 0));
|
|
1298
|
+
const show = (a: AgentPane) => {
|
|
1299
|
+
const id = liveConversationId(deps.env, deps.home, a.proc.pid);
|
|
1300
|
+
const at = startedAt(a);
|
|
1301
|
+
const marks = [
|
|
1302
|
+
id && `conversation ${id.slice(0, 8)}`,
|
|
1303
|
+
focusedOf(a.proc.pid) && 'focused',
|
|
1304
|
+
at !== null && `started ${describeAge(new Date(at))}`,
|
|
1305
|
+
].filter(Boolean);
|
|
1306
|
+
return `${a.pane.session} ${a.pane.paneId}${marks.length ? ` — ${marks.join(', ')}` : ''}`;
|
|
1307
|
+
};
|
|
1308
|
+
// Start times come from Claude Code's session files; without them, tmux's order.
|
|
1309
|
+
const newest = startedAt(sorted[0]!) !== null ? 'the newest' : 'the first';
|
|
1310
|
+
if (!canAsk || !deps.interactive) {
|
|
1311
|
+
deps.log(`note: ${sorted.length} agents of this project run here — taking ${newest}, ${show(sorted[0]!)}`);
|
|
1312
|
+
return sorted[0]!;
|
|
1313
|
+
}
|
|
1314
|
+
deps.log('agents of this project running here:');
|
|
1315
|
+
sorted.forEach((a, i) =>
|
|
1316
|
+
deps.log(` ${i + 1}) ${show(a)}${i === 0 && newest === 'the newest' ? ' ← newest' : ''}`),
|
|
1317
|
+
);
|
|
1318
|
+
for (let tries = 0; tries < 2; tries++) {
|
|
1319
|
+
const answer = deps.ask('Which one? [1]: ');
|
|
1320
|
+
if (answer === null) return null;
|
|
1321
|
+
const a = answer.trim();
|
|
1322
|
+
const agent = a === '' ? sorted[0] : /^\d+$/.test(a) ? sorted[Number(a) - 1] : undefined;
|
|
1323
|
+
if (agent) return agent;
|
|
1324
|
+
deps.log(` 1–${sorted.length}`);
|
|
1325
|
+
}
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1224
1329
|
interface Prepared {
|
|
1225
1330
|
projectRoot: string;
|
|
1226
1331
|
environment: Environment;
|
|
@@ -1308,6 +1413,59 @@ export function processFocused(pid: number): boolean {
|
|
|
1308
1413
|
}
|
|
1309
1414
|
}
|
|
1310
1415
|
|
|
1416
|
+
/** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
|
|
1417
|
+
export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* The guarded folders an agent started here could not read. Through the tmux server
|
|
1421
|
+
* (`run-shell`): its sessions have the server's rights, not vdx's. Without a server, or
|
|
1422
|
+
* without tmux, this process's own — the agent gets the rights of this terminal. A folder
|
|
1423
|
+
* that does not exist is skipped.
|
|
1424
|
+
*/
|
|
1425
|
+
export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
|
|
1426
|
+
const dirs = GUARDED_FOLDERS.map((f) => path.join(home, f));
|
|
1427
|
+
if (tmux) {
|
|
1428
|
+
// `ls`, not `[ -d ]`: a denied folder still passes -d. The answer comes back in a
|
|
1429
|
+
// file: tmux before 3.5 prints run-shell's output into a pane, not to the caller.
|
|
1430
|
+
const file = path.join(os.tmpdir(), `vdx-folders-${process.pid}-${Date.now()}`);
|
|
1431
|
+
const checks = dirs.map(
|
|
1432
|
+
(d) => `{ [ ! -d ${shellQuote(d)} ] || ls ${shellQuote(d)} >/dev/null 2>&1 || echo ${shellQuote(d)}; }`,
|
|
1433
|
+
);
|
|
1434
|
+
try {
|
|
1435
|
+
if (tmux.tryRun(['run-shell', `{ ${checks.join('; ')}; } > ${shellQuote(file)}`]) !== null) {
|
|
1436
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
1437
|
+
}
|
|
1438
|
+
} catch {
|
|
1439
|
+
// no answer from the server: this process's own check
|
|
1440
|
+
} finally {
|
|
1441
|
+
fs.rmSync(file, { force: true });
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return dirs.filter((d) => {
|
|
1445
|
+
try {
|
|
1446
|
+
fs.readdirSync(d);
|
|
1447
|
+
return false;
|
|
1448
|
+
} catch (e: any) {
|
|
1449
|
+
return e?.code === 'EPERM' || e?.code === 'EACCES';
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** One warning and its fix — the profile's `session.folders_fix`, else what works anywhere. */
|
|
1455
|
+
export function foldersWarning(denied: string[], viaTmux: boolean, fix: string | undefined, home: string): string[] {
|
|
1456
|
+
const names = denied.map((d) => (d.startsWith(home + path.sep) ? `~${d.slice(home.length)}` : d)).join(', ');
|
|
1457
|
+
return [
|
|
1458
|
+
`⚠ this session cannot read ${names} — macOS holds the denial ${viaTmux ? 'in the tmux server' : 'for this terminal'}; ` +
|
|
1459
|
+
`files there are out of the agent's reach`,
|
|
1460
|
+
` fix: ${
|
|
1461
|
+
fix ??
|
|
1462
|
+
(viaTmux
|
|
1463
|
+
? 'restart the tmux server (it closes every session)'
|
|
1464
|
+
: 'allow this terminal app in System Settings → Privacy & Security → Files and Folders')
|
|
1465
|
+
}`,
|
|
1466
|
+
];
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1311
1469
|
/** The agent's command line; a focused one carries VDX_FOCUSED=1 in front, for the shell in its tmux pane. */
|
|
1312
1470
|
function agentLine(plan: LaunchPlan, args: string[]): string {
|
|
1313
1471
|
return `${plan.focused ? `${FOCUSED_ENV_VAR}=1 ` : ''}${commandLine(plan.command, args)}`;
|
|
@@ -1398,8 +1556,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1398
1556
|
log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
|
|
1399
1557
|
return EXIT_USAGE;
|
|
1400
1558
|
}
|
|
1401
|
-
// A start continues the last conversation unless asked for a new one.
|
|
1402
|
-
|
|
1559
|
+
// A start continues the last conversation unless asked for a new one; a focused session is a new one.
|
|
1560
|
+
const wantsNew = (opts.fresh || opts.focused === true) && !opts.conversation;
|
|
1561
|
+
if (wantsNew) plan = { ...plan, resumeArgs: [] };
|
|
1403
1562
|
|
|
1404
1563
|
let multiplexer = plan.multiplexer;
|
|
1405
1564
|
if (multiplexer === 'tmux' && !tmux.available()) {
|
|
@@ -1408,13 +1567,27 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1408
1567
|
}
|
|
1409
1568
|
|
|
1410
1569
|
const running =
|
|
1411
|
-
multiplexer === 'tmux'
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1570
|
+
multiplexer === 'tmux' ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command) : [];
|
|
1571
|
+
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1572
|
+
const conversationOf = (a: AgentPane) => liveConversationId(deps.env, deps.home, a.proc.pid);
|
|
1573
|
+
// Which running agent the start is about. A new conversation, or one no agent here is in, starts
|
|
1574
|
+
// next to them; --restart replaces one. An agent whose conversation vdx cannot read may be in the
|
|
1575
|
+
// one asked for: no second agent in it blindly.
|
|
1576
|
+
const inConversation = opts.conversation ? running.find((a) => conversationOf(a) === opts.conversation) : undefined;
|
|
1577
|
+
const unreadable = opts.conversation && !inConversation ? running.find((a) => conversationOf(a) === null) : undefined;
|
|
1578
|
+
const alongside =
|
|
1579
|
+
running.length > 0 && !opts.restart && !inConversation && !unreadable && (wantsNew || !!opts.conversation);
|
|
1580
|
+
let current = inConversation ?? unreadable;
|
|
1581
|
+
if (!current && !alongside && running.length > 0) {
|
|
1582
|
+
const picked = pickAgent(running, focusedOf, deps, !opts.dryRun);
|
|
1583
|
+
if (picked === null) {
|
|
1584
|
+
log('nothing done');
|
|
1585
|
+
return EXIT_LAUNCH_FAILED;
|
|
1586
|
+
}
|
|
1587
|
+
current = picked;
|
|
1588
|
+
}
|
|
1415
1589
|
// A running agent keeps its mode: plain `vdx ai` takes a focused one as it is,
|
|
1416
1590
|
// and the rules it leaves out are not missing from it.
|
|
1417
|
-
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1418
1591
|
const runningFocused = current ? focusedOf(current.proc.pid) : false;
|
|
1419
1592
|
if (current && !opts.restart && runningFocused && !plan.focused) {
|
|
1420
1593
|
plan = { ...prepared.replan(true), resumeArgs: plan.resumeArgs };
|
|
@@ -1424,8 +1597,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1424
1597
|
let choice: ConversationChoice | null = null;
|
|
1425
1598
|
if (!current || opts.restart) {
|
|
1426
1599
|
if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
|
|
1427
|
-
// A restart into a new conversation replaces the agent here
|
|
1428
|
-
|
|
1600
|
+
// A restart into a new conversation replaces the agent here, and a new one next to an agent here
|
|
1601
|
+
// is what was asked for: the other machines are not asked.
|
|
1602
|
+
else if ((current || alongside) && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
|
|
1429
1603
|
else if (current && isClaude(plan.command)) {
|
|
1430
1604
|
const live = liveConversationId(deps.env, deps.home, current.proc.pid);
|
|
1431
1605
|
choice = live
|
|
@@ -1436,16 +1610,28 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1436
1610
|
}
|
|
1437
1611
|
|
|
1438
1612
|
const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
|
|
1613
|
+
// Said before a start or an attach, never in the way: the agent runs, without those files.
|
|
1614
|
+
const denied =
|
|
1615
|
+
(deps.platform ?? process.platform) === 'darwin'
|
|
1616
|
+
? unreadableFolders(multiplexer === 'tmux' ? tmux : null, deps.home)
|
|
1617
|
+
: [];
|
|
1618
|
+
const folders = denied.length
|
|
1619
|
+
? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home)
|
|
1620
|
+
: [];
|
|
1439
1621
|
if (opts.dryRun) {
|
|
1440
1622
|
deps.out(renderPlan(plan, multiplexer, running, focusedOf));
|
|
1441
1623
|
for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
|
|
1624
|
+
for (const l of folders) deps.out(` ${l}\n`);
|
|
1442
1625
|
ensureAuthor(projectRoot, poolDirs, deps, true);
|
|
1443
1626
|
return EXIT_OK;
|
|
1444
1627
|
}
|
|
1445
1628
|
for (const l of choice?.lines ?? []) log(l);
|
|
1629
|
+
for (const l of folders) log(l);
|
|
1446
1630
|
if (choice?.abort) return EXIT_LAUNCH_FAILED;
|
|
1447
1631
|
if (choice?.elsewhere && !current) {
|
|
1448
|
-
const answer = deps.interactive
|
|
1632
|
+
const answer = deps.interactive
|
|
1633
|
+
? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `)
|
|
1634
|
+
: null;
|
|
1449
1635
|
if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
|
|
1450
1636
|
log('nothing started');
|
|
1451
1637
|
return EXIT_LAUNCH_FAILED;
|
|
@@ -1498,54 +1684,76 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1498
1684
|
return res.status ?? 1;
|
|
1499
1685
|
}
|
|
1500
1686
|
|
|
1501
|
-
if (
|
|
1687
|
+
if (alongside) {
|
|
1688
|
+
const others = running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ');
|
|
1502
1689
|
log(
|
|
1503
|
-
`note: ${running.length}
|
|
1504
|
-
|
|
1690
|
+
`note: ${others} keep${running.length === 1 ? 's' : ''} running ${plan.command} — starting another one next to it, ` +
|
|
1691
|
+
`in ${opts.conversation ? `conversation ${opts.conversation.slice(0, 8)}` : 'a new conversation'}`,
|
|
1505
1692
|
);
|
|
1506
1693
|
}
|
|
1507
1694
|
|
|
1508
1695
|
if (current && !opts.restart) {
|
|
1509
|
-
if (
|
|
1510
|
-
if (opts.conversation) {
|
|
1511
|
-
const live = liveConversationId(deps.env, deps.home, current.proc.pid);
|
|
1512
|
-
if (live !== opts.conversation) {
|
|
1513
|
-
log(
|
|
1514
|
-
`note: ${current.pane.session} is in ${live ? `conversation ${live.slice(0, 8)}` : 'another conversation'} — ` +
|
|
1515
|
-
`to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
|
|
1516
|
-
);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
if (plan.focused && !runningFocused) {
|
|
1520
|
-
log(`✗ ${current.pane.session}: ${plan.command} is running, not focused`);
|
|
1696
|
+
if (current === unreadable) {
|
|
1521
1697
|
log(
|
|
1522
|
-
`
|
|
1523
|
-
|
|
1698
|
+
`note: ${current.pane.session} runs ${plan.command} in a conversation vdx cannot read — attaching to it; ` +
|
|
1699
|
+
`to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
|
|
1524
1700
|
);
|
|
1525
|
-
return EXIT_DRIFT;
|
|
1526
1701
|
}
|
|
1527
|
-
const
|
|
1528
|
-
|
|
1529
|
-
|
|
1702
|
+
const unfocused = plan.focused && !runningFocused;
|
|
1703
|
+
const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
|
|
1704
|
+
if (!unfocused && missing.length === 0) {
|
|
1530
1705
|
log(
|
|
1531
|
-
|
|
1532
|
-
(plan.resumeArgs.length
|
|
1533
|
-
? ` (stops the running agent, then resumes its conversation)`
|
|
1534
|
-
: opts.fresh
|
|
1535
|
-
? ' (stops the running agent and starts a new conversation)'
|
|
1536
|
-
: ' (stops the running agent; the conversation is not resumed — the profile has no resume_args)'),
|
|
1706
|
+
`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`,
|
|
1537
1707
|
);
|
|
1708
|
+
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1709
|
+
}
|
|
1710
|
+
// Drift. In a terminal the person picks: the agent as it runs, or a restart
|
|
1711
|
+
// with the profile; without one nothing is touched, and the fix is named.
|
|
1712
|
+
const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
|
|
1713
|
+
const fix =
|
|
1714
|
+
`vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
|
|
1715
|
+
(opts.conversation ? ` --conversation ${opts.conversation}` : '');
|
|
1716
|
+
const effect = plan.resumeArgs.length
|
|
1717
|
+
? 'stops the running agent, then resumes its conversation'
|
|
1718
|
+
: opts.fresh
|
|
1719
|
+
? 'stops the running agent and starts a new conversation'
|
|
1720
|
+
: 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
|
|
1721
|
+
if (!deps.interactive) {
|
|
1722
|
+
log(`✗ ${drift}`);
|
|
1723
|
+
log(` ${unfocused ? 'to focus it' : 'to apply the profile'}: ${fix} (${effect})`);
|
|
1538
1724
|
return EXIT_DRIFT;
|
|
1539
1725
|
}
|
|
1540
|
-
log(
|
|
1541
|
-
|
|
1726
|
+
log(`⚠ ${drift}`);
|
|
1727
|
+
log(
|
|
1728
|
+
` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`,
|
|
1729
|
+
);
|
|
1730
|
+
log(` r restart it ${unfocused ? 'focused' : 'with the profile'}: ${effect}`);
|
|
1731
|
+
log(` q leave it; later: ${fix}`);
|
|
1732
|
+
const answer = deps.ask(`${opts.detach ? 'Leave it as is' : 'Attach as is'}? [Enter/r/q]: `);
|
|
1733
|
+
if (answer === '') {
|
|
1734
|
+
log(`→ ${current.pane.session}: as is — ${current.proc.args}`);
|
|
1735
|
+
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1736
|
+
}
|
|
1737
|
+
if (answer?.toLowerCase() !== 'r') {
|
|
1738
|
+
log('nothing done');
|
|
1739
|
+
return EXIT_DRIFT;
|
|
1740
|
+
}
|
|
1741
|
+
// As --restart: the conversation the agent is in, not the folder's newest — another agent's, maybe.
|
|
1742
|
+
const live = isClaude(plan.command) && plan.resumeArgs.length > 0 ? conversationOf(current) : null;
|
|
1743
|
+
if (live) plan = { ...plan, resumeArgs: resumeId(live) };
|
|
1542
1744
|
}
|
|
1543
1745
|
|
|
1544
1746
|
if (current) {
|
|
1545
1747
|
const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
|
|
1546
1748
|
log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
|
|
1547
1749
|
tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
|
|
1548
|
-
return finish(
|
|
1750
|
+
return finish(
|
|
1751
|
+
settleResumed(plan, current.pane.paneId, deps),
|
|
1752
|
+
current.pane.session,
|
|
1753
|
+
current.pane.paneId,
|
|
1754
|
+
opts,
|
|
1755
|
+
deps,
|
|
1756
|
+
);
|
|
1549
1757
|
}
|
|
1550
1758
|
|
|
1551
1759
|
if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
|
package/src/audit.ts
CHANGED
|
@@ -28,12 +28,11 @@ export interface AuditResult {
|
|
|
28
28
|
* 3. fallback на root ctx.
|
|
29
29
|
*
|
|
30
30
|
* Возвращает `{ ctx, relPath }` где relPath — относительный путь либо null
|
|
31
|
-
* (если subpackage не разрезолвился — будем использовать root).
|
|
31
|
+
* (если subpackage не разрезолвился — будем использовать root). Файл, которого
|
|
32
|
+
* нет в subpackage, ищется в корне (`fallbackRoot`): `.github/` и `.gitignore`
|
|
33
|
+
* у репо одни.
|
|
32
34
|
*/
|
|
33
|
-
function resolveSubpackageCtx(
|
|
34
|
-
ctx: Ctx,
|
|
35
|
-
manifest: VdxManifest | null,
|
|
36
|
-
): { ctx: Ctx; relPath: string | null } {
|
|
35
|
+
function resolveSubpackageCtx(ctx: Ctx, manifest: VdxManifest | null): { ctx: Ctx; relPath: string | null } {
|
|
37
36
|
let relPath: string | null = null;
|
|
38
37
|
let subpackageStack: string | null = null;
|
|
39
38
|
|
|
@@ -44,9 +43,7 @@ function resolveSubpackageCtx(
|
|
|
44
43
|
relPath = p;
|
|
45
44
|
subpackageStack = stackForDir(abs);
|
|
46
45
|
} else {
|
|
47
|
-
process.stderr.write(
|
|
48
|
-
`warning: primary_subpackage "${p}" does not exist — falling back to root\n`,
|
|
49
|
-
);
|
|
46
|
+
process.stderr.write(`warning: primary_subpackage "${p}" does not exist — falling back to root\n`);
|
|
50
47
|
}
|
|
51
48
|
} else {
|
|
52
49
|
const subs = findSubPackages(ctx.projectRoot);
|
|
@@ -68,11 +65,23 @@ function resolveSubpackageCtx(
|
|
|
68
65
|
projectRoot: path.join(ctx.projectRoot, relPath),
|
|
69
66
|
stack: subpackageStack ?? ctx.stack,
|
|
70
67
|
cache: new Map(),
|
|
68
|
+
fallbackRoot: ctx.projectRoot,
|
|
71
69
|
},
|
|
72
70
|
relPath,
|
|
73
71
|
};
|
|
74
72
|
}
|
|
75
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Ctx осей без `applies_to`. Корень мета-репо (без своего манифеста стека)
|
|
76
|
+
* дочитывает недостающие файлы из subpackage: `engines` в `cli/package.json`
|
|
77
|
+
* — тоже про воспроизводимость всего репо. У корня со своим манифестом
|
|
78
|
+
* подстановки нет.
|
|
79
|
+
*/
|
|
80
|
+
function rootAxisCtx(ctx: Ctx, subpackage: Ctx, relPath: string | null): Ctx {
|
|
81
|
+
if (!relPath || stackForDir(ctx.projectRoot) !== null) return ctx;
|
|
82
|
+
return { ...ctx, cache: new Map(), fallbackRoot: subpackage.projectRoot };
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
export function audit(
|
|
77
86
|
rubric: Rubric,
|
|
78
87
|
ctx: Ctx,
|
|
@@ -92,6 +101,7 @@ export function audit(
|
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
const { ctx: subpackageCtx, relPath: subpackagePath } = resolveSubpackageCtx(ctx, manifest);
|
|
104
|
+
const rootCtx = rootAxisCtx(ctx, subpackageCtx, subpackagePath);
|
|
95
105
|
|
|
96
106
|
const perAxis: AuditResult['per_axis'] = [];
|
|
97
107
|
for (const axis of rubric.axes) {
|
|
@@ -106,7 +116,7 @@ export function audit(
|
|
|
106
116
|
});
|
|
107
117
|
continue;
|
|
108
118
|
}
|
|
109
|
-
const evalCtx = axis.applies_to ? subpackageCtx :
|
|
119
|
+
const evalCtx = axis.applies_to ? subpackageCtx : rootCtx;
|
|
110
120
|
if (axis.applies_to && !axis.applies_to.includes(evalCtx.stack)) {
|
|
111
121
|
perAxis.push({
|
|
112
122
|
axis_id: axis.id,
|