@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/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)) fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
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() === '') fail(`${at}.command`, 'must be a non-empty string');
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.replace(/\/+$/, '').replace(/\.git$/, '').split(/[/:]/).pop();
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(readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file), src.jsonpath);
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) lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
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.slice(-n).map((l) => ` | ${l}`).join('\n');
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(environment: Environment, projectRoot: string, home: string, profileDir: string): string[] {
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 === '' ? top[0] : /^\d+$/.test(a) ? top[Number(a) - 1] : a.includes('@') ? { name: top[0]!.name, email: a } : undefined;
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(`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`);
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 = plan.resumeArgs.length === 0 ? [] : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
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].filter(([, r]) => r === null).map(([m]) => `note: ${m} does not answer over ssh — what runs there is not known`);
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(({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`);
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: { id: c.id, why: `It runs ${on(host)} right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
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) return { args: [], remote: { host: m, id: c.id, reachable: reach.get(m) != null }, lines: [`continuing on ${m}: ${show(c)}`] };
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: { id: c.id, why: `Its machine is unknown (never typed in ${on(host)}); if it runs there, both machines will write to it` },
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 = [i === 0 && 'newest', c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`), !machine(c) && 'machine unknown', ...running(c)];
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)) return `note: ${m} has ${which} — continue it where you are: vdx ai ${rootOn(m)} --conversation ${c.id}`;
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
- if (opts.fresh) plan = { ...plan, resumeArgs: [] };
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
- ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
1413
- : [];
1414
- const current = running[0];
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: no second agent, nothing to ask.
1428
- else if (current && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
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 ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
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 (running.length > 1) {
1687
+ if (alongside) {
1688
+ const others = running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ');
1502
1689
  log(
1503
- `note: ${running.length} panes run ${plan.command} in this project: ` +
1504
- `${running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ')} — using the first`,
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 (opts.fresh) log(`note: ${current.pane.session} already runs ${plan.command} — --new replaces it only with --restart`);
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
- ` to focus it: vdx ai --restart --focused ${shellQuote(projectRoot)}` +
1523
- (plan.resumeArgs.length ? ' (stops the running agent, then resumes its conversation)' : ' (stops the running agent)'),
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 missing = missingArgs(current.proc.args, plan.args);
1528
- if (missing.length > 0) {
1529
- log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
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
- ` to apply the profile: vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
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(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
1541
- return finish(true, current.pane.session, current.pane.paneId, opts, deps);
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(settleResumed(plan, current.pane.paneId, deps), current.pane.session, current.pane.paneId, opts, deps);
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 : ctx;
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,