@vodmal/vdx-cli 0.24.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 CHANGED
@@ -77,8 +77,9 @@ vdx test # → mise run test (which calls `vitest run` or whatever wa
77
77
  # 3. Ship a new minor release of a Node lib
78
78
  vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP or browser) → commit + tag → push → wait for the registry
79
79
 
80
- # 4. Start the agent here, continuing its last conversation (--new: a new one;
81
- # another machine's conversation is continued on that machine);
80
+ # 4. Start the agent here, continuing its last conversation (--new: a new one,
81
+ # next to a running agent; another machine's conversation is continued on
82
+ # that machine);
82
83
  # re-run to attach; an agent without the profile's flags is a question:
83
84
  # Enter attaches as is, r restarts it; --restart applies a changed profile
84
85
  vdx ai
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
5
5
  "keywords": [
6
6
  "vdx",
@@ -39,6 +39,9 @@
39
39
  "audit": "tsx src/index.ts audit",
40
40
  "smoke": "bash smoke.sh",
41
41
  "typecheck": "tsc --noEmit",
42
+ "format:check": "prettier --check .",
43
+ "check": "npm run typecheck && npm run format:check",
44
+ "fix": "prettier --write .",
42
45
  "test": "vitest run",
43
46
  "test:unit": "vitest run tests/unit",
44
47
  "coverage": "vitest run --coverage"
@@ -58,6 +61,7 @@
58
61
  "@types/marked-terminal": "^6.1.1",
59
62
  "@types/node": "^22.0.0",
60
63
  "@vitest/coverage-v8": "^4.1.7",
64
+ "prettier": "^3.9.10",
61
65
  "typescript": "^5.6.0",
62
66
  "vitest": "^4.1.7"
63
67
  },
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.3.0"
14
+ version: "1.4.0"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
@@ -35,7 +35,7 @@ axes:
35
35
 
36
36
  - id: lifecycle-interface
37
37
  class: critical
38
- description: "Стандартный словарь глаголов up/down/build/test/check/fix"
38
+ description: "Стандартный словарь глаголов up/down/build/test/check/fix (up/down — не у библиотек)"
39
39
  storage: level
40
40
  default_target: L4
41
41
  fact_sources: [mise.toml, composer.json, package.json, Makefile]
@@ -59,14 +59,39 @@ axes:
59
59
  - has_task: check
60
60
  - has_task: fix
61
61
  L3:
62
+ # v1.4.0: up/down — глаголы приложения. Библиотека (lib-intent, D12)
63
+ # выпускается, а не поднимается: от неё L3 требует только остальные четыре.
62
64
  requires:
63
65
  all_of:
64
- - has_task: up
65
- - has_task: down
66
66
  - has_task: build
67
67
  - has_task: test
68
68
  - has_task: check
69
69
  - has_task: fix
70
+ - any_of:
71
+ # lib-intent — проект заявляет, что публикует артефакт (O35).
72
+ # Одно определение на сет: release-artifact.applies_when — ссылка сюда.
73
+ - &lib_intent
74
+ any_of:
75
+ # Node-сторона: НЕ private И есть хоть один entry-point/publish-signal.
76
+ - all_of:
77
+ - has_file: package.json
78
+ - not:
79
+ config_value: { path: package.json, jsonpath: "private", equals: true }
80
+ - any_of:
81
+ - config_value: { path: package.json, jsonpath: "publishConfig", op: present }
82
+ - config_value: { path: package.json, jsonpath: "bin", op: present }
83
+ - config_value: { path: package.json, jsonpath: "main", op: present }
84
+ - config_value: { path: package.json, jsonpath: "exports", op: present }
85
+ - config_value: { path: package.json, jsonpath: "module", op: present }
86
+ # PHP-сторона: composer.json есть, name задан, type ≠ project (project = app).
87
+ - all_of:
88
+ - has_file: composer.json
89
+ - config_value: { path: composer.json, jsonpath: "name", op: present }
90
+ - not:
91
+ config_value: { path: composer.json, jsonpath: "type", op: equals, equals: "project" }
92
+ - all_of:
93
+ - has_task: up
94
+ - has_task: down
70
95
  L4:
71
96
  requires:
72
97
  any_of:
@@ -564,26 +589,9 @@ axes:
564
589
  applies_to: [node, php, ruby, python]
565
590
  # O35 (v0.3.1): ось применяется только если проект декларирует намерение
566
591
  # публиковать артефакт. Чистые app'ы (private:true в package.json или
