@vodmal/vdx-cli 0.35.0 → 0.36.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
@@ -54,7 +54,8 @@ vdx doctor --check [project-path] # one line per hook declared but not running
54
54
  vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
55
55
 
56
56
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
57
- vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
57
+ vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused | --intercom] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there; --intercom: the project's mail session
58
+ vdx ai --close [--waiting <n>] # inside the mail session: it ends itself; n questions to you still unanswered show on the project's other tmux sessions
58
59
  vdx ai --check [project-path] # this session against the profile and the installed Claude Code and plugins — what the plugin's SessionStart hook tells the agent
59
60
 
60
61
  # What a command does and takes; nothing runs (ai: also where launch flags live)
@@ -79,6 +80,7 @@ vdx test # → mise run test (which calls `vitest run` or whatever wa
79
80
 
80
81
  # 3. Ship a new minor release of a Node lib
81
82
  vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP or browser) → commit + tag → push → wait for the registry
83
+ # in your own terminal: without one it releases nothing (--force goes on)
82
84
 
83
85
  # 4. Start the agent here, continuing its last conversation (--new: a new one,
84
86
  # next to a running agent; another machine's conversation is continued on
@@ -143,6 +145,9 @@ the one git keeps for that host (`git credential fill`, never a prompt — log i
143
145
  once with `git ls-remote https://<host>/scm/<project>/<repo>.git`, a personal
144
146
  access token as the password), or `BITBUCKET_TOKEN` when the host is
145
147
  `BITBUCKET_HOST`. Branch restrictions are visible to a repository admin only.
148
+ A login the server refuses (a revoked token) is unknown with the command that
149
+ erases it: vdx leaves git's store alone, and sends the login again each audit
150
+ until it is gone.
146
151
  Not met comes with the reason; another hosting, no network or no token
147
152
  where one is needed leave the axis unknown, with the reason too;
148
153
  `VDX_OFFLINE=1` does not ask at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.35.0",
3
+ "version": "0.36.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.5.0"
14
+ version: "1.6.1"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
@@ -260,8 +260,9 @@ axes:
260
260
  default_target: L4
261
261
  fact_sources: [hosting API]
262
262
  # Гейт — свойство L3 общей лестницы; ниже ось ничего не требует и уровень
263
- # не ограничивает. Знает о защите только хостинг: пока расширения для него
264
- # нет, L3 — «неизвестно» у всех. L4 — позже: force-push, удаление ветки,
263
+ # не ограничивает. Знает о защите только хостинг: vdx спрашивает GitHub
264
+ # (с CLI 0.29), GitLab (0.33) и Bitbucket Server (0.34), у другого хостинга
265
+ # L3 — «неизвестно» с причиной. L4 — позже: force-push, удаление ветки,
265
266
  # ревью (как Branch-Protection у Scorecard).
266
267
  levels:
267
268
  L1: { not_required: true }
package/src/ai.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  liveConversationId,
23
23
  liveSessionsHere,
24
24
  liveStartedAt,
25
+ MAIL_LINE_PREFIX,
25
26
  machineLine,
26
27
  type MachineReport,
27
28
  machineOf,
@@ -69,6 +70,8 @@ export interface AgentProfile {
69
70
  command: string;
70
71
  args?: string[];
71
72
  resume_args?: string[];
73
+ /** What a mail session (`vdx ai --intercom`) starts with, right after the command: its first prompt. */
74
+ intercom_args?: string[];
72
75
  when?: WhenRule[];
73
76
  }
74
77
 
@@ -131,6 +134,16 @@ const SEAT_ENV_VAR = 'VDX_AI_FROM';
131
134
  * its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
132
135
  */
133
136
  const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
137
+ /**
138
+ * `1` in the environment of the project's mail session (`vdx ai --intercom`): the
139
+ * session letters and wakes go to, so that the person's own session stays quiet.
140
+ * Nobody sits at it; it ends itself with `vdx ai --close`.
141
+ */
142
+ const INTERCOM_ENV_VAR = 'VDX_INTERCOM';
143
+ /** The tmux session option `vdx ai --close --waiting <n>` sets: questions to the person still unanswered. */
144
+ const WAITING_OPTION = '@vdx_waiting';
145
+ /** A mail session's pane closes this long after `--close`, so the call returns and the turn ends. */
146
+ const CLOSE_DELAY_S = 2;
134
147
  const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
135
148
  /** Tried in order when no profile names an agent. Started plain, with no flags. */
136
149
  const FALLBACK_AGENTS = ['claude', 'codex'] as const;
