@vodmal/vdx-cli 0.22.1 → 0.24.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)
@@ -75,11 +75,12 @@ vdx init --stack node
75
75
  vdx test # → mise run test (which calls `vitest run` or whatever was detected)
76
76
 
77
77
  # 3. Ship a new minor release of a Node lib
78
- vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP) → commit + tag → push → wait for the registry
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
80
  # 4. Start the agent here, continuing its last conversation (--new: a new one;
81
81
  # another machine's conversation is continued on that machine);
82
- # re-run to attach, --restart to apply a changed profile
82
+ # re-run to attach; an agent without the profile's flags is a question:
83
+ # Enter attaches as is, r restarts it; --restart applies a changed profile
83
84
  vdx ai
84
85
 
85
86
  # 5. The same on another machine (an ssh host with vdx), attaching from here
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.22.1",
3
+ "version": "0.24.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.3.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 {
@@ -82,6 +84,8 @@ export interface SessionProfile {
82
84
  project_names?: NameSource[];
83
85
  /** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
84
86
  machines?: string[];
87
+ /** What to do when a session cannot read ~/Downloads, ~/Desktop or ~/Documents (macOS): shown after the warning. */
88
+ folders_fix?: string;
85
89
  }
86
90
 
87
91
  /**
@@ -118,6 +122,11 @@ export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
118
122
  export const HOST_ENV_VAR = 'VDX_HOST';
119
123
  /** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
120
124
  export const SEAT_ENV_VAR = 'VDX_AI_FROM';
125
+ /**
126
+ * `1` in the environment of an agent started with `vdx ai --focused`, and so of
127
+ * its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
128
+ */
129
+ export const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
121
130
  export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
122
131
  /** Tried in order when no profile names an agent. Started plain, with no flags. */
123
132
  export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