567
- # composer type:project) → excluded.
568
- applies_when:
569
- any_of:
570
- # Node-сторона: НЕ private И есть хоть один entry-point/publish-signal.
571
- - all_of:
572
- - has_file: package.json
573
- - not:
574
- config_value: { path: package.json, jsonpath: "private", equals: true }
575
- - any_of:
576
- - config_value: { path: package.json, jsonpath: "publishConfig", op: present }
577
- - config_value: { path: package.json, jsonpath: "bin", op: present }
578
- - config_value: { path: package.json, jsonpath: "main", op: present }
579
- - config_value: { path: package.json, jsonpath: "exports", op: present }
580
- - config_value: { path: package.json, jsonpath: "module", op: present }
581
- # PHP-сторона: composer.json есть, name задан, type ≠ project (project = app).
582
- - all_of:
583
- - has_file: composer.json
584
- - config_value: { path: composer.json, jsonpath: "name", op: present }
585
- - not:
586
- config_value: { path: composer.json, jsonpath: "type", op: equals, equals: "project" }
592
+ # composer type:project) → excluded. Определение lib-intent — в
593
+ # lifecycle-interface.L3 (якорь, v1.4.0).
594
+ applies_when: *lib_intent
587
595
  storage: level
588
596
  default_target: L4
589
597
  fact_sources:
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,
@@ -221,10 +223,14 @@ export function parseEnvironment(text: string, source: string): Environment {
221
223
  if (session.machines !== undefined) {
222
224
  if (!isStringList(session.machines)) fail('session.machines', 'must be a list of machine names');
223
225
  session.machines.forEach((m, i) => {
224
- 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 -');
225
228
  });
226
229
  }
227
- if (session.folders_fix !== undefined && (typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')) {
230
+ if (
231
+ session.folders_fix !== undefined &&
232
+ (typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')
233
+ ) {
228
234
  fail('session.folders_fix', 'must be a non-empty string');
229
235
  }
230
236
  }
@@ -250,7 +256,8 @@ export function parseEnvironment(text: string, source: string): Environment {
250
256
  if (events.length === 0 || !events.every((e) => typeof e === 'string' && GIT_HOOK_EVENTS.has(e))) {
251
257
  fail(`${at}.event`, 'must be a git hook event (pre-commit, pre-push, …) or a list of them');
252
258
  }
253
- 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');
254
261
  if (h.when_exists !== undefined && (typeof h.when_exists !== 'string' || h.when_exists === '')) {
255
262
  fail(`${at}.when_exists`, 'must be a path');
256
263
  }
@@ -519,7 +526,11 @@ export function projectIdentity(projectRoot: string): string {
519
526
  encoding: 'utf8',
520
527
  stdio: ['ignore', 'pipe', 'ignore'],
521
528
  }).trim();
522
- const slug = url.replace(/\/+$/, '').replace(/\.git$/, '').split(/[/:]/).pop();
529
+ const slug = url
530
+ .replace(/\/+$/, '')
531
+ .replace(/\.git$/, '')
532
+ .split(/[/:]/)
533
+ .pop();
523
534
  if (slug) return slug.toLowerCase();
524
535
  } catch {
525
536
  /* no git or no origin */
@@ -552,7 +563,10 @@ export function resolveProjectName(input: {
552
563
  src.file.replace(/^~(?=$|\/)/, input.home).replace(/\{repo\}/g, input.repo),
553
564
  );
554
565
  // 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);
566
+ const value = resolveConfigPath(
567
+ readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file),
568
+ src.jsonpath,
569
+ );
556
570
  if (typeof value === 'string' && value.trim()) {
557
571
  const shown = file.startsWith(input.home + path.sep) ? `~${file.slice(input.home.length)}` : file;
558
572
  return { name: value.trim(), source: `${shown} ${src.jsonpath}` };
@@ -675,9 +689,7 @@ export class Tmux {
675
689
  }
676
690
 
677
691
  attach(session: string, insideTmux: boolean): number {
678
- const args = insideTmux
679
- ? ['switch-client', '-t', `=${session}`]
680
- : ['attach-session', '-t', `=${session}`];
692
+ const args = insideTmux ? ['switch-client', '-t', `=${session}`] : ['attach-session', '-t', `=${session}`];
681
693
  return spawnSync('tmux', this.argv(args), { stdio: 'inherit' }).status ?? 1;
682
694
  }
683
695
 
@@ -804,7 +816,8 @@ export function renderPlan(
804
816
  ` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
805
817
  ` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
806
818
  ];
807
- 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(', ') : '—'}`);
808
821
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
809
822
  lines.push(
810
823
  multiplexer === 'tmux'
@@ -828,7 +841,10 @@ export function renderPlan(
828
841
 
829
842
  function lastLines(screen: string | null, n: number): string {
830
843
  const lines = (screen ?? '').split('\n').filter((l) => l.trim() !== '');
831
- return lines.slice(-n).map((l) => ` | ${l}`).join('\n');
844
+ return lines
845
+ .slice(-n)
846
+ .map((l) => ` | ${l}`)
847
+ .join('\n');
832
848
  }
833
849
 
834
850
  function findAgentInPane(plan: LaunchPlan, paneId: string, deps: AiDeps): ProcInfo | null {
@@ -981,7 +997,12 @@ export function remoteAiArgs(input: {
981
997
  }
982
998
 
983
999
  /** 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[] {
1000
+ export function authorPoolDirs(
1001
+ environment: Environment,
1002
+ projectRoot: string,
1003
+ home: string,
1004
+ profileDir: string,
1005
+ ): string[] {
985
1006
  const listed = environment.git?.author_pool;
986
1007
  if (!listed?.length) return [path.dirname(projectRoot)];
987
1008
  return listed.map((d) => path.resolve(profileDir, d.replace(/^~(?=$|\/)/, home)));
@@ -1025,7 +1046,13 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
1025
1046
  return;
1026
1047
  }
1027
1048
  const pick: Author | undefined =
1028
- 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;
1029
1056
  if (pick) {
1030
1057
  if (writeAuthor(projectRoot, pick)) deps.log(`✓ ${pick.name} <${pick.email}> → .git/config`);
1031
1058
  else deps.log(`✗ could not write .git/config — ${command(pick)}`);
@@ -1040,7 +1067,9 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: s
1040
1067
  const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
1041
1068
  const tty = deps.interactive && !opts.detach && !opts.dryRun;
1042
1069
  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}` : ''}`);
1070
+ deps.log(
1071
+ `→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`,
1072
+ );
1044
1073
  const res = (deps.ssh ?? runSsh)(args, false);
1045
1074
  if (res === null) {
1046
1075
  deps.log('vdx ai: `ssh` not found on PATH');
@@ -1113,7 +1142,10 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1113
1142
  if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1114
1143
  const host = plan.host;
1115
1144
  const same = sameMachine;
1116
- 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 });
1117
1149
  const root = shellQuote(plan.projectRoot);
1118
1150
  // Said from where the person sits: under `vdx ai@<host>` that is not this machine.
1119
1151
  const atSeat = same(plan.seat, host);
@@ -1124,7 +1156,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1124
1156
  for (const m of [...plan.machines, ...all.map((c) => c.machine)]) {
1125
1157
  if (m && !same(m, host) && ![...reach.keys()].some((k) => same(k, m))) reach.set(m, askMachine(m, deps));
1126
1158
  }
1127
- 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`);
1128
1162
  const elsewhere = [...reach].flatMap(([m, r]) =>
1129
1163
  (r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
1130
1164
  );
@@ -1139,7 +1173,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1139
1173
  !l.tmux ? '' : same(m, plan.seat) ? ` — attach: vdx ai ${rootOn(m)}` : ` — there: vdx ai@${m} ${root}`;
1140
1174
  const fresh = (lines: string[]): ConversationChoice => {
1141
1175
  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)}`);
1176
+ const agents = elsewhere.map(
1177
+ ({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`,
1178
+ );
1143
1179
  return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