@@ -164,7 +177,7 @@ export function parseEnvironment(text: string, source: string): Environment {
164
177
  if (typeof agent.command !== 'string' || agent.command.trim() === '') {
165
178
  fail('agent.command', 'must be a non-empty string');
166
179
  }
167
- for (const key of ['args', 'resume_args'] as const) {
180
+ for (const key of ['args', 'resume_args', 'intercom_args'] as const) {
168
181
  if (agent[key] !== undefined && !isStringList(agent[key])) {
169
182
  fail(`agent.${key}`, 'must be a list of strings');
170
183
  }
@@ -325,6 +338,10 @@ export interface LaunchPlan {
325
338
  focused: boolean;
326
339
  /** Matched rules a focused session leaves out (`wakes: true`). */
327
340
  skipped: string[];
341
+ /** `vdx ai --intercom`: the project's mail session — VDX_INTERCOM=1, a session `<name>-mail`. */
342
+ intercom: boolean;
343
+ /** agent.intercom_args: what a mail session starts with; not required of a running agent. */
344
+ intercomArgs: string[];
328
345
  confirm: ConfirmRule[];
329
346
  multiplexer: Multiplexer;
330
347
  sessionName: string;
@@ -342,9 +359,11 @@ export function planLaunch(input: {
342
359
  host: string;
343
360
  seat?: string;
344
361
  focused?: boolean;
362
+ intercom?: boolean;
345
363
  }): LaunchPlan {
346
364
  const { environment, agent, projectRoot } = input;
347
365
  const focused = input.focused === true;
366
+ const intercom = input.intercom === true && !focused;
348
367
  const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
349
368
  const args = [...(agent.args ?? [])];
350
369
  const matched: string[] = [];
@@ -373,12 +392,15 @@ export function planLaunch(input: {
373
392
  matched,
374
393
  focused,
375
394
  skipped,
395
+ intercom,
396
+ intercomArgs: intercom ? [...(agent.intercom_args ?? [])] : [],
376
397
  confirm,
377
398
  multiplexer: environment.session?.multiplexer ?? 'none',
378
- sessionName: renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
379
- project: input.project,
380
- host: input.host,
381
- }),
399
+ sessionName:
400
+ renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
401
+ project: input.project,
402
+ host: input.host,
403
+ }) + (intercom ? '-mail' : ''),
382
404
  machines: (environment.session?.machines ?? []).filter((m) => m.toLowerCase() !== input.host.toLowerCase()),
383
405
  };
384
406
  }
@@ -395,12 +417,13 @@ export function commandLine(command: string, args: string[]): string {
395
417
  /**
396
418
  * The pane runs the agent inside the user's login shell, so the agent sees what
397
419
  * `.zshrc` sets up exactly as when it is typed by hand; the tmux server's own
398
- * environment does not have it. When the agent exits, the pane keeps a shell.
420
+ * environment does not have it. When the agent exits, the pane keeps a shell —
421
+ * unless `keepShell` is false (a mail session: its tab goes when it is done).
399
422
  */
400
- export function tmuxShellCommand(line: string, shell: string | null): string {
423
+ export function tmuxShellCommand(line: string, shell: string | null, keepShell = true): string {
401
424
  if (!shell) return line;
402
425
  const sh = shellQuote(shell);
403
- return `exec ${sh} -lic ${shellQuote(`${line}; exec ${sh} -l`)}`;
426
+ return `exec ${sh} -lic ${shellQuote(keepShell ? `${line}; exec ${sh} -l` : line)}`;
404
427
  }
405
428
 
406
429
  export interface PaneInfo {
@@ -723,6 +746,8 @@ export interface AiOptions {
723
746
  dryRun: boolean;
724
747
  /** `--focused`: a session nothing wakes — VDX_FOCUSED=1, the profile's `wakes` rules left out. */
725
748
  focused?: boolean;
749
+ /** `--intercom`: the project's mail session — VDX_INTERCOM=1, one per project and machine. */
750
+ intercom?: boolean;
726
751
  }
727
752
 
728
753
  export interface AiDeps {
@@ -738,6 +763,8 @@ export interface AiDeps {
738
763
  processes: () => ProcInfo[];
739
764
  /** Was this agent process started focused; default: its environment (`processFocused`). */
740
765
  agentFocused?: (pid: number) => boolean;
766
+ /** Is this agent process a mail session; default: its environment (`processMode`). */
767
+ agentMail?: (pid: number) => boolean;
741
768
  /** Default `process.platform`: the folder check is for macOS. */
742
769
  platform?: NodeJS.Platform;
743
770
  sleep: (ms: number) => void;
@@ -844,6 +871,8 @@ function renderPlan(
844
871
  focusedOf: (pid: number) => boolean,
845
872
  /** The session a start would make: next to running agents, one of its own. */
846
873
  session: string,
874
+ /** The project's agents this start leaves alone: the mail session for the others, the others for it. */
875
+ aside: AgentPane[] = [],
847
876
  ): string {
848
877
  const lines = [
849
878
  `vdx ai — ${plan.projectRoot}`,
@@ -855,6 +884,12 @@ function renderPlan(
855
884
  ];
856
885
  if (plan.focused)
857
886
  lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
887
+ if (plan.intercom)
888
+ lines.push(
889
+ ` mail: the project's mail session, a new conversation; first: ${
890
+ plan.intercomArgs.length ? plan.intercomArgs.join(' ') : '— (no agent.intercom_args: it waits for a wake)'
891
+ }`,
892
+ );
858
893
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
859
894
  lines.push(
860
895
  multiplexer === 'tmux'
@@ -872,6 +907,11 @@ function renderPlan(
872
907
  }${focused ? ', focused' : plan.focused ? ', not focused' : ''}`,
873
908
  );
874
909
  }
910
+ for (const a of aside) {
911
+ lines.push(
912
+ ` aside: ${a.pane.session} ${a.pane.paneId} — ${plan.intercom ? 'not the mail session' : 'the mail session'}, left alone`,
913
+ );
914
+ }
875
915
  }
876
916
  return lines.join('\n') + '\n';
877
917
  }
@@ -974,7 +1014,7 @@ function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean
974
1014
  if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
975
1015
  const line = agentLine(plan, plan.args);
976
1016
  deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
977
- deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
1017
+ deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
978
1018
  return settle(plan, paneId, deps) === 'ok';
979
1019
  }
980
1020
 
@@ -1019,6 +1059,7 @@ export function remoteAiArgs(input: {
1019
1059
  const flags = [
1020
1060
  opts.fresh && '--new',
1021
1061
  opts.focused && '--focused',
1062
+ opts.intercom && '--intercom',
1022
1063
  opts.restart && '--restart',
1023
1064
  opts.detach && '--detach',
1024
1065
  opts.dryRun && '--dry-run',
@@ -1179,10 +1220,11 @@ function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): Co
1179
1220
  if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1180
1221
  const host = plan.host;
1181
1222
  const same = sameMachine;
1223
+ // A mail session's conversations are its own: a start of the person's never continues one.
1182
1224
  const all =
1183
1225
  plan.resumeArgs.length === 0
1184
1226
  ? []
1185
- : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
1227
+ : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot }).filter((c) => !c.mail);
1186
1228
  const root = shellQuote(plan.projectRoot);
1187
1229
  // Said from where the person sits: under `vdx ai@<host>` that is not this machine.
1188
1230
  const atSeat = same(plan.seat, host);
@@ -1374,7 +1416,7 @@ interface Prepared {
1374
1416
  }
1375
1417
 
1376
1418
  /** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
1377
- function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | number {
1419
+ function prepare(pathArg: string, deps: AiDeps, focused = false, intercom = false): Prepared | number {
1378
1420
  const { log } = deps;
1379
1421
 
1380
1422
  const projectRoot = resolveProjectRoot(pathArg);
@@ -1423,6 +1465,7 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1423
1465
  host: hostLabel(deps.env),
1424
1466
  seat: seatLabel(deps.env),
1425
1467
  focused: asFocused,
1468
+ intercom,
1426
1469
  });
1427
1470
  let plan: LaunchPlan;
1428
1471
  try {
@@ -1434,20 +1477,35 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1434
1477
  return { projectRoot, environment, profilePath, profileDir, plan, replan };
1435
1478
  }
1436
1479
 
1437
- /** Was the process started focused — VDX_FOCUSED=1 in its environment (`ps -E` on macOS, /proc on Linux)? */
1438
- export function processFocused(pid: number): boolean {
1439
- const mark = `${FOCUSED_ENV_VAR}=1`;
1480
+ /** How an agent process was started: `vdx ai --focused`, `vdx ai --intercom`, or neither. */
1481
+ export type AgentMode = 'focused' | 'mail' | 'plain';
1482
+
1483
+ /**
1484
+ * The mode from the process's environment (`ps -E` on macOS, /proc on Linux). Both marks —
1485
+ * focused: deaf wins, as intercom reads it.
1486
+ */
1487
+ export function processMode(pid: number): AgentMode {
1488
+ let words: string[];
1440
1489
  try {
1441
1490
  const environ = `/proc/${pid}/environ`;
1442
- if (fs.existsSync(environ)) return fs.readFileSync(environ, 'utf8').split('\0').includes(mark);
1443
- const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
1444
- encoding: 'utf8',
1445
- stdio: ['ignore', 'pipe', 'ignore'],
1446
- });
1447
- return out.split(/\s+/).includes(mark);
1491
+ if (fs.existsSync(environ)) words = fs.readFileSync(environ, 'utf8').split('\0');
1492
+ else {
1493
+ const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
1494
+ encoding: 'utf8',
1495
+ stdio: ['ignore', 'pipe', 'ignore'],
1496
+ });
1497
+ words = out.split(/\s+/);
1498
+ }
1448
1499
  } catch {
1449
- return false;
1500
+ return 'plain';
1450
1501
  }
1502
+ if (words.includes(`${FOCUSED_ENV_VAR}=1`)) return 'focused';
1503
+ return words.includes(`${INTERCOM_ENV_VAR}=1`) ? 'mail' : 'plain';
1504
+ }
1505
+
1506
+ /** Was the process started focused — VDX_FOCUSED=1 in its environment? */
1507
+ export function processFocused(pid: number): boolean {
1508
+ return processMode(pid) === 'focused';
1451
1509
  }
1452
1510
 
1453
1511
  /** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
@@ -1504,9 +1562,23 @@ export function foldersWarning(denied: string[], viaTmux: boolean, fix: string |
1504
1562
  ];
1505
1563
  }
1506
1564
 
1507
- /** The agent's command line; a focused one carries VDX_FOCUSED=1 in front, for the shell in its tmux pane. */
1565
+ /**
1566
+ * The agent's argv after the command. A mail session's intercom_args go first: its first
1567
+ * prompt after `--dangerously-load-development-channels <servers...>` would be read as a server.
1568
+ */
1569
+ function launchArgs(plan: LaunchPlan, args: string[]): string[] {
1570
+ return [...plan.intercomArgs, ...args];
1571
+ }
1572
+
1573
+ /** The mark of the agent's mode in its environment, if any: `VDX_FOCUSED` or `VDX_INTERCOM`. */
1574
+ function modeEnvVar(plan: LaunchPlan): string | null {
1575
+ return plan.focused ? FOCUSED_ENV_VAR : plan.intercom ? INTERCOM_ENV_VAR : null;
1576
+ }
1577
+
1578
+ /** The agent's command line; its mode's mark goes in front, for the shell in its tmux pane. */
1508
1579
  function agentLine(plan: LaunchPlan, args: string[]): string {
1509
- return `${plan.focused ? `${FOCUSED_ENV_VAR}=1 ` : ''}${commandLine(plan.command, args)}`;
1580
+ const mark = modeEnvVar(plan);
1581
+ return `${mark ? `${mark}=1 ` : ''}${commandLine(plan.command, launchArgs(plan, args))}`;
1510
1582
  }
1511
1583
 
1512
1584
  /** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
@@ -1527,12 +1599,14 @@ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): P
1527
1599
  */
1528
1600
  export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
1529
1601
  if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
1530
- // The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1.
1602
+ // The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1,
1603
+ // a mail session's VDX_INTERCOM=1.
1531
1604
  const focused = deps.env[FOCUSED_ENV_VAR] === '1';
1532
- const prepared = prepare(pathArg, deps, focused);
1605
+ const intercom = !focused && deps.env[INTERCOM_ENV_VAR] === '1';
1606
+ const prepared = prepare(pathArg, deps, focused, intercom);
1533
1607
  if (typeof prepared === 'number') return prepared;
1534
1608
  const { projectRoot, profilePath, plan } = prepared;
1535
- const vdxAi = `vdx ai${focused ? ' --focused' : ''}`;
1609
+ const vdxAi = `vdx ai${focused ? ' --focused' : intercom ? ' --intercom' : ''}`;
1536
1610
 
1537
1611
  const lines = [
1538
1612
  `${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
@@ -1549,6 +1623,17 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1549
1623
  '.',
1550
1624
  );
1551
1625
  }
1626
+ if (intercom) {
1627
+ // The conversation is known by this line as a mail session's (conversations.ts): keep its opening.
1628
+ lines.push(
1629
+ `${MAIL_LINE_PREFIX} (\`vdx ai --intercom\`): letters and wakes for the project come here, so the user's ` +
1630
+ `own session stays quiet, and nobody sits at this one. When nothing is left for it — right after a check ` +
1631
+ `of its inbox shows nothing to take — it ends itself: \`vdx ai --close\` (the pane closes ${CLOSE_DELAY_S} s ` +
1632
+ `later, so say what was done first). With questions to the user still unanswered: ` +
1633
+ `\`vdx ai --close --waiting <n>\` — the project's other tmux sessions here show the number; a plain ` +
1634
+ `\`--close\` clears it.`,
1635
+ );
1636
+ }
1552
1637
  const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1553
1638
  if (!own) {
1554
1639
  lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
@@ -1587,7 +1672,12 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1587
1672
  `Hand the user the fix — ${reload}` +
1588
1673
  (deps.env['TMUX']
1589
1674
  ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
1590
- (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1675
+ // A focused session and the mail session restart into a new conversation.
1676
+ (focused || intercom
1677
+ ? ', in a new conversation'
1678
+ : plan.resumeArgs.length
1679
+ ? `, resuming the conversation (${plan.resumeArgs.join(' ')})`
1680
+ : '') +
1591
1681
  `. Do not run it yourself: it ends this session.`
1592
1682
  : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
1593
1683
  );
@@ -1596,6 +1686,60 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1596
1686
  return EXIT_DRIFT;
1597
1687
  }
1598
1688
 
1689
+ /**
1690
+ * `vdx ai --close [--waiting <n>]`: the mail session ends itself — run inside it, by its
1691
+ * agent. Only the pane whose agent is this process's own ancestor and carries VDX_INTERCOM=1
1692
+ * is closed, a few seconds later: the command never ends the person's session. The project's
1693
+ * other tmux sessions here get `@vdx_waiting` = n, the questions to the person still
1694
+ * unanswered — the mail session's tab is about to go; without `--waiting`, none, and the
1695
+ * mark is cleared: the last mail session's word holds.
1696
+ */
1697
+ export function runAiClose(pathArg: string, waiting: number, deps: AiDeps, selfPid: number = process.pid): number {
1698
+ const { log, tmux } = deps;
1699
+ const prepared = prepare(pathArg, deps, false, true);
1700
+ if (typeof prepared === 'number') return prepared;
1701
+ const { projectRoot, plan } = prepared;
1702
+ const procs = deps.processes();
1703
+ const own = findOwnAgent(procs, selfPid, plan.command);
1704
+ const mailOf = deps.agentMail ?? ((pid: number) => processMode(pid) === 'mail');
1705
+ if (!own || !mailOf(own.pid)) {
1706
+ log(
1707
+ `vdx ai --close: this is not a mail session (\`vdx ai --intercom\`)` +
1708
+ `${own ? '' : ` — no ${plan.command} among the ancestors of this process`}; nothing closed`,
1709
+ );
1710
+ return EXIT_USAGE;
1711
+ }
1712
+ const panes = tmux.panes();
1713
+ const pane = panes.find((p) => processTree(procs, p.panePid).some((x) => x.pid === own.pid));
1714
+ if (!pane) {
1715
+ log('vdx ai --close: this mail session runs outside tmux — nothing to close here; it ends when its agent exits');
1716
+ return EXIT_USAGE;
1717
+ }
1718
+ const others = [
1719
+ ...new Set(findAgentPanes(panes, procs, projectRoot, plan.command).map((a) => a.pane.session)),
1720
+ ].filter((s) => s !== pane.session);
1721
+ for (const s of others) {
1722
+ // `=name:` — a session option needs the session as a target, `=name` alone is not found.
1723
+ tmux.tryRun(
1724
+ waiting > 0
1725
+ ? ['set-option', '-t', `=${s}:`, WAITING_OPTION, String(waiting)]
1726
+ : ['set-option', '-u', '-t', `=${s}:`, WAITING_OPTION],
1727
+ );
1728
+ }
1729
+ if (waiting > 0)
1730
+ log(
1731
+ others.length
1732
+ ? `✓ ${waiting} waiting for the user — shown on ${others.join(', ')}`
1733
+ : `note: ${waiting} waiting for the user, and no other session of this project here to show it`,
1734
+ );
1735
+ if (tmux.tryRun(['run-shell', '-b', '-d', String(CLOSE_DELAY_S), '-C', `kill-pane -t ${pane.paneId}`]) === null) {
1736
+ log(`✗ ${pane.session} ${pane.paneId}: tmux did not take the close — the mail session keeps running`);
1737
+ return EXIT_LAUNCH_FAILED;
1738
+ }
1739
+ log(`✓ ${pane.session} ${pane.paneId}: the mail session closes in ${CLOSE_DELAY_S} s`);
1740
+ return EXIT_OK;
1741
+ }
1742
+
1599
1743
  /** `this session runs Claude Code 2.1.294 — 2.1.304 is installed; plugins updated since it loaded them: vdm 2.54.0`. */
1600
1744
  function describeLag(lag: Lag): string {
1601
1745
  const names = lag.plugins.map((p) => `${p.name} ${p.version}`).join(', ');
@@ -1607,14 +1751,23 @@ function describeLag(lag: Lag): string {
1607
1751
  export function runAi(opts: AiOptions, deps: AiDeps): number {
1608
1752
  const { log, tmux } = deps;
1609
1753
 
1610
- const prepared = prepare(opts.path, deps, opts.focused === true);
1754
+ if (opts.intercom && opts.focused) {
1755
+ log('vdx ai: --intercom and --focused are different sessions — the mail session, or one nothing wakes; pick one');
1756
+ return EXIT_USAGE;
1757
+ }
1758
+ if (opts.intercom && opts.conversation) {
1759
+ log('vdx ai: --intercom starts the mail session in a new conversation — it takes no --conversation');
1760
+ return EXIT_USAGE;
1761
+ }
1762
+ const prepared = prepare(opts.path, deps, opts.focused === true, opts.intercom === true);
1611
1763
  if (typeof prepared === 'number') return prepared;
1612
1764
  const { projectRoot, environment, profileDir } = prepared;
1613
1765
  let { plan } = prepared;
1614
1766
  // The agent started here runs on this machine: it does not inherit where the person sat,
1615
- // and it is focused by this start's flag, not by the shell vdx runs in.
1767
+ // and its mode is this start's flag, not the shell vdx runs in.
1616
1768
  delete deps.env[SEAT_ENV_VAR];
1617
1769
  delete deps.env[FOCUSED_ENV_VAR];
1770
+ delete deps.env[INTERCOM_ENV_VAR];
1618
1771
  // Under `vdx ai@<host>` the person is not here: this machine goes by its name.
1619
1772
  const atSeat = sameMachine(plan.seat, plan.host);
1620
1773
  const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
@@ -1623,8 +1776,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1623
1776
  log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
1624
1777
  return EXIT_USAGE;
1625
1778
  }
1626
- // A start continues the last conversation unless asked for a new one; a focused session is a new one.
1627
- const wantsNew = (opts.fresh || opts.focused === true) && !opts.conversation;
1779
+ // A start continues the last conversation unless asked for a new one; a focused session and
1780
+ // the mail session are new ones.
1781
+ const wantsNew = (opts.fresh || opts.focused === true || plan.intercom) && !opts.conversation;
1628
1782
  if (wantsNew) plan = { ...plan, resumeArgs: [] };
1629
1783
 
1630
1784
  let multiplexer = plan.multiplexer;
@@ -1633,8 +1787,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1633
1787
  multiplexer = 'none';
1634
1788
  }
1635
1789
 
1636
- const running =
1790
+ const agentsHere =
1637
1791
  multiplexer === 'tmux' ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command) : [];
1792
+ // The mail session and the person's sessions do not see each other: --intercom is about the
1793
+ // one mail session, every other start leaves it alone — nobody sits at it, and it ends itself.
1794
+ const mailOf = deps.agentMail ?? ((pid: number) => processMode(pid) === 'mail');
1795
+ const running = agentsHere.filter((a) => mailOf(a.proc.pid) === plan.intercom);
1796
+ const aside = agentsHere.filter((a) => !running.includes(a));
1638
1797
  const focusedOf = deps.agentFocused ?? processFocused;
1639
1798
  const conversationOf = (a: AgentPane) => liveConversationId(deps.env, deps.home, a.proc.pid);
1640
1799
  // Which running agent the start is about. A new conversation, or one no agent here is in, starts
@@ -1642,8 +1801,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1642
1801
  // one asked for: no second agent in it blindly.
1643
1802
  const inConversation = opts.conversation ? running.find((a) => conversationOf(a) === opts.conversation) : undefined;
1644
1803
  const unreadable = opts.conversation && !inConversation ? running.find((a) => conversationOf(a) === null) : undefined;
1804
+ // One mail session per project and machine: a running one is the one, never a second next to it.
1645
1805
  const alongside =
1646
- running.length > 0 && !opts.restart && !inConversation && !unreadable && (wantsNew || !!opts.conversation);
1806
+ !plan.intercom &&
1807
+ running.length > 0 &&
1808
+ !opts.restart &&
1809
+ !inConversation &&
1810
+ !unreadable &&
1811
+ (wantsNew || !!opts.conversation);
1647
1812
  let current = inConversation ?? unreadable;
1648
1813
  if (!current && !alongside && running.length > 0) {
1649
1814
  const picked = pickAgent(running, focusedOf, deps, !opts.dryRun);
@@ -1663,7 +1828,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1663
1828
  // What a start continues. A restart keeps the conversation the agent is in.
1664
1829
  let choice: ConversationChoice | null = null;
1665
1830
  if (!current || opts.restart) {
1666
- if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1831
+ // The mail session runs next to the person's agents on any machine by design: nothing to ask.
1832
+ if (plan.intercom) choice = { args: [], lines: [] };
1833
+ else if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1667
1834
  // A restart into a new conversation replaces the agent here, and a new one next to an agent here
1668
1835
  // is what was asked for: the other machines are not asked.
1669
1836
  else if ((current || alongside) && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
@@ -1687,7 +1854,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1687
1854
  : [];
1688
1855
  if (opts.dryRun) {
1689
1856
  deps.out(
1690
- renderPlan(plan, multiplexer, running, focusedOf, freeSessionName(plan.sessionName, alongside ? running : [])),
1857
+ renderPlan(
1858
+ plan,
1859
+ multiplexer,
1860
+ running,
1861
+ focusedOf,
1862
+ freeSessionName(plan.sessionName, alongside ? running : []),
1863
+ aside,
1864
+ ),
1691
1865
  );
1692
1866
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1693
1867
  for (const l of folders) deps.out(` ${l}\n`);
@@ -1738,9 +1912,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1738
1912
  return EXIT_USAGE;
1739
1913
  }
1740
1914
  for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
1741
- const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
1915
+ const mark = modeEnvVar(plan);
1916
+ const env = mark ? { ...process.env, [mark]: '1' } : undefined;
1742
1917
  const startedAt = Date.now();
1743
- let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
1918
+ let res = spawnSync(plan.command, launchArgs(plan, [...plan.args, ...plan.resumeArgs]), {
1919
+ cwd: projectRoot,
1920
+ stdio: 'inherit',
1921
+ env,
1922
+ });
1744
1923
  if (at(res.error, 'code') === 'ENOENT') {
1745
1924
  log(`vdx ai: \`${plan.command}\` not found on PATH`);
1746
1925
  return 127;
@@ -1748,7 +1927,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1748
1927
  const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
1749
1928
  if (plan.resumeArgs.length > 0 && failedAtOnce) {
1750
1929
  log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${agentLine(plan, plan.args)}`);
1751
- res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit', env });
1930
+ res = spawnSync(plan.command, launchArgs(plan, plan.args), { cwd: projectRoot, stdio: 'inherit', env });
1752
1931
  }
1753
1932
  return res.status ?? 1;
1754
1933
  }
@@ -1771,20 +1950,20 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1771
1950
  const unfocused = plan.focused && !runningFocused;
1772
1951
  const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
1773
1952
  if (!unfocused && missing.length === 0) {
1774
- log(
1775
- `✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`,
1776
- );
1953
+ const mode = runningFocused ? ', focused' : plan.intercom ? ', the mail session' : '';
1954
+ log(`✓ ${current.pane.session}: already running with the profile${mode} — ${current.proc.args}`);
1777
1955
  return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1778
1956
  }
1779
1957
  // Drift. In a terminal the person picks: the agent as it runs, or a restart
1780
1958
  // with the profile; without one nothing is touched, and the fix is named.
1781
1959
  const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
1960
+ const modeFlag = plan.focused ? ' --focused' : plan.intercom ? ' --intercom' : '';
1782
1961
  const fix =
1783
- `vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1962
+ `vdx ai --restart${modeFlag}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1784
1963
  (opts.conversation ? ` --conversation ${opts.conversation}` : '');
1785
1964
  const effect = plan.resumeArgs.length
1786
1965
  ? 'stops the running agent, then resumes its conversation'
1787
- : opts.fresh
1966
+ : opts.fresh || plan.intercom
1788
1967
  ? 'stops the running agent and starts a new conversation'
1789
1968
  : 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
1790
1969
  if (!deps.interactive) {
@@ -1815,7 +1994,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1815
1994
  if (current) {
1816
1995
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1817
1996
  log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
1818
- tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
1997
+ tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
1819
1998
  return finish(
1820
1999
  settleResumed(plan, current.pane.paneId, deps),
1821
2000
  current.pane.session,
@@ -1826,8 +2005,10 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1826
2005
  }
1827
2006
 
1828
2007
  if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
2008
+ if (plan.intercom && plan.intercomArgs.length === 0)
2009
+ log('note: the profile has no agent.intercom_args — the mail session starts idle and waits for a wake');
1829
2010
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1830
- const cmd = tmuxShellCommand(line, deps.shell);
2011
+ const cmd = tmuxShellCommand(line, deps.shell, !plan.intercom);
1831
2012
  const session = freeSessionName(plan.sessionName, running);
1832
2013
  const reuse = tmux.hasSession(session);
1833
2014
  const paneId = reuse ? tmux.newWindow(session, projectRoot, cmd) : tmux.newSession(session, projectRoot, cmd);
@@ -31,6 +31,8 @@ import { at, dict, str } from './narrow.ts';
31
31
  /** The opening of `vdx ai --check`'s text; the label is an ssh destination from the other machines. */
32
32
  const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
33
33
  const MACHINE_LINE_RE = /^vdx ai: this machine is `([^`]+)`/m;
34
+ /** The opening of the line `vdx ai --check` adds in a mail session (`vdx ai --intercom`). */
35
+ export const MAIL_LINE_PREFIX = "· this is the project's mail session";
34
36
  /** A label read from a synced file goes to ssh: it must not be able to read as an option. */
35
37
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
36
38
  const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -58,6 +60,8 @@ export interface Conversation {
58
60
  typedHere: boolean;
59
61
  /** Claude's title for it, else the last prompt. */
60
62
  title: string | null;
63
+ /** Last started as the project's mail session (`vdx ai --intercom`): not the person's to continue. */
64
+ mail: boolean;
61
65
  }
62
66
 
63
67
  export function claudeConfigDir(env: NodeJS.ProcessEnv, home: string): string {
@@ -78,6 +82,7 @@ export function readConversation(file: string, updated: Date): Conversation | nu
78
82
  return null;
79
83
  }
80
84
  let machine: string | null = null;
85
+ let mail = false;
81
86
  let aiTitle: string | null = null;
82
87
  let lastPrompt: string | null = null;
83
88
  let turns = false;
@@ -102,13 +107,17 @@ export function readConversation(file: string, updated: Date): Conversation | nu
102
107
  const content = at(entry, 'attachment', 'content');
103
108
  for (const part of Array.isArray(content) ? (content as unknown[]) : [content]) {
104
109
  const label = typeof part === 'string' ? part.match(MACHINE_LINE_RE)?.[1] : undefined;
105
- if (label && LABEL_RE.test(label)) machine = label;
110
+ if (label && LABEL_RE.test(label)) {
111
+ machine = label;
112
+ // The same text says whether that start was a mail session's.
113
+ mail = (part as string).includes(MAIL_LINE_PREFIX);
114
+ }
106
115
  }
107
116
  }
108
117
  }
109
118
  if (!turns) return null;
110
119
  const id = path.basename(file, '.jsonl');
111
- return { id, updated, machine, typedHere: false, title: aiTitle ?? lastPrompt };
120
+ return { id, updated, machine, typedHere: false, title: aiTitle ?? lastPrompt, mail };
112
121
  }
113
122
 
114
123
  /** Which of `ids` had a prompt typed in them on this machine. */
package/src/hosting.ts CHANGED
@@ -332,7 +332,7 @@ function judgeGitlab(io: HostingIo, host: string, repo: string, token: string |
332
332
  interface BitbucketAuth {
333
333
  token: string;
334
334
  scheme: 'Bearer' | 'Basic';
335
- source: string;
335
+ source: 'BITBUCKET_TOKEN' | 'the login git keeps';
336
336
  }
337
337
 
338
338
  const MIN_BUILDS = 'com.atlassian.bitbucket.server.bitbucket-build:requiredBuildsMergeCheck';
@@ -340,14 +340,37 @@ const MIN_BUILDS = 'com.atlassian.bitbucket.server.bitbucket-build:requiredBuild
340
340
  /** A refusal of Bitbucket as the reason the answer is unknown; null for a 200. */
341
341
  function bitbucketRefusal(a: Answer, host: string, repo: string, auth: BitbucketAuth | null): Unknown | null {
342
342
  if (a.status === 200) return null;
343
- const message = str(at(list(at(a.body, 'errors'))[0], 'message')) ?? '';
343
+ const error = list(at(a.body, 'errors'))[0];
344
+ const message = str(at(error, 'message')) ?? '';
345
+ const login = `git ls-remote https://${host}/scm/${repo}.git, a token as the password`;
344
346
  if (a.status === 401 && !auth) {
345
347
  return {
346
- unknown: `${host} shows ${repo} only after a login — once: git ls-remote https://${host}/scm/${repo}.git, a token as the password; or BITBUCKET_TOKEN with BITBUCKET_HOST`,
348
+ unknown: `${host} shows ${repo} only after a login — once: ${login}; or BITBUCKET_TOKEN with BITBUCKET_HOST`,
347
349
  };
348
350
  }
349
- if (a.status === 401) {
350
- return { unknown: `${host} refused ${repo} to ${auth?.source} — no access, a changed password, a revoked token?` };
351
+ if (a.status === 401 && auth) {
352
+ // A login the server refused — a revoked token, a changed password — is an
353
+ // AuthenticationException; one it took, without access, an AuthorisationException,
354
+ // as is no login at all, and a Bearer token it does not know (git.finam.ru, 2026-10-22).
355
+ // vdx does not touch git's store: the owner erases the login (Sidetrack #11).
356
+ const exception = str(at(error, 'exceptionName')) ?? '';
357
+ if (
358
+ exception === 'com.atlassian.bitbucket.auth.IncorrectPasswordAuthenticationException' &&
359
+ auth.source !== 'BITBUCKET_TOKEN'
360
+ ) {
361
+ return {
362
+ unknown: `${host} refused the login git keeps for it, and every audit sends it again — each refusal brings the account nearer a CAPTCHA; erase it: printf 'protocol=https\\nhost=${host}\\n\\n' | git credential reject — and log in once more: ${login}`,
363
+ };
364
+ }
365
+ if (exception.endsWith('AuthenticationException')) {
366
+ return { unknown: `${host} refused ${auth.source}${message ? `: ${message}` : ''}` };
367
+ }
368
+ return {
369
+ unknown:
370
+ auth.source === 'BITBUCKET_TOKEN'
371
+ ? `${host} refused ${repo} to BITBUCKET_TOKEN — no access, or a token it does not know: it takes one for no login`
372
+ : `${host} refused ${repo} to the login git keeps — it has no access to the repository`,
373
+ };
351
374
  }
352
375
  return { unknown: `${host} answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
353
376
  }
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  } from './publish.ts';
36
36
  import { resolveDefaultRubric } from './defaults.ts';
37
37
  import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate } from './gate.ts';
38
- import { askLine, runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
38
+ import { askLine, runAi, runAiCheck, runAiClose, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
39
39
  import { isConversationId } from './conversations.ts';
40
40
 
41
41
  const DEFAULT_RUBRIC = resolveDefaultRubric();
@@ -90,11 +90,27 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
90
90
  rules marked \`wakes: true\` (a channel flag) are left out. Back
91
91
  to it: --focused --conversation <id>. A running agent keeps its
92
92
  mode: plain vdx ai attaches to it as it is
93
+ --intercom the project's mail session: a new conversation in a tmux
94
+ session of its own (<name>-mail), one per project and machine —
95
+ a running one is attached to, never a second. The agent, its
96
+ hooks and MCP servers get VDX_INTERCOM=1: letters and wakes for
97
+ the project go there, and your own session stays quiet; the
98
+ profile's \`wakes\` rules stay. It starts with agent.intercom_args
99
+ (its first prompt). Every other start leaves it alone, and its
100
+ conversations are not continued unless named with --conversation.
101
+ Started by the agent that has a letter and nobody to wake:
102
+ vdx ai --intercom --detach <project>
103
+ --close [--waiting <n>]
104
+ inside the mail session: it ends itself — its pane closes 2 s
105
+ later. --waiting <n>: n questions to you still unanswered; the
106
+ project's other tmux sessions here get @vdx_waiting = n (without
107
+ it the mark is cleared). Anywhere else: refused, nothing closed
93
108
  --conversation <id>
94
109
  continue this Claude Code conversation: attach to the agent in
95
110
  it, or start one next to the agents running here
96
111
  --restart restart the running agent in its pane — applies a changed profile;
97
- with --new, --focused or --conversation, it is replaced by that.
112
+ with --new, --focused or --conversation, it is replaced by that;
113
+ with --intercom, the mail session gets a new conversation.
98
114
  Without it, an agent running without the profile's flags is
99
115
  a question in a terminal: Enter attaches to it as is, r
100
116
  restarts it, q leaves it
@@ -166,8 +182,9 @@ mise.toml is overwritten only with --force.
166
182
  Ctrl-C there stops only the wait).
167
183
  Nothing in the package's directory changed since the tag of its version: the
168
184
  same package under a new number — at a terminal a question, Enter releases nothing.
185
+ Without a terminal it releases nothing: npm would ask for an OTP nobody sees.
169
186
  --dry-run runs the pre-flight and prints the plan; --force goes on past a failed
170
- pre-flight; --no-push leaves the commit and the tag local.
187
+ pre-flight, and without a terminal; --no-push leaves the commit and the tag local.
171
188
  `,
172
189
  bools: ['dry-run', 'force', 'no-push'],
173
190
  positionals: 1,
@@ -218,11 +235,12 @@ Exit 2 when something is missing.
218
235
  positionals: 1,
219
236
  },
220
237
  ai: {
221
- usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run]',
238
+ usage:
239
+ 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--focused | --intercom] [--restart] [--detach] [--dry-run]',
222
240
  help: AI_HELP,
223
241
  // `--resume` asked to continue in 0.12; continuing is the default now.
224
- bools: ['new', 'focused', 'restart', 'resume', 'detach', 'dry-run', 'check'],
225
- values: ['conversation'],
242
+ bools: ['new', 'focused', 'intercom', 'restart', 'resume', 'detach', 'dry-run', 'check', 'close'],
243
+ values: ['conversation', 'waiting'],
226
244
  positionals: 1,
227
245
  },
228
246
  ...Object.fromEntries(
@@ -412,6 +430,18 @@ function cmdPublish(opts: ParsedArgs): void {
412
430
  const bump = bumpArg as BumpKind;
413
431
  const projectRoot = process.cwd();
414
432
 
433
+ // npm asks for an OTP or a confirmation in the browser, after the bump: without a
434
+ // terminal nobody answers. Claude Code's Bash tool has none, and `!` there moves a
435
+ // command past 120 s to the background (publish-npm-outage-preflight, 2026-10-22).
436
+ if (!opts.flags['dry-run'] && !opts.flags.force && process.stdin.isTTY !== true) {
437
+ process.stderr.write(
438
+ `vdx publish: no terminal here — npm asks for a one-time password or a confirmation in the browser, ` +
439
+ `and nobody could answer. Run it in your own terminal: vdx publish ${bump}. ` +
440
+ `--dry-run shows the plan; --force releases without a terminal (an automation token)\n`,
441
+ );
442
+ process.exit(2);
443
+ }
444
+
415
445
  const manifest = loadManifest(projectRoot);
416
446
  const overrides = loadOverrides(projectRoot);
417
447
  const rubric = loadRubric(DEFAULT_RUBRIC);
@@ -602,6 +632,27 @@ function cmdAi(opts: ParsedArgs): void {
602
632
  }
603
633
  process.exit(runAiCheck(opts.positionals[0] ?? '.', defaultDeps()));
604
634
  }
635
+ if (opts.flags.close === true || opts.flags.waiting !== undefined) {
636
+ // Run inside the mail session, about that session: nothing to start, no other machine.
637
+ const others = Object.keys(opts.flags).filter((f) => f !== 'close' && f !== 'waiting');
638
+ if (opts.flags.close !== true || others.length > 0 || opts.cmd !== 'ai') {
639
+ process.stderr.write(
640
+ opts.flags.close !== true
641
+ ? 'vdx ai: --waiting goes with --close\n'
642
+ : `vdx ai: --close takes only the project path and --waiting (not ${opts.cmd !== 'ai' ? opts.cmd : `--${others.join(', --')}`})\n`,
643
+ );
644
+ process.exit(2);
645
+ }
646
+ const raw = opts.flags.waiting;
647
+ const waiting = raw === undefined ? 0 : typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : NaN;
648
+ if (Number.isNaN(waiting)) {
649
+ process.stderr.write(
650
+ `vdx ai: --waiting takes the number of questions to the user (0, 1, 2…), not "${String(raw)}"\n`,
651
+ );
652
+ process.exit(2);
653
+ }
654
+ process.exit(runAiClose(opts.positionals[0] ?? '.', waiting, defaultDeps()));
655
+ }
605
656
  const conversation = typeof opts.flags.conversation === 'string' ? opts.flags.conversation : undefined;
606
657
  if (conversation !== undefined && !isConversationId(conversation)) {
607
658
  process.stderr.write(`vdx ai: --conversation takes a conversation id (a UUID), not "${conversation}"\n`);
@@ -619,6 +670,7 @@ function cmdAi(opts: ParsedArgs): void {
619
670
  detach: opts.flags.detach === true,
620
671
  dryRun: opts.flags['dry-run'] === true,
621
672
  focused: opts.flags.focused === true,
673
+ intercom: opts.flags.intercom === true,
622
674
  };
623
675
  const deps = { ...defaultDeps(), version: readCliVersion() };
624
676
  // `ai@m3`: the same command on another machine. Its own label runs here.
package/src/init.ts CHANGED
@@ -6,7 +6,7 @@ import { at } from './narrow.ts';
6
6
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
7
7
  export type Verb = (typeof STANDARD_VERBS)[number];
8
8
 
9
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.5.0';
9
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.6.1';
10
10
 
11
11
  const VERB_ALIASES: Record<Verb, string[]> = {
12
12
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
package/src/publish.ts CHANGED
@@ -199,6 +199,17 @@ export function checkNpmAuth(registry: string | null, npm: NpmRunner): Preflight
199
199
  fix,
200
200
  };
201
201
  }
202
+ // A 5xx is the registry's trouble, not the login: 0.35.0 met E503 during an npm
203
+ // incident on 2026-10-22, and the line read as a failed login.
204
+ const outage = /\bE(5\d\d)\b/.exec(stderr);
205
+ if (outage) {
206
+ const npmjs = !registry || registry.includes('registry.npmjs.org');
207
+ return {
208
+ name: 'npm-auth',
209
+ ok: false,
210
+ message: `${where} is not answering (E${outage[1]}) — trouble on its side, not the login${npmjs ? '; status: https://status.npmjs.org' : ''}. Run vdx publish again once it answers`,
211
+ };
212
+ }
202
213
  return {
203
214
  name: 'npm-auth',
204
215
  ok: false,