@@ -170,6 +179,7 @@ export function parseEnvironment(text: string, source: string): Environment {
170
179
  if (rule.args !== undefined && !isStringList(rule.args)) {
171
180
  fail(`${at}.args`, 'must be a list of strings');
172
181
  }
182
+ if (rule.wakes !== undefined && typeof rule.wakes !== 'boolean') fail(`${at}.wakes`, 'must be true or false');
173
183
  if (rule.confirm !== undefined) {
174
184
  if (!Array.isArray(rule.confirm)) fail(`${at}.confirm`, 'must be a list');
175
185
  rule.confirm.forEach((c, j) => {
@@ -214,6 +224,9 @@ export function parseEnvironment(text: string, source: string): Environment {
214
224
  if (!isMachineLabel(m)) fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
215
225
  });
216
226
  }
227
+ if (session.folders_fix !== undefined && (typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')) {
228
+ fail('session.folders_fix', 'must be a non-empty string');
229
+ }
217
230
  }
218
231
 
219
232
  const gitDoc = envDoc.git;
@@ -289,6 +302,10 @@ export interface LaunchPlan {
289
302
  args: string[];
290
303
  resumeArgs: string[];
291
304
  matched: string[];
305
+ /** `vdx ai --focused`: the agent gets VDX_FOCUSED=1, and the rules that wake it are left out. */
306
+ focused: boolean;
307
+ /** Matched rules a focused session leaves out (`wakes: true`). */
308
+ skipped: string[];
292
309
  confirm: ConfirmRule[];
293
310
  multiplexer: Multiplexer;
294
311
  sessionName: string;
@@ -305,14 +322,21 @@ export function planLaunch(input: {
305
322
  projectSource?: string;
306
323
  host: string;
307
324
  seat?: string;
325
+ focused?: boolean;
308
326
  }): LaunchPlan {
309
327
  const { environment, agent, projectRoot } = input;
328
+ const focused = input.focused === true;
310
329
  const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
311
330
  const args = [...(agent.args ?? [])];
312
331
  const matched: string[] = [];
332
+ const skipped: string[] = [];
313
333
  const confirm: ConfirmRule[] = [];
314
334
  for (const rule of agent.when ?? []) {
315
335
  if (!evalPredicate(rule.if, ctx)) continue;
336
+ if (focused && rule.wakes) {
337
+ skipped.push(rule.id);
338
+ continue;
339
+ }
316
340
  matched.push(rule.id);
317
341
  args.push(...(rule.args ?? []));
318
342
  confirm.push(...(rule.confirm ?? []));
@@ -328,6 +352,8 @@ export function planLaunch(input: {
328
352
  args,
329
353
  resumeArgs: [...(agent.resume_args ?? [])],
330
354
  matched,
355
+ focused,
356
+ skipped,
331
357
  confirm,
332
358
  multiplexer: environment.session?.multiplexer ?? 'none',
333
359
  sessionName: renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
@@ -670,6 +696,8 @@ export interface AiOptions {
670
696
  conversation?: string;
671
697
  detach: boolean;
672
698
  dryRun: boolean;
699
+ /** `--focused`: a session nothing wakes — VDX_FOCUSED=1, the profile's `wakes` rules left out. */
700
+ focused?: boolean;
673
701
  }
674
702
 
675
703
  export interface AiDeps {
@@ -683,6 +711,10 @@ export interface AiDeps {
683
711
  out: (text: string) => void;
684
712
  onPath: (bin: string) => boolean;
685
713
  processes: () => ProcInfo[];
714
+ /** Was this agent process started focused; default: its environment (`processFocused`). */
715
+ agentFocused?: (pid: number) => boolean;
716
+ /** Default `process.platform`: the folder check is for macOS. */
717
+ platform?: NodeJS.Platform;
686
718
  sleep: (ms: number) => void;
687
719
  confirmTimeoutMs: number;
688
720
  /** One line from the person at the terminal, or null when there is none. */
@@ -758,15 +790,21 @@ export const EXIT_LAUNCH_FAILED = 4;
758
790
  */
759
791
  export const NOTHING_TO_RESUME_MS = 30_000;
760
792
 
761
- export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running: AgentPane[]): string {
793
+ export function renderPlan(
794
+ plan: LaunchPlan,
795
+ multiplexer: Multiplexer,
796
+ running: AgentPane[],
797
+ focusedOf: (pid: number) => boolean = () => false,
798
+ ): string {
762
799
  const lines = [
763
800
  `vdx ai — ${plan.projectRoot}`,
764
801
  ` name: ${plan.project} (${plan.projectSource})`,
765
802
  ` profile: ${plan.profilePath ?? `none — built-in default (${FALLBACK_AGENTS.join(' or ')}, no flags, no tmux)`}`,
766
- ` agent: ${commandLine(plan.command, plan.args)}`,
803
+ ` agent: ${agentLine(plan, plan.args)}`,
767
804
  ` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
768
805
  ` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
769
806
  ];
807
+ if (plan.focused) lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
770
808
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
771
809
  lines.push(
772
810
  multiplexer === 'tmux'
@@ -777,10 +815,11 @@ export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running:
777
815
  if (running.length === 0) lines.push(' running: —');
778
816
  for (const r of running) {
779
817
  const missing = missingArgs(r.proc.args, plan.args);
818
+ const focused = focusedOf(r.proc.pid);
780
819
  lines.push(
781
820
  ` running: ${r.pane.session} ${r.pane.paneId} — ${
782
821
  missing.length ? `missing ${missing.join(' ')}` : 'matches the profile'
783
- }`,
822
+ }${focused ? ', focused' : plan.focused ? ', not focused' : ''}`,
784
823
  );
785
824
  }
786
825
  }
@@ -880,7 +919,7 @@ function settle(plan: LaunchPlan, paneId: string, deps: AiDeps): SettleResult {
880
919
  function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean {
881
920
  const result = settle(plan, paneId, deps);
882
921
  if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
883
- const line = commandLine(plan.command, plan.args);
922
+ const line = agentLine(plan, plan.args);
884
923
  deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
885
924
  deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
886
925
  return settle(plan, paneId, deps) === 'ok';
@@ -926,6 +965,7 @@ export function remoteAiArgs(input: {
926
965
  const { host, opts, version } = input;
927
966
  const flags = [
928
967
  opts.fresh && '--new',
968
+ opts.focused && '--focused',
929
969
  opts.restart && '--restart',
930
970
  opts.detach && '--detach',
931
971
  opts.dryRun && '--dry-run',
@@ -1194,10 +1234,12 @@ interface Prepared {
1194
1234
  profilePath: string | null;
1195
1235
  profileDir: string;
1196
1236
  plan: LaunchPlan;
1237
+ /** The same plan for a focused session or a plain one. */
1238
+ replan: (focused: boolean) => LaunchPlan;
1197
1239
  }
1198
1240
 
1199
1241
  /** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
1200
- function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1242
+ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | number {
1201
1243
  const { log } = deps;
1202
1244
 
1203
1245
  const projectRoot = resolveProjectRoot(pathArg);
@@ -1235,9 +1277,8 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1235
1277
  profileDir,
1236
1278
  });
1237
1279
 
1238
- let plan: LaunchPlan;
1239
- try {
1240
- plan = planLaunch({
1280
+ const replan = (asFocused: boolean) =>
1281
+ planLaunch({
1241
1282
  environment,
1242
1283
  profilePath,
1243
1284
  agent,
@@ -1246,12 +1287,77 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
1246
1287
  projectSource: named.source,
1247
1288
  host: hostLabel(deps.env),
1248
1289
  seat: seatLabel(deps.env),
1290
+ focused: asFocused,
1249
1291
  });
1292
+ let plan: LaunchPlan;
1293
+ try {
1294
+ plan = replan(focused);
1250
1295
  } catch (e: any) {
1251
1296
  log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
1252
1297
  return EXIT_USAGE;
1253
1298
  }
1254
- return { projectRoot, environment, profilePath, profileDir, plan };
1299
+ return { projectRoot, environment, profilePath, profileDir, plan, replan };
1300
+ }
1301
+
1302
+ /** Was the process started focused — VDX_FOCUSED=1 in its environment (`ps -E` on macOS, /proc on Linux)? */
1303
+ export function processFocused(pid: number): boolean {
1304
+ const mark = `${FOCUSED_ENV_VAR}=1`;
1305
+ try {
1306
+ const environ = `/proc/${pid}/environ`;
1307
+ if (fs.existsSync(environ)) return fs.readFileSync(environ, 'utf8').split('\0').includes(mark);
1308
+ const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
1309
+ encoding: 'utf8',
1310
+ stdio: ['ignore', 'pipe', 'ignore'],
1311
+ });
1312
+ return out.split(/\s+/).includes(mark);
1313
+ } catch {
1314
+ return false;
1315
+ }
1316
+ }
1317
+
1318
+ /** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
1319
+ export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
1320
+
1321
+ /**
1322
+ * The guarded folders an agent started here could not read. Through the tmux server
1323
+ * (`run-shell`): its sessions have the server's rights, not vdx's. Without a server, or
1324
+ * without tmux, this process's own — the agent gets the rights of this terminal. A folder
1325
+ * that does not exist is skipped.
1326
+ */
1327
+ export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
1328
+ 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);
1333
+ return dirs.filter((d) => {
1334
+ try {
1335
+ fs.readdirSync(d);
1336
+ return false;
1337
+ } catch (e: any) {
1338
+ return e?.code === 'EPERM' || e?.code === 'EACCES';
1339
+ }
1340
+ });
1341
+ }
1342
+
1343
+ /** One warning and its fix — the profile's `session.folders_fix`, else what works anywhere. */
1344
+ export function foldersWarning(denied: string[], viaTmux: boolean, fix: string | undefined, home: string): string[] {
1345
+ const names = denied.map((d) => (d.startsWith(home + path.sep) ? `~${d.slice(home.length)}` : d)).join(', ');
1346
+ return [
1347
+ `⚠ this session cannot read ${names} — macOS holds the denial ${viaTmux ? 'in the tmux server' : 'for this terminal'}; ` +
1348
+ `files there are out of the agent's reach`,
1349
+ ` fix: ${
1350
+ fix ??
1351
+ (viaTmux
1352
+ ? 'restart the tmux server (it closes every session)'
1353
+ : 'allow this terminal app in System Settings → Privacy & Security → Files and Folders')
1354
+ }`,
1355
+ ];
1356
+ }
1357
+
1358
+ /** The agent's command line; a focused one carries VDX_FOCUSED=1 in front, for the shell in its tmux pane. */
1359
+ function agentLine(plan: LaunchPlan, args: string[]): string {
1360
+ return `${plan.focused ? `${FOCUSED_ENV_VAR}=1 ` : ''}${commandLine(plan.command, args)}`;
1255
1361
  }
1256
1362
 
1257
1363
  /** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
@@ -1272,9 +1378,12 @@ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): P
1272
1378
  */
1273
1379
  export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
1274
1380
  if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
1275
- const prepared = prepare(pathArg, deps);
1381
+ // The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1.
1382
+ const focused = deps.env[FOCUSED_ENV_VAR] === '1';
1383
+ const prepared = prepare(pathArg, deps, focused);
1276
1384
  if (typeof prepared === 'number') return prepared;
1277
1385
  const { projectRoot, profilePath, plan } = prepared;
1386
+ const vdxAi = `vdx ai${focused ? ' --focused' : ''}`;
1278
1387
 
1279
1388
  const lines = [
1280
1389
  `${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
@@ -1283,6 +1392,14 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1283
1392
  `condition in the profile, not an instruction to type. A project's agent on another machine: ` +
1284
1393
  `\`vdx ai@<machine>\`. More: \`vdx ai --help\`.`,
1285
1394
  ];
1395
+ if (focused) {
1396
+ lines.push(
1397
+ `· this session is focused (\`vdx ai --focused\`): it works on what the user gave it — nothing should wake it, ` +
1398
+ `and it does not read its inbox unless the user asks` +
1399
+ (plan.skipped.length ? `; the profile's ${plan.skipped.join(', ')} is left out` : '') +
1400
+ '.',
1401
+ );
1402
+ }
1286
1403
  const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1287
1404
  if (!own) {
1288
1405
  lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
@@ -1300,10 +1417,10 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1300
1417
  lines.push(
1301
1418
  `✗ this session runs without ${missing.join(' ')}${why}. Hand the user the fix — ` +
1302
1419
  (deps.env['TMUX']
1303
- ? `\`vdx ai --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1420
+ ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1304
1421
  (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1305
1422
  `. Do not run it yourself: it ends this session.`
1306
- : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`vdx ai ${root}\`.`),
1423
+ : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
1307
1424
  );
1308
1425
  deps.out(lines.join('\n') + '\n');
1309
1426
  return EXIT_DRIFT;
@@ -1312,12 +1429,14 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1312
1429
  export function runAi(opts: AiOptions, deps: AiDeps): number {
1313
1430
  const { log, tmux } = deps;
1314
1431
 
1315
- const prepared = prepare(opts.path, deps);
1432
+ const prepared = prepare(opts.path, deps, opts.focused === true);
1316
1433
  if (typeof prepared === 'number') return prepared;
1317
1434
  const { projectRoot, environment, profileDir } = prepared;
1318
1435
  let { plan } = prepared;
1319
- // The agent started here runs on this machine: it does not inherit where the person sat.
1436
+ // The agent started here runs on this machine: it does not inherit where the person sat,
1437
+ // and it is focused by this start's flag, not by the shell vdx runs in.
1320
1438
  delete deps.env[SEAT_ENV_VAR];
1439
+ delete deps.env[FOCUSED_ENV_VAR];
1321
1440
  // Under `vdx ai@<host>` the person is not here: this machine goes by its name.
1322
1441
  const atSeat = sameMachine(plan.seat, plan.host);
1323
1442
  const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
@@ -1340,6 +1459,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1340
1459
  ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
1341
1460
  : [];
1342
1461
  const current = running[0];
1462
+ // A running agent keeps its mode: plain `vdx ai` takes a focused one as it is,
1463
+ // and the rules it leaves out are not missing from it.
1464
+ const focusedOf = deps.agentFocused ?? processFocused;
1465
+ const runningFocused = current ? focusedOf(current.proc.pid) : false;
1466
+ if (current && !opts.restart && runningFocused && !plan.focused) {
1467
+ plan = { ...prepared.replan(true), resumeArgs: plan.resumeArgs };
1468
+ }
1343
1469
 
1344
1470
  // What a start continues. A restart keeps the conversation the agent is in.
1345
1471
  let choice: ConversationChoice | null = null;
@@ -1357,13 +1483,18 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1357
1483
  }
1358
1484
 
1359
1485
  const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
1486
+ // 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) : [];
1360
1489
  if (opts.dryRun) {
1361
- deps.out(renderPlan(plan, multiplexer, running));
1490
+ deps.out(renderPlan(plan, multiplexer, running, focusedOf));
1362
1491
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1492
+ for (const l of folders) deps.out(` ${l}\n`);
1363
1493
  ensureAuthor(projectRoot, poolDirs, deps, true);
1364
1494
  return EXIT_OK;
1365
1495
  }
1366
1496
  for (const l of choice?.lines ?? []) log(l);
1497
+ for (const l of folders) log(l);
1367
1498
  if (choice?.abort) return EXIT_LAUNCH_FAILED;
1368
1499
  if (choice?.elsewhere && !current) {
1369
1500
  const answer = deps.interactive ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
@@ -1404,16 +1535,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1404
1535
  return EXIT_USAGE;
1405
1536
  }
1406
1537
  for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
1538
+ const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
1407
1539
  const startedAt = Date.now();
1408
- let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit' });
1540
+ let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
1409
1541
  if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
1410
1542
  log(`vdx ai: \`${plan.command}\` not found on PATH`);
1411
1543
  return 127;
1412
1544
  }
1413
1545
  const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
1414
1546
  if (plan.resumeArgs.length > 0 && failedAtOnce) {
1415
- log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${commandLine(plan.command, plan.args)}`);
1416
- res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit' });
1547
+ log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${agentLine(plan, plan.args)}`);
1548
+ res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit', env });
1417
1549
  }
1418
1550
  return res.status ?? 1;
1419
1551
  }
@@ -1436,32 +1568,50 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1436
1568
  );
1437
1569
  }
1438
1570
  }
1439
- const missing = missingArgs(current.proc.args, plan.args);
1440
- if (missing.length > 0) {
1441
- log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
1442
- log(
1443
- ` to apply the profile: vdx ai --restart${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1444
- (plan.resumeArgs.length
1445
- ? ` (stops the running agent, then resumes its conversation)`
1446
- : opts.fresh
1447
- ? ' (stops the running agent and starts a new conversation)'
1448
- : ' (stops the running agent; the conversation is not resumed — the profile has no resume_args)'),
1449
- );
1571
+ const unfocused = plan.focused && !runningFocused;
1572
+ const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
1573
+ if (!unfocused && missing.length === 0) {
1574
+ log(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
1575
+ return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1576
+ }
1577
+ // Drift. In a terminal the person picks: the agent as it runs, or a restart
1578
+ // with the profile; without one nothing is touched, and the fix is named.
1579
+ 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)}`;
1581
+ const effect = plan.resumeArgs.length
1582
+ ? 'stops the running agent, then resumes its conversation'
1583
+ : opts.fresh
1584
+ ? 'stops the running agent and starts a new conversation'
1585
+ : 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
1586
+ if (!deps.interactive) {
1587
+ log(`✗ ${drift}`);
1588
+ log(` ${unfocused ? 'to focus it' : 'to apply the profile'}: ${fix} (${effect})`);
1589
+ return EXIT_DRIFT;
1590
+ }
1591
+ log(`⚠ ${drift}`);
1592
+ log(` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`);
1593
+ log(` r restart it ${unfocused ? 'focused' : 'with the profile'}: ${effect}`);
1594
+ log(` q leave it; later: ${fix}`);
1595
+ const answer = deps.ask(`${opts.detach ? 'Leave it as is' : 'Attach as is'}? [Enter/r/q]: `);
1596
+ if (answer === '') {
1597
+ log(`→ ${current.pane.session}: as is — ${current.proc.args}`);
1598
+ return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1599
+ }
1600
+ if (answer?.toLowerCase() !== 'r') {
1601
+ log('nothing done');
1450
1602
  return EXIT_DRIFT;
1451
1603
  }
1452
- log(`✓ ${current.pane.session}: already running with the profile — ${current.proc.args}`);
1453
- return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1454
1604
  }
1455
1605
 
1456
1606
  if (current) {
1457
- const line = commandLine(plan.command, [...plan.args, ...plan.resumeArgs]);
1607
+ const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1458
1608
  log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
1459
1609
  tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
1460
1610
  return finish(settleResumed(plan, current.pane.paneId, deps), current.pane.session, current.pane.paneId, opts, deps);
1461
1611
  }
1462
1612
 
1463
1613
  if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
1464
- const line = commandLine(plan.command, [...plan.args, ...plan.resumeArgs]);
1614
+ const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1465
1615
  const cmd = tmuxShellCommand(line, deps.shell);
1466
1616
  const reuse = tmux.hasSession(plan.sessionName);
1467
1617
  const paneId = reuse
package/src/index.ts CHANGED
@@ -76,9 +76,17 @@ 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
- --restart restart the running agent in its pane — applies a changed profile
86
+ --restart restart the running agent in its pane — applies a changed profile.
87
+ Without it, an agent running without the profile's flags is
88
+ a question in a terminal: Enter attaches to it as is, r
89
+ restarts it, q leaves it
82
90
  --detach start without attaching; print the command that attaches
83
91
  --dry-run print the plan and the project's running agents; start nothing
84
92
  --check for the agent's own session: how the agent here is launched, and
@@ -97,7 +105,8 @@ machine's. A new conversation (--new, or none to continue) while an agent of
97
105
  the project runs on another machine starts only after a yes.
98
106
 
99
107
  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
108
+ 3 the running agent lacks profile flags, or is not focused under --focused,
109
+ and was left as is — no terminal, or q (rerun with --restart); 4 the start
101
110
  was not confirmed.
102
111
  Profile format: https://github.com/VoDmAl/vdx/blob/main/docs/specs/environment-format.md
103
112
  `;
@@ -128,7 +137,8 @@ mise.toml is overwritten only with --force.
128
137
  pre-flight checks — at a terminal an expired npm login is renewed (\`npm login\`)
129
138
  and the checks run again;
130
139
  the version bump in package.json and package-lock.json;
131
- npm publish (npm asks for an OTP);
140
+ npm publish (npm asks for an OTP or a confirmation in the browser; Ctrl-C
141
+ there puts the version back);
132
142
  git commit and tag, then \`git push --follow-tags\` to the branch's upstream;
133
143
  a wait until the registry shows the new version (up to 3 min).
134
144
  --dry-run runs the pre-flight and prints the plan; --force goes on past a failed
@@ -156,10 +166,10 @@ Exit 2 when something is missing.
156
166
  positionals: 1,
157
167
  },
158
168
  ai: {
159
- usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run]',
169
+ usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run]',
160
170
  help: AI_HELP,
161
171
  // `--resume` asked to continue in 0.12; continuing is the default now.
162
- bools: ['new', 'restart', 'resume', 'detach', 'dry-run', 'check'],
172
+ bools: ['new', 'focused', 'restart', 'resume', 'detach', 'dry-run', 'check'],
163
173
  values: ['conversation'],
164
174
  positionals: 1,
165
175
  },
@@ -500,6 +510,7 @@ function cmdAi(opts: ParsedArgs): void {
500
510
  conversation,
501
511
  detach: opts.flags.detach === true,
502
512
  dryRun: opts.flags['dry-run'] === true,
513
+ focused: opts.flags.focused === true,
503
514
  };
504
515
  const deps = { ...defaultDeps(), version: readCliVersion() };
505
516
  // `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.3.0';
16
16
 
17
17
  const VERB_ALIASES: Record<Verb, string[]> = {
18
18
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
package/src/publish.ts CHANGED
@@ -481,7 +481,7 @@ export function waitForRegistry(name: string, version: string, deps: PublishDeps
481
481
  * or there is none. The pre-flight refused a branch behind its upstream.
482
482
  * 5. Wait until the registry shows the version.
483
483
  *
484
- * On npm publish failure → restore the original files.
484
+ * On npm publish failure or interruption (Ctrl-C at its prompt) → restore the original files.
485
485
  * Git failures after a successful npm publish leave the user in a
486
486
  * partially-released state; they fix it manually (commit/tag/push what's
487
487
  * needed) — we don't try to unpublish.
@@ -511,14 +511,22 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
511
511
  `✓ bumped ${bumped.map((f) => path.relative(plan.projectRoot, f)).join(' and ')}: ${plan.currentVersion} → ${plan.newVersion}`,
512
512
  );
513
513
 
514
- // 2. npm publish (interactive — OTP prompt may appear)
515
- deps.log('→ running `npm publish` (OTP prompt may appear)...');
514
+ // 2. npm publish (interactive — npm asks for an OTP or a confirmation in the browser).
515
+ // Ctrl-C at that prompt reaches npm and vdx alike: vdx holds out while npm runs, so the
516
+ // interrupted npm comes back as an error and the bump is put back — not left for the next run.
517
+ deps.log('→ running `npm publish` (npm may ask for an OTP or a confirmation in the browser)...');
518
+ const holdOut = () => {};
519
+ const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
520
+ for (const s of signals) process.on(s, holdOut);
516
521
  try {
517
522
  deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
518
523
  } catch (e: any) {
519
524
  for (const [file, text] of originals) fs.writeFileSync(file, text, 'utf8');
520
- deps.log(`✗ npm publish failed (exit ${e?.status ?? '?'}); reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
521
- throw new Error('npm publish failed');
525
+ const what = e?.signal ? `interrupted (${e.signal})` : `failed (exit ${e?.status ?? '?'})`;
526
+ deps.log(`✗ npm publish ${what}; reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
527
+ throw new Error(`npm publish ${e?.signal ? 'interrupted' : 'failed'}`);
528
+ } finally {
529
+ for (const s of signals) process.off(s, holdOut);
522
530
  }
523
531
  deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
524
532