1144
1180
  };
1145
1181
  if (plan.resumeArgs.length === 0) return fresh(silent);
@@ -1167,15 +1203,26 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1167
1203
  if (here) {
1168
1204
  return {
1169
1205
  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` },
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
+ },
1171
1210
  lines: [`chosen: ${show(c)}`],
1172
1211
  };
1173
1212
  }
1174
1213
  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)}`] };
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
+ };
1176
1220
  return {
1177
1221
  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` },
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
+ },
1179
1226
  lines: [`chosen: ${show(c)}`],
1180
1227
  };
1181
1228
  };
@@ -1188,7 +1235,12 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1188
1235
  if (canAsk && deps.interactive) {
1189
1236
  deps.log('conversations of this project:');
1190
1237
  found.forEach((c, i) => {
1191
- 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
+ ];
1192
1244
  const m = marks.filter(Boolean);
1193
1245
  deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
1194
1246
  });
@@ -1215,7 +1267,8 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1215
1267
  `${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
1216
1268
  (now.length ? `, ${now.join(', ')}` : '');
1217
1269
  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}`;
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}`;
1219
1272
  return m
1220
1273
  ? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
1221
1274
  : `note: ${which} is of an unknown machine (never typed in ${on(host)}) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
@@ -1228,6 +1281,51 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1228
1281
  return fresh([`no idle conversation ${atSeat ? 'of this machine' : `on ${host}`} — starting a new one`, ...notes]);
1229
1282
  }
1230
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
+
1231
1329
  interface Prepared {
1232
1330
  projectRoot: string;
1233
1331
  environment: Environment;
@@ -1326,10 +1424,23 @@ export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
1326
1424
  */
1327
1425
  export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
1328
1426
  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);
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
+ }
1333
1444
  return dirs.filter((d) => {
1334
1445
  try {
1335
1446
  fs.readdirSync(d);
@@ -1445,8 +1556,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1445
1556
  log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
1446
1557
  return EXIT_USAGE;
1447
1558
  }
1448
- // A start continues the last conversation unless asked for a new one.
1449
- 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: [] };
1450
1562
 
1451
1563
  let multiplexer = plan.multiplexer;
1452
1564
  if (multiplexer === 'tmux' && !tmux.available()) {
@@ -1455,13 +1567,27 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1455
1567
  }
1456
1568
 
1457
1569
  const running =
1458
- multiplexer === 'tmux'
1459
- ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
1460
- : [];
1461
- 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
+ }
1462
1589
  // A running agent keeps its mode: plain `vdx ai` takes a focused one as it is,
1463
1590
  // and the rules it leaves out are not missing from it.
1464
- const focusedOf = deps.agentFocused ?? processFocused;
1465
1591
  const runningFocused = current ? focusedOf(current.proc.pid) : false;
1466
1592
  if (current && !opts.restart && runningFocused && !plan.focused) {
1467
1593
  plan = { ...prepared.replan(true), resumeArgs: plan.resumeArgs };
@@ -1471,8 +1597,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1471
1597
  let choice: ConversationChoice | null = null;
1472
1598
  if (!current || opts.restart) {
1473
1599
  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: [] };
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: [] };
1476
1603
  else if (current && isClaude(plan.command)) {
1477
1604
  const live = liveConversationId(deps.env, deps.home, current.proc.pid);
1478
1605
  choice = live
@@ -1484,8 +1611,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1484
1611
 
1485
1612
  const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
1486
1613
  // 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) : [];
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
+ : [];
1489
1621
  if (opts.dryRun) {
1490
1622
  deps.out(renderPlan(plan, multiplexer, running, focusedOf));
1491
1623
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
@@ -1497,7 +1629,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1497
1629
  for (const l of folders) log(l);
1498
1630
  if (choice?.abort) return EXIT_LAUNCH_FAILED;
1499
1631
  if (choice?.elsewhere && !current) {
1500
- 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;
1501
1635
  if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1502
1636
  log('nothing started');
1503
1637
  return EXIT_LAUNCH_FAILED;
@@ -1550,34 +1684,35 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1550
1684
  return res.status ?? 1;
1551
1685
  }
1552
1686
 
1553
- if (running.length > 1) {
1687
+ if (alongside) {
1688
+ const others = running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ');
1554
1689
  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`,
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'}`,
1557
1692
  );
