@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/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 { loadManifest } from './manifest.ts';
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) as unknown;
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)) fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
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 (session.folders_fix !== undefined && (typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')) {
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() === '') fail(`${at}.command`, 'must be a non-empty string');
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: any) {
268
- throw new Error(`cannot read profile ${file}: ${e?.code ?? e?.message ?? 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_\/.:@%+=,-]+$/.test(s)) return s;
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.replace(/\/+$/, '').replace(/\.git$/, '').split(/[/:]/).pop();
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 own = loadManifest(input.projectRoot)?.name;
548
- if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: 'mise.toml [vdx] name' };
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(readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file), src.jsonpath);
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 as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null;
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: any) {
750
- if (e?.code === 'EAGAIN') {
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) lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
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.slice(-n).map((l) => ` | ${l}`).join('\n');
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(environment: Environment, projectRoot: string, home: string, profileDir: string): string[] {
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 === '' ? top[0] : /^\d+$/.test(a) ? top[Number(a) - 1] : a.includes('@') ? { name: top[0]!.name, email: a } : undefined;
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(`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`);
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 = plan.resumeArgs.length === 0 ? [] : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
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].filter(([, r]) => r === null).map(([m]) => `note: ${m} does not answer over ssh — what runs there is not known`);
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(({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`);
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: { id: c.id, why: `It runs ${on(host)} right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
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) return { args: [], remote: { host: m, id: c.id, reachable: reach.get(m) != null }, lines: [`continuing on ${m}: ${show(c)}`] };
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: { id: c.id, why: `Its machine is unknown (never typed in ${on(host)}); if it runs there, both machines will write to it` },
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 = [i === 0 && 'newest', c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`), !machine(c) && 'machine unknown', ...running(c)];
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)) return `note: ${m} has ${which} — continue it where you are: vdx ai ${rootOn(m)} --conversation ${c.id}`;
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: any) {
1257
- log(`vdx ai: ${e?.message ?? 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: any) {
1296
- log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? 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
- // `ls`, not `[ -d ]`: a denied folder still passes -d.
1330
- const script = dirs.map((d) => `{ [ ! -d ${shellQuote(d)} ] || ls ${shellQuote(d)} >/dev/null 2>&1 || echo ${shellQuote(d)}; }`).join('; ');
1331
- const out = tmux?.tryRun(['run-shell', script]);
1332
- if (out !== undefined && out !== null) return out.split('\n').filter(Boolean);
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: any) {
1338
- return e?.code === 'EPERM' || e?.code === 'EACCES';
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
- if (opts.fresh) plan = { ...plan, resumeArgs: [] };
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
- ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
1460
- : [];
1461
- const current = running[0];
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: no second agent, nothing to ask.
1475
- else if (current && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
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 = (deps.platform ?? process.platform) === 'darwin' ? unreadableFolders(multiplexer === 'tmux' ? tmux : null, deps.home) : [];
1488
- const folders = denied.length ? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home) : [];
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 ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
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 as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
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 (running.length > 1) {
1686
+ if (alongside) {
1687
+ const others = running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ');
1554
1688
  log(
1555
- `note: ${running.length} panes run ${plan.command} in this project: ` +
1556
- `${running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ')} — using the first`,
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 (opts.fresh) log(`note: ${current.pane.session} already runs ${plan.command} — --new replaces it only with --restart`);
1562
- if (opts.conversation) {
1563
- const live = liveConversationId(deps.env, deps.home, current.proc.pid);
1564
- if (live !== opts.conversation) {
1565
- log(
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(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
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 = `vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}`;
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(` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`);
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(settleResumed(plan, current.pane.paneId, deps), current.pane.session, current.pane.paneId, opts, deps);
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`);