@vodmal/vdx-cli 0.22.0 → 0.23.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
@@ -51,7 +51,7 @@ vdx doctor --check [project-path] # one line per hook declared but not running
51
51
  vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
52
52
 
53
53
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
54
- vdx ai[@host] [project-path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
54
+ vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
55
55
  vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
56
56
 
57
57
  # What a command does and takes; nothing runs (ai: also where launch flags live)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.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",
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.0.0"
14
+ version: "1.2.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"
package/src/ai.ts CHANGED
@@ -57,6 +57,8 @@ export interface WhenRule {
57
57
  if: Predicate;
58
58
  args?: string[];
59
59
  confirm?: ConfirmRule[];
60
+ /** The rule is there to wake the agent: a focused session (`vdx ai --focused`) leaves it out. */
61
+ wakes?: boolean;
60
62
  }
61
63
 
62
64
  export interface AgentProfile {
@@ -116,6 +118,13 @@ export interface Environment {
116
118
 
117
119
  export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
118
120
  export const HOST_ENV_VAR = 'VDX_HOST';
121
+ /** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
122
+ export const SEAT_ENV_VAR = 'VDX_AI_FROM';
123
+ /**
124
+ * `1` in the environment of an agent started with `vdx ai --focused`, and so of
125
+ * its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
126
+ */
127
+ export const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
119
128
  export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
120
129
  /** Tried in order when no profile names an agent. Started plain, with no flags. */
121
130
  export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
@@ -168,6 +177,7 @@ export function parseEnvironment(text: string, source: string): Environment {
168
177
  if (rule.args !== undefined && !isStringList(rule.args)) {
169
178
  fail(`${at}.args`, 'must be a list of strings');
170
179
  }
180
+ if (rule.wakes !== undefined && typeof rule.wakes !== 'boolean') fail(`${at}.wakes`, 'must be true or false');
171
181
  if (rule.confirm !== undefined) {
172
182
  if (!Array.isArray(rule.confirm)) fail(`${at}.confirm`, 'must be a list');
173
183
  rule.confirm.forEach((c, j) => {
@@ -279,12 +289,18 @@ export interface LaunchPlan {
279
289
  /** Where `project` came from: mise.toml, a profile source, or the repository name. */
280
290
  projectSource: string;
281
291
  host: string;
292
+ /** The machine the person sits at: `host`, or the one that ran `vdx ai@<host>`. */
293
+ seat: string;
282
294
  profilePath: string | null;
283
295
  command: string;
284
296
  /** agent.args plus the args of every matched `when` rule — what a running agent must carry. */
285
297
  args: string[];
286
298
  resumeArgs: string[];
287
299
  matched: string[];
300
+ /** `vdx ai --focused`: the agent gets VDX_FOCUSED=1, and the rules that wake it are left out. */
301
+ focused: boolean;
302
+ /** Matched rules a focused session leaves out (`wakes: true`). */
303
+ skipped: string[];
288
304
  confirm: ConfirmRule[];
289
305
  multiplexer: Multiplexer;
290
306
  sessionName: string;
@@ -300,14 +316,22 @@ export function planLaunch(input: {
300
316
  project: string;
301
317
  projectSource?: string;
302
318
  host: string;
319
+ seat?: string;
320
+ focused?: boolean;
303
321
  }): LaunchPlan {
304
322
  const { environment, agent, projectRoot } = input;
323
+ const focused = input.focused === true;
305
324
  const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
306
325
  const args = [...(agent.args ?? [])];
307
326
  const matched: string[] = [];
327
+ const skipped: string[] = [];
308
328
  const confirm: ConfirmRule[] = [];
309
329
  for (const rule of agent.when ?? []) {
310
330
  if (!evalPredicate(rule.if, ctx)) continue;
331
+ if (focused && rule.wakes) {
332
+ skipped.push(rule.id);
333
+ continue;
334
+ }
311
335
  matched.push(rule.id);
312
336
  args.push(...(rule.args ?? []));
313
337
  confirm.push(...(rule.confirm ?? []));
@@ -317,11 +341,14 @@ export function planLaunch(input: {
317
341
  project: input.project,
318
342
  projectSource: input.projectSource ?? 'repository name',
319
343
  host: input.host,
344
+ seat: input.seat ?? input.host,
320
345
  profilePath: input.profilePath,
321
346
  command: agent.command,
322
347
  args,
323
348
  resumeArgs: [...(agent.resume_args ?? [])],
324
349
  matched,
350
+ focused,
351
+ skipped,
325
352
  confirm,
326
353
  multiplexer: environment.session?.multiplexer ?? 'none',
327
354
  sessionName: renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
@@ -535,6 +562,16 @@ export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
535
562
  return label || os.hostname().split('.')[0]!;
536
563
  }
537
564
 
565
+ /** The machine the person sits at: the one whose `vdx ai@<host>` started this vdx, else this one. */
566
+ export function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
567
+ return env[SEAT_ENV_VAR]?.trim() || hostLabel(env);
568
+ }
569
+
570
+ /** Machine labels compare without case: `M3` in the profile is `m3` in a transcript. */
571
+ export function sameMachine(a: string | null, b: string | null): boolean {
572
+ return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
573
+ }
574
+
538
575
  export function onPath(bin: string, env: NodeJS.ProcessEnv = process.env): boolean {
539
576
  for (const dir of (env['PATH'] ?? '').split(path.delimiter)) {
540
577
  if (!dir) continue;
@@ -654,6 +691,8 @@ export interface AiOptions {
654
691
  conversation?: string;
655
692
  detach: boolean;
656
693
  dryRun: boolean;
694
+ /** `--focused`: a session nothing wakes — VDX_FOCUSED=1, the profile's `wakes` rules left out. */
695
+ focused?: boolean;
657
696
  }
658
697
 
659
698
  export interface AiDeps {
@@ -667,6 +706,8 @@ export interface AiDeps {
667
706
  out: (text: string) => void;
668
707
  onPath: (bin: string) => boolean;
669
708
  processes: () => ProcInfo[];
709
+ /** Was this agent process started focused; default: its environment (`processFocused`). */
710
+ agentFocused?: (pid: number) => boolean;
670
711
  sleep: (ms: number) => void;
671
712
  confirmTimeoutMs: number;
672
713
  /** One line from the person at the terminal, or null when there is none. */
@@ -742,15 +783,21 @@ export const EXIT_LAUNCH_FAILED = 4;
742
783
  */
743
784
  export const NOTHING_TO_RESUME_MS = 30_000;
744
785
 
745
- export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running: AgentPane[]): string {
786
+ export function renderPlan(
787
+ plan: LaunchPlan,
788
+ multiplexer: Multiplexer,
789
+ running: AgentPane[],
790
+ focusedOf: (pid: number) => boolean = () => false,
791
+ ): string {
746
792
  const lines = [
747
793
  `vdx ai — ${plan.projectRoot}`,
748
794
  ` name: ${plan.project} (${plan.projectSource})`,
749
795
  ` profile: ${plan.profilePath ?? `none — built-in default (${FALLBACK_AGENTS.join(' or ')}, no flags, no tmux)`}`,
750
- ` agent: ${commandLine(plan.command, plan.args)}`,
796
+ ` agent: ${agentLine(plan, plan.args)}`,
751
797
  ` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
752
798
  ` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
753
799
  ];
800
+ if (plan.focused) lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
754
801
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
755
802
  lines.push(
756
803
  multiplexer === 'tmux'
@@ -761,10 +808,11 @@ export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running:
761
808
  if (running.length === 0) lines.push(' running: —');
762
809
  for (const r of running) {
763
810
  const missing = missingArgs(r.proc.args, plan.args);
811
+ const focused = focusedOf(r.proc.pid);
764
812
  lines.push(
765
813
  ` running: ${r.pane.session} ${r.pane.paneId} — ${
766
814
  missing.length ? `missing ${missing.join(' ')}` : 'matches the profile'
767
- }`,
815
+ }${focused ? ', focused' : plan.focused ? ', not focused' : ''}`,
768
816
  );
769
817
  }
770
818
  }
@@ -864,7 +912,7 @@ function settle(plan: LaunchPlan, paneId: string, deps: AiDeps): SettleResult {
864
912
  function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean {
865
913
  const result = settle(plan, paneId, deps);
866
914
  if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
867
- const line = commandLine(plan.command, plan.args);
915
+ const line = agentLine(plan, plan.args);
868
916
  deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
869
917
  deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
870
918
  return settle(plan, paneId, deps) === 'ok';
@@ -895,9 +943,12 @@ export function remotePathWord(abs: string, home: string): string {
895
943
  * The ssh argv for `vdx ai@<host>`: the same `vdx ai` on the host, in its own
896
944
  * tmux there. The path goes first: an older vdx hands the token after a flag it
897
945
  * does not know to that flag. A different vdx version there is named, not fatal.
946
+ * `from` — the machine the person sits at, so the vdx there speaks from it; an
947
+ * older vdx leaves the variable alone.
898
948
  */
899
949
  export function remoteAiArgs(input: {
900
950
  host: string;
951
+ from: string;
901
952
  projectPath: string;
902
953
  home: string;
903
954
  version: string;
@@ -907,6 +958,7 @@ export function remoteAiArgs(input: {
907
958
  const { host, opts, version } = input;
908
959
  const flags = [
909
960
  opts.fresh && '--new',
961
+ opts.focused && '--focused',
910
962
  opts.restart && '--restart',
911
963
  opts.detach && '--detach',
912
964
  opts.dryRun && '--dry-run',
@@ -916,6 +968,7 @@ export function remoteAiArgs(input: {
916
968
  const script =
917
969
  `v=$(vdx --version 2>/dev/null) || v='older than 0.13.1'; ` +
918
970
  `[ "$v" = ${v} ] || echo "note: vdx on ${host} is $v, here "${v} >&2; ` +
971
+ `export ${SEAT_ENV_VAR}=${shellQuote(input.from)}; ` +
919
972
  `exec vdx ai ${[remotePathWord(input.projectPath, input.home), ...flags].join(' ')}`;
920
973
  return [input.tty ? '-t' : '-T', host, script];
921
974
  }
@@ -975,10 +1028,11 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
975
1028
  }
976
1029
  }
977
1030
 
978
- export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string = deps.version ?? ''): number {
1031
+ /** `from` — where the person sits; a vdx that `vdx ai@<host>` started passes its own on. */
1032
+ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: string = seatLabel(deps.env)): number {
979
1033
  const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
980
1034
  const tty = deps.interactive && !opts.detach && !opts.dryRun;
981
- const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
1035
+ const args = remoteAiArgs({ host, from, projectPath, home: deps.home, version: deps.version ?? '', opts, tty });
982
1036
  deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`);
983
1037
  const res = (deps.ssh ?? runSsh)(args, false);
984
1038
  if (res === null) {
@@ -1051,9 +1105,12 @@ const resumeId = (id: string) => ['--resume', id];
1051
1105
  export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
1052
1106
  if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1053
1107
  const host = plan.host;
1054
- const same = (a: string | null, b: string | null) => (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
1108
+ const same = sameMachine;
1055
1109
  const all = plan.resumeArgs.length === 0 ? [] : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
1056
1110
  const root = shellQuote(plan.projectRoot);
1111
+ // Said from where the person sits: under `vdx ai@<host>` that is not this machine.
1112
+ const atSeat = same(plan.seat, host);
1113
+ const on = (m: string) => (same(m, plan.seat) ? (atSeat ? 'here' : `on ${m}, where you are,`) : `on ${m}`);
1057
1114
 
1058
1115
  // Who runs what right now: this machine from its session files, the others over ssh.
1059
1116
  const reach = new Map<string, MachineReport | null>();
@@ -1064,9 +1121,18 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1064
1121
  const elsewhere = [...reach].flatMap(([m, r]) =>
1065
1122
  (r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
1066
1123
  );
1124
+ // The project's path on the machine `m`, by its home: the person types it there.
1125
+ const rootOn = (m: string) => {
1126
+ const theirs = [...reach].find(([k]) => same(k, m))?.[1]?.home;
1127
+ const rel = path.relative(deps.home, plan.projectRoot);
1128
+ return theirs && !rel.startsWith('..') && !path.isAbsolute(rel) ? shellQuote(path.join(theirs, rel)) : root;
1129
+ };
1130
+ // `vdx ai` attaches to an agent in tmux; one in a terminal of its own has no command to reach it.
1131
+ const reachAgent = (m: string, l: LiveSession) =>
1132
+ !l.tmux ? '' : same(m, plan.seat) ? ` — attach: vdx ai ${rootOn(m)}` : ` — there: vdx ai@${m} ${root}`;
1067
1133
  const fresh = (lines: string[]): ConversationChoice => {
1068
1134
  if (elsewhere.length === 0) return { args: [], lines };
1069
- const agents = elsewhere.map(({ m, l }) => `an agent of this project is ${describeLive(m, l, false)} — there: vdx ai@${m} ${root}`);
1135
+ const agents = elsewhere.map(({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`);
1070
1136
  return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
1071
1137
  };
1072
1138
  if (plan.resumeArgs.length === 0) return fresh(silent);
@@ -1086,7 +1152,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1086
1152
  (a, b) => b.updated.getTime() - a.updated.getTime(),
1087
1153
  );
1088
1154
  const show = (c: Conversation) => describeConversation(c, machine(c));
1089
- const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(l.machine, l.session, l.here));
1155
+ const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(on(l.machine), l.session));
1090
1156
 
1091
1157
  const take = (c: Conversation): ConversationChoice => {
1092
1158
  const m = machine(c);
@@ -1094,7 +1160,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1094
1160
  if (here) {
1095
1161
  return {
1096
1162
  args: [],
1097
- askHere: { id: c.id, why: `It runs here right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
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` },
1098
1164
  lines: [`chosen: ${show(c)}`],
1099
1165
  };
1100
1166
  }
@@ -1102,7 +1168,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1102
1168
  if (m) return { args: [], remote: { host: m, id: c.id, reachable: reach.get(m) != null }, lines: [`continuing on ${m}: ${show(c)}`] };
1103
1169
  return {
1104
1170
  args: [],
1105
- askHere: { id: c.id, why: 'Its machine is unknown (never typed in here); if it runs there, both machines will write to it' },
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` },
1106
1172
  lines: [`chosen: ${show(c)}`],
1107
1173
  };
1108
1174
  };
@@ -1115,7 +1181,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1115
1181
  if (canAsk && deps.interactive) {
1116
1182
  deps.log('conversations of this project:');
1117
1183
  found.forEach((c, i) => {
1118
- const marks = [i === 0 && 'newest', c === firstIdle && 'this machine', !machine(c) && 'machine unknown', ...running(c)];
1184
+ const marks = [i === 0 && 'newest', c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`), !machine(c) && 'machine unknown', ...running(c)];
1119
1185
  const m = marks.filter(Boolean);
1120
1186
  deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
1121
1187
  });
@@ -1142,16 +1208,17 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1142
1208
  `${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
1143
1209
  (now.length ? `, ${now.join(', ')}` : '');
1144
1210
  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}`;
1145
1212
  return m
1146
1213
  ? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
1147
- : `note: ${which} is of an unknown machine (never typed in here) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1214
+ : `note: ${which} is of an unknown machine (never typed in ${on(host)}) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
1148
1215
  });
1149
1216
  notes.push(...silent);
1150
1217
  if (firstIdle) {
1151
1218
  const chosen = take(firstIdle);
1152
1219
  return { ...chosen, lines: [...chosen.lines, ...notes] };
1153
1220
  }
1154
- return fresh(['no idle conversation of this machine — starting a new one', ...notes]);
1221
+ return fresh([`no idle conversation ${atSeat ? 'of this machine' : `on ${host}`} — starting a new one`, ...notes]);
1155
1222
  }
1156
1223
 
1157
1224
  interface Prepared {
@@ -1160,10 +1227,12 @@ interface Prepared {
1160
1227
  profilePath: string | null;
1161
1228
  profileDir: string;
1162
1229
  plan: LaunchPlan;
1230
+ /** The same plan for a focused session or a plain one. */
1231
+ replan: (focused: boolean) => LaunchPlan;
1163
1232
  }
1164
1233
 
1165
1234
  /** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
1166
- function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1235
+ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | number {
1167
1236
  const { log } = deps;
1168
1237
 
1169
1238
  const projectRoot = resolveProjectRoot(pathArg);
@@ -1201,9 +1270,8 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1201
1270
  profileDir,
1202
1271
  });
1203
1272
 
1204
- let plan: LaunchPlan;
1205
- try {
1206
- plan = planLaunch({
1273
+ const replan = (asFocused: boolean) =>
1274
+ planLaunch({
1207
1275
  environment,
1208
1276
  profilePath,
1209
1277
  agent,
@@ -1211,12 +1279,38 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1211
1279
  project: named.name,
1212
1280
  projectSource: named.source,
1213
1281
  host: hostLabel(deps.env),
1282
+ seat: seatLabel(deps.env),
1283
+ focused: asFocused,
1214
1284
  });
1285
+ let plan: LaunchPlan;
1286
+ try {
1287
+ plan = replan(focused);
1215
1288
  } catch (e: any) {
1216
1289
  log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
1217
1290
  return EXIT_USAGE;
1218
1291
  }
1219
- return { projectRoot, environment, profilePath, profileDir, plan };
1292
+ return { projectRoot, environment, profilePath, profileDir, plan, replan };
1293
+ }
1294
+
1295
+ /** Was the process started focused — VDX_FOCUSED=1 in its environment (`ps -E` on macOS, /proc on Linux)? */
1296
+ export function processFocused(pid: number): boolean {
1297
+ const mark = `${FOCUSED_ENV_VAR}=1`;
1298
+ try {
1299
+ const environ = `/proc/${pid}/environ`;
1300
+ if (fs.existsSync(environ)) return fs.readFileSync(environ, 'utf8').split('\0').includes(mark);
1301
+ const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
1302
+ encoding: 'utf8',
1303
+ stdio: ['ignore', 'pipe', 'ignore'],
1304
+ });
1305
+ return out.split(/\s+/).includes(mark);
1306
+ } catch {
1307
+ return false;
1308
+ }
1309
+ }
1310
+
1311
+ /** The agent's command line; a focused one carries VDX_FOCUSED=1 in front, for the shell in its tmux pane. */
1312
+ function agentLine(plan: LaunchPlan, args: string[]): string {
1313
+ return `${plan.focused ? `${FOCUSED_ENV_VAR}=1 ` : ''}${commandLine(plan.command, args)}`;
1220
1314
  }
1221
1315
 
1222
1316
  /** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
@@ -1237,9 +1331,12 @@ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): P
1237
1331
  */
1238
1332
  export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
1239
1333
  if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
1240
- const prepared = prepare(pathArg, deps);
1334
+ // The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1.
1335
+ const focused = deps.env[FOCUSED_ENV_VAR] === '1';
1336
+ const prepared = prepare(pathArg, deps, focused);
1241
1337
  if (typeof prepared === 'number') return prepared;
1242
1338
  const { projectRoot, profilePath, plan } = prepared;
1339
+ const vdxAi = `vdx ai${focused ? ' --focused' : ''}`;
1243
1340
 
1244
1341
  const lines = [
1245
1342
  `${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
@@ -1248,6 +1345,14 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1248
1345
  `condition in the profile, not an instruction to type. A project's agent on another machine: ` +
1249
1346
  `\`vdx ai@<machine>\`. More: \`vdx ai --help\`.`,
1250
1347
  ];
1348
+ if (focused) {
1349
+ lines.push(
1350
+ `· this session is focused (\`vdx ai --focused\`): it works on what the user gave it — nothing should wake it, ` +
1351
+ `and it does not read its inbox unless the user asks` +
1352
+ (plan.skipped.length ? `; the profile's ${plan.skipped.join(', ')} is left out` : '') +
1353
+ '.',
1354
+ );
1355
+ }
1251
1356
  const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1252
1357
  if (!own) {
1253
1358
  lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
@@ -1265,10 +1370,10 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1265
1370
  lines.push(
1266
1371
  `✗ this session runs without ${missing.join(' ')}${why}. Hand the user the fix — ` +
1267
1372
  (deps.env['TMUX']
1268
- ? `\`vdx ai --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1373
+ ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1269
1374
  (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1270
1375
  `. Do not run it yourself: it ends this session.`
1271
- : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`vdx ai ${root}\`.`),
1376
+ : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
1272
1377
  );
1273
1378
  deps.out(lines.join('\n') + '\n');
1274
1379
  return EXIT_DRIFT;
@@ -1277,10 +1382,18 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1277
1382
  export function runAi(opts: AiOptions, deps: AiDeps): number {
1278
1383
  const { log, tmux } = deps;
1279
1384
 
1280
- const prepared = prepare(opts.path, deps);
1385
+ const prepared = prepare(opts.path, deps, opts.focused === true);
1281
1386
  if (typeof prepared === 'number') return prepared;
1282
1387
  const { projectRoot, environment, profileDir } = prepared;
1283
1388
  let { plan } = prepared;
1389
+ // The agent started here runs on this machine: it does not inherit where the person sat,
1390
+ // and it is focused by this start's flag, not by the shell vdx runs in.
1391
+ delete deps.env[SEAT_ENV_VAR];
1392
+ delete deps.env[FOCUSED_ENV_VAR];
1393
+ // Under `vdx ai@<host>` the person is not here: this machine goes by its name.
1394
+ const atSeat = sameMachine(plan.seat, plan.host);
1395
+ const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
1396
+ const vdxAiHere = atSeat ? 'vdx ai' : `vdx ai@${plan.host}`;
1284
1397
  if (opts.conversation && !isClaude(plan.command)) {
1285
1398
  log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
1286
1399
  return EXIT_USAGE;
@@ -1299,6 +1412,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1299
1412
  ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
1300
1413
  : [];
1301
1414
  const current = running[0];
1415
+ // A running agent keeps its mode: plain `vdx ai` takes a focused one as it is,
1416
+ // and the rules it leaves out are not missing from it.
1417
+ const focusedOf = deps.agentFocused ?? processFocused;
1418
+ const runningFocused = current ? focusedOf(current.proc.pid) : false;
1419
+ if (current && !opts.restart && runningFocused && !plan.focused) {
1420
+ plan = { ...prepared.replan(true), resumeArgs: plan.resumeArgs };
1421
+ }
1302
1422
 
1303
1423
  // What a start continues. A restart keeps the conversation the agent is in.
1304
1424
  let choice: ConversationChoice | null = null;
@@ -1317,7 +1437,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1317
1437
 
1318
1438
  const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
1319
1439
  if (opts.dryRun) {
1320
- deps.out(renderPlan(plan, multiplexer, running));
1440
+ deps.out(renderPlan(plan, multiplexer, running, focusedOf));
1321
1441
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1322
1442
  ensureAuthor(projectRoot, poolDirs, deps, true);
1323
1443
  return EXIT_OK;
@@ -1325,7 +1445,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1325
1445
  for (const l of choice?.lines ?? []) log(l);
1326
1446
  if (choice?.abort) return EXIT_LAUNCH_FAILED;
1327
1447
  if (choice?.elsewhere && !current) {
1328
- const answer = deps.interactive ? deps.ask(`Start a new conversation here, on ${plan.host}, anyway? [y/N]: `) : null;
1448
+ const answer = deps.interactive ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
1329
1449
  if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1330
1450
  log('nothing started');
1331
1451
  return EXIT_LAUNCH_FAILED;
@@ -1335,17 +1455,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1335
1455
  let askHere = choice?.askHere;
1336
1456
  if (choice?.remote) {
1337
1457
  const { host, id, reachable } = choice.remote;
1338
- if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps);
1458
+ if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps, plan.seat);
1339
1459
  log(`✗ ${host} does not answer over ssh — its conversation ${id.slice(0, 8)} cannot be continued there`);
1340
1460
  askHere = { id, why: `If it still runs on ${host}, both machines will write to it` };
1341
1461
  }
1342
1462
  if (askHere) {
1343
1463
  const { id, why } = askHere;
1344
- const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)} here, on ${plan.host}? ${why}. [y/N]: `) : null;
1464
+ const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)} ${here}? ${why}. [y/N]: `) : null;
1345
1465
  if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
1346
1466
  log(
1347
- `nothing started. Here: vdx ai ${shellQuote(projectRoot)} --conversation ${id}` +
1348
- ` · a new conversation: vdx ai --new ${shellQuote(projectRoot)}`,
1467
+ `nothing started. ${atSeat ? 'Here' : `On ${plan.host}`}: ${vdxAiHere} ${shellQuote(projectRoot)} --conversation ${id}` +
1468
+ ` · a new conversation: ${vdxAiHere} --new ${shellQuote(projectRoot)}`,
1349
1469
  );
1350
1470
  return EXIT_LAUNCH_FAILED;
1351
1471
  }
@@ -1363,16 +1483,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1363
1483
  return EXIT_USAGE;
1364
1484
  }
1365
1485
  for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
1486
+ const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
1366
1487
  const startedAt = Date.now();
1367
- let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit' });
1488
+ let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
1368
1489
  if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
1369
1490
  log(`vdx ai: \`${plan.command}\` not found on PATH`);
1370
1491
  return 127;
1371
1492
  }
1372
1493
  const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
1373
1494
  if (plan.resumeArgs.length > 0 && failedAtOnce) {
1374
- log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${commandLine(plan.command, plan.args)}`);
1375
- res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit' });
1495
+ log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${agentLine(plan, plan.args)}`);
1496
+ res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit', env });
1376
1497
  }
1377
1498
  return res.status ?? 1;
1378
1499
  }
@@ -1395,11 +1516,19 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1395
1516
  );
1396
1517
  }
1397
1518
  }
1519
+ if (plan.focused && !runningFocused) {
1520
+ log(`✗ ${current.pane.session}: ${plan.command} is running, not focused`);
1521
+ 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)'),
1524
+ );
1525
+ return EXIT_DRIFT;
1526
+ }
1398
1527
  const missing = missingArgs(current.proc.args, plan.args);
1399
1528
  if (missing.length > 0) {
1400
1529
  log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
1401
1530
  log(
1402
- ` to apply the profile: vdx ai --restart${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1531
+ ` to apply the profile: vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1403
1532
  (plan.resumeArgs.length
1404
1533
  ? ` (stops the running agent, then resumes its conversation)`
1405
1534
  : opts.fresh
@@ -1408,19 +1537,19 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1408
1537
  );
1409
1538
  return EXIT_DRIFT;
1410
1539
  }
1411
- log(`✓ ${current.pane.session}: already running with the profile — ${current.proc.args}`);
1540
+ log(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
1412
1541
  return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1413
1542
  }
1414
1543
 
1415
1544
  if (current) {
1416
- const line = commandLine(plan.command, [...plan.args, ...plan.resumeArgs]);
1545
+ const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1417
1546
  log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
1418
1547
  tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
1419
1548
  return finish(settleResumed(plan, current.pane.paneId, deps), current.pane.session, current.pane.paneId, opts, deps);
1420
1549
  }
1421
1550
 
1422
1551
  if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
1423
- const line = commandLine(plan.command, [...plan.args, ...plan.resumeArgs]);
1552
+ const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1424
1553
  const cmd = tmuxShellCommand(line, deps.shell);
1425
1554
  const reuse = tmux.hasSession(plan.sessionName);
1426
1555
  const paneId = reuse
@@ -314,10 +314,13 @@ export function parseMachineReport(text: string): MachineReport {
314
314
  return { home: text.match(/^home=(.+)$/m)?.[1] ?? null, sessions: parseLiveSessions(text) };
315
315
  }
316
316
 
317
- /** "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for a person choosing. */
318
- export function describeLive(machine: string, live: LiveSession, here: boolean): string {
317
+ /**
318
+ * "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for
319
+ * a person choosing; `place` is the machine as they see it: "here", "on m3".
320
+ */
321
+ export function describeLive(place: string, live: LiveSession): string {
319
322
  const where = live.tmux ? live.tmux.replace(/:.*$/, '') : `pid ${live.pid}`;
320
- return `running ${here ? 'here' : `on ${machine}`} now (${where}${live.status ? `, ${live.status}` : ''})`;
323
+ return `running ${place} now (${where}${live.status ? `, ${live.status}` : ''})`;
321
324
  }
322
325
 
323
326
  /** "3 min ago", "yesterday 14:05", "2026-09-30 14:05" — for a person choosing. */
package/src/index.ts CHANGED
@@ -76,6 +76,11 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
76
76
  project_path the project (default: cwd); its git toplevel is used
77
77
  @host the same command on <host> over ssh: its vdx, profile and tmux
78
78
  --new a new conversation instead of continuing the last one
79
+ --focused a session nothing wakes: the agent, its hooks and MCP servers get
80
+ VDX_FOCUSED=1 — for the plugins that wake sessions to leave it
81
+ alone — and the profile's rules marked \`wakes: true\` (a channel
82
+ flag) are left out. A running agent keeps its mode; --restart
83
+ applies this one
79
84
  --conversation <id>
80
85
  continue this Claude Code conversation
81
86
  --restart restart the running agent in its pane — applies a changed profile
@@ -97,7 +102,8 @@ machine's. A new conversation (--new, or none to continue) while an agent of
97
102
  the project runs on another machine starts only after a yes.
98
103
 
99
104
  Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
100
- 3 the running agent lacks profile flags (rerun with --restart); 4 the start
105
+ 3 the running agent lacks profile flags, or is not focused under --focused
106
+ (rerun with --restart); 4 the start
101
107
  was not confirmed.
102
108
  Profile format: https://github.com/VoDmAl/vdx/blob/main/docs/specs/environment-format.md
103
109
  `;
@@ -156,10 +162,10 @@ Exit 2 when something is missing.
156
162
  positionals: 1,
157
163
  },
158
164
  ai: {
159
- usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run]',
165
+ usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run]',
160
166
  help: AI_HELP,
161
167
  // `--resume` asked to continue in 0.12; continuing is the default now.
162
- bools: ['new', 'restart', 'resume', 'detach', 'dry-run', 'check'],
168
+ bools: ['new', 'focused', 'restart', 'resume', 'detach', 'dry-run', 'check'],
163
169
  values: ['conversation'],
164
170
  positionals: 1,
165
171
  },
@@ -500,6 +506,7 @@ function cmdAi(opts: ParsedArgs): void {
500
506
  conversation,
501
507
  detach: opts.flags.detach === true,
502
508
  dryRun: opts.flags['dry-run'] === true,
509
+ focused: opts.flags.focused === true,
503
510
  };
504
511
  const deps = { ...defaultDeps(), version: readCliVersion() };
505
512
  // `ai@m3`: the same command on another machine. Its own label runs here.
package/src/init.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
13
13
  export type Verb = (typeof STANDARD_VERBS)[number];
14
14
 
15
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.0.0';
15
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.2.0';
16
16
 
17
17
  const VERB_ALIASES: Record<Verb, string[]> = {
18
18
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],