1558
1693
  }
1559
1694
 
1560
1695
  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
- }
1696
+ if (current === unreadable) {
1697
+ log(
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}`,
1700
+ );
1570
1701
  }
1571
1702
  const unfocused = plan.focused && !runningFocused;
1572
1703
  const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
1573
1704
  if (!unfocused && missing.length === 0) {
1574
- log(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
1705
+ log(
1706
+ `✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`,
1707
+ );
1575
1708
  return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1576
1709
  }
1577
1710
  // Drift. In a terminal the person picks: the agent as it runs, or a restart
1578
1711
  // with the profile; without one nothing is touched, and the fix is named.
1579
1712
  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)}`;
1713
+ const fix =
1714
+ `vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1715
+ (opts.conversation ? ` --conversation ${opts.conversation}` : '');
1581
1716
  const effect = plan.resumeArgs.length
1582
1717
  ? 'stops the running agent, then resumes its conversation'
1583
1718
  : opts.fresh
@@ -1589,7 +1724,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1589
1724
  return EXIT_DRIFT;
1590
1725
  }
1591
1726
  log(`⚠ ${drift}`);
1592
- log(` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`);
1727
+ log(
1728
+ ` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`,
1729
+ );
1593
1730
  log(` r restart it ${unfocused ? 'focused' : 'with the profile'}: ${effect}`);
1594
1731
  log(` q leave it; later: ${fix}`);
1595
1732
  const answer = deps.ask(`${opts.detach ? 'Leave it as is' : 'Attach as is'}? [Enter/r/q]: `);
@@ -1601,13 +1738,22 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1601
1738
  log('nothing done');
1602
1739
  return EXIT_DRIFT;
1603
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) };
1604
1744
  }
1605
1745
 
1606
1746
  if (current) {
1607
1747
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1608
1748
  log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
1609
1749
  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);
1750
+ return finish(
1751
+ settleResumed(plan, current.pane.paneId, deps),
1752
+ current.pane.session,
1753
+ current.pane.paneId,
1754
+ opts,
1755
+ deps,
1756
+ );
1611
1757
  }
1612
1758
 
1613
1759
  if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);