@vodmal/vdx-cli 0.34.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,8 +54,9 @@ 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
58
- vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
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
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)
61
62
  vdx <command> --help
@@ -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.34.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,
@@ -29,6 +30,7 @@ import {
29
30
  runsInProject,
30
31
  } from './conversations.ts';
31
32
  import { at, errorMessage, isStringList, str } from './narrow.ts';
33
+ import { type Lag, sessionLag } from './session-versions.ts';
32
34
 
33
35
  /**
34
36
  * `vdx ai` — start the person's agent in a project, with the flags their
@@ -68,6 +70,8 @@ export interface AgentProfile {
68
70
  command: string;
69
71
  args?: string[];
70
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[];
71
75
  when?: WhenRule[];
72
76
  }
73
77
 
@@ -130,6 +134,16 @@ const SEAT_ENV_VAR = 'VDX_AI_FROM';
130
134
  * its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
131
135
  */
132
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;
133
147
  const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
134
148
  /** Tried in order when no profile names an agent. Started plain, with no flags. */
135
149
  const FALLBACK_AGENTS = ['claude', 'codex'] as const;
@@ -163,7 +177,7 @@ export function parseEnvironment(text: string, source: string): Environment {
163
177
  if (typeof agent.command !== 'string' || agent.command.trim() === '') {
164
178
  fail('agent.command', 'must be a non-empty string');
165
179
  }
166
- for (const key of ['args', 'resume_args'] as const) {
180
+ for (const key of ['args', 'resume_args', 'intercom_args'] as const) {
167
181
  if (agent[key] !== undefined && !isStringList(agent[key])) {
168
182
  fail(`agent.${key}`, 'must be a list of strings');
169
183
  }
@@ -324,6 +338,10 @@ export interface LaunchPlan {
324
338
  focused: boolean;
325
339
  /** Matched rules a focused session leaves out (`wakes: true`). */
326
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[];
327
345
  confirm: ConfirmRule[];
328
346
  multiplexer: Multiplexer;
329
347
  sessionName: string;
@@ -341,9 +359,11 @@ export function planLaunch(input: {
341
359
  host: string;
342
360
  seat?: string;
343
361
  focused?: boolean;
362
+ intercom?: boolean;
344
363
  }): LaunchPlan {
345
364
  const { environment, agent, projectRoot } = input;
346
365
  const focused = input.focused === true;
366
+ const intercom = input.intercom === true && !focused;
347
367
  const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
348
368
  const args = [...(agent.args ?? [])];
349
369
  const matched: string[] = [];
@@ -372,12 +392,15 @@ export function planLaunch(input: {
372
392
  matched,
373
393
  focused,
374
394
  skipped,
395
+ intercom,
396
+ intercomArgs: intercom ? [...(agent.intercom_args ?? [])] : [],
375
397
  confirm,
376
398
  multiplexer: environment.session?.multiplexer ?? 'none',
377
- sessionName: renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
378
- project: input.project,
379
- host: input.host,
380
- }),
399
+ sessionName:
400
+ renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
401
+ project: input.project,
402
+ host: input.host,
403
+ }) + (intercom ? '-mail' : ''),
381
404
  machines: (environment.session?.machines ?? []).filter((m) => m.toLowerCase() !== input.host.toLowerCase()),
382
405
  };
383
406
  }
@@ -394,12 +417,13 @@ export function commandLine(command: string, args: string[]): string {
394
417
  /**
395
418
  * The pane runs the agent inside the user's login shell, so the agent sees what
396
419
  * `.zshrc` sets up exactly as when it is typed by hand; the tmux server's own
397
- * 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).
398
422
  */
399
- export function tmuxShellCommand(line: string, shell: string | null): string {
423
+ export function tmuxShellCommand(line: string, shell: string | null, keepShell = true): string {
400
424
  if (!shell) return line;
401
425
  const sh = shellQuote(shell);
402
- return `exec ${sh} -lic ${shellQuote(`${line}; exec ${sh} -l`)}`;
426
+ return `exec ${sh} -lic ${shellQuote(keepShell ? `${line}; exec ${sh} -l` : line)}`;
403
427
  }
404
428
 
405
429
  export interface PaneInfo {
@@ -722,6 +746,8 @@ export interface AiOptions {
722
746
  dryRun: boolean;
723
747
  /** `--focused`: a session nothing wakes — VDX_FOCUSED=1, the profile's `wakes` rules left out. */
724
748
  focused?: boolean;
749
+ /** `--intercom`: the project's mail session — VDX_INTERCOM=1, one per project and machine. */
750
+ intercom?: boolean;
725
751
  }
726
752
 
727
753
  export interface AiDeps {
@@ -737,6 +763,8 @@ export interface AiDeps {
737
763
  processes: () => ProcInfo[];
738
764
  /** Was this agent process started focused; default: its environment (`processFocused`). */
739
765
  agentFocused?: (pid: number) => boolean;
766
+ /** Is this agent process a mail session; default: its environment (`processMode`). */
767
+ agentMail?: (pid: number) => boolean;
740
768
  /** Default `process.platform`: the folder check is for macOS. */
741
769
  platform?: NodeJS.Platform;
742
770
  sleep: (ms: number) => void;
@@ -749,6 +777,28 @@ export interface AiDeps {
749
777
  ssh?: (args: string[], capture: boolean) => { status: number; stdout: string } | null;
750
778
  /** Claude Code processes alive on this machine; read from its session files when not given. */
751
779
  liveHere?: () => LiveSession[];
780
+ /** What the agent process `pid` lags behind; read from Claude Code's files when not given. */
781
+ lag?: (pid: number, projectRoot: string, command: string) => Lag | null;
782
+ }
783
+
784
+ function machineLag(deps: AiDeps, pid: number, projectRoot: string, command: string): Lag | null {
785
+ return sessionLag({
786
+ env: deps.env,
787
+ home: deps.home,
788
+ pid,
789
+ projectRoot,
790
+ installedClaude: () => {
791
+ try {
792
+ return execFileSync(command, ['--version'], {
793
+ encoding: 'utf8',
794
+ stdio: ['ignore', 'pipe', 'ignore'],
795
+ timeout: 5_000,
796
+ });
797
+ } catch {
798
+ return null;
799
+ }
800
+ },
801
+ });
752
802
  }
753
803
 
754
804
  function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
@@ -821,6 +871,8 @@ function renderPlan(
821
871
  focusedOf: (pid: number) => boolean,
822
872
  /** The session a start would make: next to running agents, one of its own. */
823
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[] = [],
824
876
  ): string {
825
877
  const lines = [
826
878
  `vdx ai — ${plan.projectRoot}`,
@@ -832,6 +884,12 @@ function renderPlan(
832
884
  ];
833
885
  if (plan.focused)
834
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
+ );
835
893
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
836
894
  lines.push(
837
895
  multiplexer === 'tmux'
@@ -849,6 +907,11 @@ function renderPlan(
849
907
  }${focused ? ', focused' : plan.focused ? ', not focused' : ''}`,
850
908
  );
851
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
+ }
852
915
  }
853
916
  return lines.join('\n') + '\n';
854
917
  }
@@ -951,7 +1014,7 @@ function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean
951
1014
  if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
952
1015
  const line = agentLine(plan, plan.args);
953
1016
  deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
954
- deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
1017
+ deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
955
1018
  return settle(plan, paneId, deps) === 'ok';
956
1019
  }
957
1020
 
@@ -996,6 +1059,7 @@ export function remoteAiArgs(input: {
996
1059
  const flags = [
997
1060
  opts.fresh && '--new',
998
1061
  opts.focused && '--focused',
1062
+ opts.intercom && '--intercom',
999
1063
  opts.restart && '--restart',
1000
1064
  opts.detach && '--detach',
1001
1065
  opts.dryRun && '--dry-run',
@@ -1156,10 +1220,11 @@ function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): Co
1156
1220
  if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1157
1221
  const host = plan.host;
1158
1222
  const same = sameMachine;
1223
+ // A mail session's conversations are its own: a start of the person's never continues one.
1159
1224
  const all =
1160
1225
  plan.resumeArgs.length === 0
1161
1226
  ? []
1162
- : 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);
1163
1228
  const root = shellQuote(plan.projectRoot);
1164
1229
  // Said from where the person sits: under `vdx ai@<host>` that is not this machine.
1165
1230
  const atSeat = same(plan.seat, host);
@@ -1351,7 +1416,7 @@ interface Prepared {
1351
1416
  }
1352
1417
 
1353
1418
  /** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
1354
- function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | number {
1419
+ function prepare(pathArg: string, deps: AiDeps, focused = false, intercom = false): Prepared | number {
1355
1420
  const { log } = deps;
1356
1421
 
1357
1422
  const projectRoot = resolveProjectRoot(pathArg);
@@ -1400,6 +1465,7 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1400
1465
  host: hostLabel(deps.env),
1401
1466
  seat: seatLabel(deps.env),
1402
1467
  focused: asFocused,
1468
+ intercom,
1403
1469
  });
1404
1470
  let plan: LaunchPlan;
1405
1471
  try {
@@ -1411,20 +1477,35 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1411
1477
  return { projectRoot, environment, profilePath, profileDir, plan, replan };
1412
1478
  }
1413
1479
 
1414
- /** Was the process started focused — VDX_FOCUSED=1 in its environment (`ps -E` on macOS, /proc on Linux)? */
1415
- export function processFocused(pid: number): boolean {
1416
- 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[];
1417
1489
  try {
1418
1490
  const environ = `/proc/${pid}/environ`;
1419
- if (fs.existsSync(environ)) return fs.readFileSync(environ, 'utf8').split('\0').includes(mark);
1420
- const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
1421
- encoding: 'utf8',
1422
- stdio: ['ignore', 'pipe', 'ignore'],
1423
- });
1424
- 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
+ }
1425
1499
  } catch {
1426
- return false;
1500
+ return 'plain';
1427
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';
1428
1509
  }
1429
1510
 
1430
1511
  /** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
@@ -1481,9 +1562,23 @@ export function foldersWarning(denied: string[], viaTmux: boolean, fix: string |
1481
1562
  ];
1482
1563
  }
1483
1564
 
1484
- /** 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. */
1485
1579
  function agentLine(plan: LaunchPlan, args: string[]): string {
1486
- 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))}`;
1487
1582
  }
1488
1583
 
1489
1584
  /** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
@@ -1504,12 +1599,14 @@ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): P
1504
1599
  */
1505
1600
  export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
1506
1601
  if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
1507
- // 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.
1508
1604
  const focused = deps.env[FOCUSED_ENV_VAR] === '1';
1509
- const prepared = prepare(pathArg, deps, focused);
1605
+ const intercom = !focused && deps.env[INTERCOM_ENV_VAR] === '1';
1606
+ const prepared = prepare(pathArg, deps, focused, intercom);
1510
1607
  if (typeof prepared === 'number') return prepared;
1511
1608
  const { projectRoot, profilePath, plan } = prepared;
1512
- const vdxAi = `vdx ai${focused ? ' --focused' : ''}`;
1609
+ const vdxAi = `vdx ai${focused ? ' --focused' : intercom ? ' --intercom' : ''}`;
1513
1610
 
1514
1611
  const lines = [
1515
1612
  `${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
@@ -1526,6 +1623,17 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1526
1623
  '.',
1527
1624
  );
1528
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
+ }
1529
1637
  const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1530
1638
  if (!own) {
1531
1639
  lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
@@ -1534,35 +1642,132 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1534
1642
  }
1535
1643
  const missing = missingArgs(own.args, plan.args);
1536
1644
  const why = plan.matched.length ? ` (profile conditions met here: ${plan.matched.join(', ')})` : '';
1537
- if (missing.length === 0) {
1645
+ // A live session keeps the Claude Code and plugins it started with (vdx-ai, Sidetrack #24).
1646
+ const lag = (deps.lag ?? ((pid, root, command) => machineLag(deps, pid, root, command)))(
1647
+ own.pid,
1648
+ projectRoot,
1649
+ plan.command,
1650
+ );
1651
+ if (missing.length === 0 && !lag) {
1538
1652
  lines.push(`✓ this session carries the profile's flags${why}.`);
1539
1653
  deps.out(lines.join('\n') + '\n');
1540
1654
  return EXIT_OK;
1541
1655
  }
1656
+ const problems: string[] = [];
1657
+ if (missing.length > 0) problems.push(`✗ this session runs without ${missing.join(' ')}${why}.`);
1658
+ else lines.push(`✓ this session carries the profile's flags${why}.`);
1659
+ if (lag) problems.push(`✗ ${describeLag(lag)}.`);
1660
+
1542
1661
  const root = shellQuote(projectRoot);
1543
- lines.push(
1544
- `✗ this session runs without ${missing.join(' ')}${why}. Hand the user the fix — ` +
1662
+ const again = [
1663
+ missing.length > 0 ? " with the profile's flags" : '',
1664
+ lag ? `${missing.length > 0 ? ',' : ''} on the installed Claude Code and plugins` : '',
1665
+ ].join('');
1666
+ // Plugins alone: /reload-plugins loads them and the conversation goes on in the same process.
1667
+ const reload =
1668
+ missing.length === 0 && lag && !lag.claude
1669
+ ? '`/reload-plugins`: it loads the installed plugins and keeps the conversation (Claude Code stays as it is); or '
1670
+ : '';
1671
+ problems.push(
1672
+ `Hand the user the fix — ${reload}` +
1545
1673
  (deps.env['TMUX']
1546
- ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1547
- (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1674
+ ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
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
+ : '') +
1548
1681
  `. Do not run it yourself: it ends this session.`
1549
1682
  : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
1550
1683
  );
1684
+ lines.push(problems.join(' '));
1551
1685
  deps.out(lines.join('\n') + '\n');
1552
1686
  return EXIT_DRIFT;
1553
1687
  }
1554
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
+
1743
+ /** `this session runs Claude Code 2.1.294 — 2.1.304 is installed; plugins updated since it loaded them: vdm 2.54.0`. */
1744
+ function describeLag(lag: Lag): string {
1745
+ const names = lag.plugins.map((p) => `${p.name} ${p.version}`).join(', ');
1746
+ if (!lag.claude) return `plugins updated since this session loaded them: ${names} — it still runs the ones it loaded`;
1747
+ const claude = `this session runs Claude Code ${lag.claude.runs} — ${lag.claude.installed} is installed`;
1748
+ return lag.plugins.length > 0 ? `${claude}; plugins updated since it loaded them: ${names}` : claude;
1749
+ }
1750
+
1555
1751
  export function runAi(opts: AiOptions, deps: AiDeps): number {
1556
1752
  const { log, tmux } = deps;
1557
1753
 
1558
- 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);
1559
1763
  if (typeof prepared === 'number') return prepared;
1560
1764
  const { projectRoot, environment, profileDir } = prepared;
1561
1765
  let { plan } = prepared;
1562
1766
  // The agent started here runs on this machine: it does not inherit where the person sat,
1563
- // 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.
1564
1768
  delete deps.env[SEAT_ENV_VAR];
1565
1769
  delete deps.env[FOCUSED_ENV_VAR];
1770
+ delete deps.env[INTERCOM_ENV_VAR];
1566
1771
  // Under `vdx ai@<host>` the person is not here: this machine goes by its name.
1567
1772
  const atSeat = sameMachine(plan.seat, plan.host);
1568
1773
  const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
@@ -1571,8 +1776,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1571
1776
  log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
1572
1777
  return EXIT_USAGE;
1573
1778
  }
1574
- // A start continues the last conversation unless asked for a new one; a focused session is a new one.
1575
- 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;
1576
1782
  if (wantsNew) plan = { ...plan, resumeArgs: [] };
1577
1783
 
1578
1784
  let multiplexer = plan.multiplexer;
@@ -1581,8 +1787,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1581
1787
  multiplexer = 'none';
1582
1788
  }
1583
1789
 
1584
- const running =
1790
+ const agentsHere =
1585
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));
1586
1797
  const focusedOf = deps.agentFocused ?? processFocused;
1587
1798
  const conversationOf = (a: AgentPane) => liveConversationId(deps.env, deps.home, a.proc.pid);
1588
1799
  // Which running agent the start is about. A new conversation, or one no agent here is in, starts
@@ -1590,8 +1801,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1590
1801
  // one asked for: no second agent in it blindly.
1591
1802
  const inConversation = opts.conversation ? running.find((a) => conversationOf(a) === opts.conversation) : undefined;
1592
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.
1593
1805
  const alongside =
1594
- 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);
1595
1812
  let current = inConversation ?? unreadable;
1596
1813
  if (!current && !alongside && running.length > 0) {
1597
1814
  const picked = pickAgent(running, focusedOf, deps, !opts.dryRun);
@@ -1611,7 +1828,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1611
1828
  // What a start continues. A restart keeps the conversation the agent is in.
1612
1829
  let choice: ConversationChoice | null = null;
1613
1830
  if (!current || opts.restart) {
1614
- 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: [] };
1615
1834
  // A restart into a new conversation replaces the agent here, and a new one next to an agent here
1616
1835
  // is what was asked for: the other machines are not asked.
1617
1836
  else if ((current || alongside) && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
@@ -1635,7 +1854,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1635
1854
  : [];
1636
1855
  if (opts.dryRun) {
1637
1856
  deps.out(
1638
- 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
+ ),
1639
1865
  );
1640
1866
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1641
1867
  for (const l of folders) deps.out(` ${l}\n`);
@@ -1686,9 +1912,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1686
1912
  return EXIT_USAGE;
1687
1913
  }
1688
1914
  for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
1689
- 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;
1690
1917
  const startedAt = Date.now();
1691
- 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
+ });
1692
1923
  if (at(res.error, 'code') === 'ENOENT') {
1693
1924
  log(`vdx ai: \`${plan.command}\` not found on PATH`);
1694
1925
  return 127;
@@ -1696,7 +1927,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1696
1927
  const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
1697
1928
  if (plan.resumeArgs.length > 0 && failedAtOnce) {
1698
1929
  log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${agentLine(plan, plan.args)}`);
1699
- 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 });
1700
1931
  }
1701
1932
  return res.status ?? 1;
1702
1933
  }
@@ -1719,20 +1950,20 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1719
1950
  const unfocused = plan.focused && !runningFocused;
1720
1951
  const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
1721
1952
  if (!unfocused && missing.length === 0) {
1722
- log(
1723
- `✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`,
1724
- );
1953
+ const mode = runningFocused ? ', focused' : plan.intercom ? ', the mail session' : '';
1954
+ log(`✓ ${current.pane.session}: already running with the profile${mode} — ${current.proc.args}`);
1725
1955
  return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1726
1956
  }
1727
1957
  // Drift. In a terminal the person picks: the agent as it runs, or a restart
1728
1958
  // with the profile; without one nothing is touched, and the fix is named.
1729
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' : '';
1730
1961
  const fix =
1731
- `vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1962
+ `vdx ai --restart${modeFlag}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1732
1963
  (opts.conversation ? ` --conversation ${opts.conversation}` : '');
1733
1964
  const effect = plan.resumeArgs.length
1734
1965
  ? 'stops the running agent, then resumes its conversation'
1735
- : opts.fresh
1966
+ : opts.fresh || plan.intercom
1736
1967
  ? 'stops the running agent and starts a new conversation'
1737
1968
  : 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
1738
1969
  if (!deps.interactive) {
@@ -1763,7 +1994,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1763
1994
  if (current) {
1764
1995
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1765
1996
  log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
1766
- tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
1997
+ tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
1767
1998
  return finish(
1768
1999
  settleResumed(plan, current.pane.paneId, deps),
1769
2000
  current.pane.session,
@@ -1774,8 +2005,10 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1774
2005
  }
1775
2006
 
1776
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');
1777
2010
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1778
- const cmd = tmuxShellCommand(line, deps.shell);
2011
+ const cmd = tmuxShellCommand(line, deps.shell, !plan.intercom);
1779
2012
  const session = freeSessionName(plan.sessionName, running);
1780
2013
  const reuse = tmux.hasSession(session);
1781
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,18 +90,35 @@ 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
101
117
  --detach start without attaching; print the command that attaches
102
118
  --dry-run print the plan and the project's running agents; start nothing
103
- --check for the agent's own session: how the agent here is launched, and
104
- whether this session carries the profile's flags (the vdx plugin's
119
+ --check for the agent's own session: how the agent here is launched,
120
+ whether this session carries the profile's flags, and whether it
121
+ runs the installed Claude Code and plugins (the vdx plugin's
105
122
  SessionStart hook); prints nothing without a profile; exit 3 on drift
106
123
  -h, --help this help
107
124
 
@@ -165,8 +182,9 @@ mise.toml is overwritten only with --force.
165
182
  Ctrl-C there stops only the wait).
166
183
  Nothing in the package's directory changed since the tag of its version: the
167
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.
168
186
  --dry-run runs the pre-flight and prints the plan; --force goes on past a failed
169
- 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.
170
188
  `,
171
189
  bools: ['dry-run', 'force', 'no-push'],
172
190
  positionals: 1,
@@ -217,11 +235,12 @@ Exit 2 when something is missing.
217
235
  positionals: 1,
218
236
  },
219
237
  ai: {
220
- 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]',
221
240
  help: AI_HELP,
222
241
  // `--resume` asked to continue in 0.12; continuing is the default now.
223
- bools: ['new', 'focused', 'restart', 'resume', 'detach', 'dry-run', 'check'],
224
- values: ['conversation'],
242
+ bools: ['new', 'focused', 'intercom', 'restart', 'resume', 'detach', 'dry-run', 'check', 'close'],
243
+ values: ['conversation', 'waiting'],
225
244
  positionals: 1,
226
245
  },
227
246
  ...Object.fromEntries(
@@ -411,6 +430,18 @@ function cmdPublish(opts: ParsedArgs): void {
411
430
  const bump = bumpArg as BumpKind;
412
431
  const projectRoot = process.cwd();
413
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
+
414
445
  const manifest = loadManifest(projectRoot);
415
446
  const overrides = loadOverrides(projectRoot);
416
447
  const rubric = loadRubric(DEFAULT_RUBRIC);
@@ -601,6 +632,27 @@ function cmdAi(opts: ParsedArgs): void {
601
632
  }
602
633
  process.exit(runAiCheck(opts.positionals[0] ?? '.', defaultDeps()));
603
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
+ }
604
656
  const conversation = typeof opts.flags.conversation === 'string' ? opts.flags.conversation : undefined;
605
657
  if (conversation !== undefined && !isConversationId(conversation)) {
606
658
  process.stderr.write(`vdx ai: --conversation takes a conversation id (a UUID), not "${conversation}"\n`);
@@ -618,6 +670,7 @@ function cmdAi(opts: ParsedArgs): void {
618
670
  detach: opts.flags.detach === true,
619
671
  dryRun: opts.flags['dry-run'] === true,
620
672
  focused: opts.flags.focused === true,
673
+ intercom: opts.flags.intercom === true,
621
674
  };
622
675
  const deps = { ...defaultDeps(), version: readCliVersion() };
623
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,
@@ -0,0 +1,149 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { claudeConfigDir, projectDirName } from './conversations.ts';
4
+ import { at, dict, list, str } from './narrow.ts';
5
+
6
+ /**
7
+ * What a running Claude Code session lags behind: a live session keeps the
8
+ * Claude Code and the plugins it started with, and takes new ones only when it
9
+ * starts again or by `/reload-plugins` (docs/tasks/vdx-ai, Sidetrack #24).
10
+ * Read from Claude Code's own files — its session file, `installed_plugins.json`,
11
+ * the settings that enable a plugin, the transcript — which are its internals,
12
+ * not an API.
13
+ */
14
+ export interface Lag {
15
+ /** Claude Code: the version the session runs and the one installed now; null when they agree. */
16
+ claude: { runs: string; installed: string } | null;
17
+ /** Enabled plugins updated after the session loaded its plugins: their name and the installed version. */
18
+ plugins: Array<{ name: string; version: string }>;
19
+ }
20
+
21
+ export interface LagInput {
22
+ env: NodeJS.ProcessEnv;
23
+ home: string;
24
+ /** The session's Claude Code process. */
25
+ pid: number;
26
+ projectRoot: string;
27
+ /** `claude --version` of the Claude Code a new start would run; null when it does not answer. */
28
+ installedClaude: () => string | null;
29
+ /** Default `Date.now()`. */
30
+ now?: number;
31
+ }
32
+
33
+ /**
34
+ * How long what a session loaded may stand before newer counts as a lag. On
35
+ * 2026-10-21 all 14 live sessions on lft were behind, an echelon release minutes
36
+ * old among them; the harm is a session that runs old code for days (twelve, in
37
+ * ai-dev-plugins' count). A day lets an update arrive without every session's
38
+ * next compaction calling for a reload.
39
+ */
40
+ const LAG_AFTER_MS = 24 * 60 * 60 * 1000;
41
+
42
+ /** What the session lags behind; null when nothing — or when its session file is not here to say. */
43
+ export function sessionLag(input: LagInput): Lag | null {
44
+ const now = input.now ?? Date.now();
45
+ const config = claudeConfigDir(input.env, input.home);
46
+ const session = readJson(path.join(config, 'sessions', `${input.pid}.json`));
47
+ const startedAt = at(session, 'startedAt');
48
+ if (typeof startedAt !== 'number' || now - startedAt < LAG_AFTER_MS) return null;
49
+
50
+ const runs = str(at(session, 'version'));
51
+ const installed = versionOf(input.installedClaude());
52
+ const claude = runs && installed && newer(installed, runs) ? { runs, installed } : null;
53
+
54
+ let plugins = updatedPlugins(config, input.projectRoot).filter((p) => p.at > startedAt);
55
+ if (plugins.length > 0) {
56
+ // A /reload-plugins loaded what was installed then: the transcript keeps when (its "Reloaded: …" line).
57
+ const sessionId = str(at(session, 'sessionId'));
58
+ const reloaded = sessionId ? lastReload(config, input.projectRoot, sessionId) : null;
59
+ if (reloaded !== null) plugins = now - reloaded < LAG_AFTER_MS ? [] : plugins.filter((p) => p.at > reloaded);
60
+ }
61
+ if (!claude && plugins.length === 0) return null;
62
+ return { claude, plugins: plugins.map(({ name, version }) => ({ name, version })) };
63
+ }
64
+
65
+ /** Enabled plugins and when each was last updated (ms) — the user's and this project's. */
66
+ function updatedPlugins(config: string, projectRoot: string): Array<{ name: string; version: string; at: number }> {
67
+ const records = dict(at(readJson(path.join(config, 'plugins', 'installed_plugins.json')), 'plugins')) ?? {};
68
+ // The nearest settings that name a plugin decide: the project's local, the project's, the user's.
69
+ const layers = [
70
+ path.join(projectRoot, '.claude', 'settings.local.json'),
71
+ path.join(projectRoot, '.claude', 'settings.json'),
72
+ path.join(config, 'settings.json'),
73
+ ].map((f) => dict(at(readJson(f), 'enabledPlugins')) ?? {});
74
+ const out: Array<{ name: string; version: string; at: number }> = [];
75
+ for (const [key, entries] of Object.entries(records)) {
76
+ if (layers.map((l) => l[key]).find((v) => typeof v === 'boolean') !== true) continue;
77
+ const entry = list(entries).find((e) => at(e, 'scope') === 'user' || at(e, 'projectPath') === projectRoot);
78
+ const when = Date.parse(str(at(entry, 'lastUpdated')) ?? '');
79
+ const version = str(at(entry, 'version'));
80
+ if (Number.isNaN(when) || !version) continue;
81
+ out.push({ name: key.split('@')[0] ?? key, version, at: when });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** When the session last ran /reload-plugins (ms), from its transcript; null — never, or no transcript here. */
87
+ function lastReload(config: string, projectRoot: string, sessionId: string): number | null {
88
+ const projects = path.join(config, 'projects');
89
+ let file = path.join(projects, projectDirName(projectRoot), `${sessionId}.jsonl`);
90
+ if (!fs.existsSync(file)) {
91
+ // The transcript sits under the directory the session started in, which may be another.
92
+ const dir = safeReaddir(projects).find((d) => fs.existsSync(path.join(projects, d, `${sessionId}.jsonl`)));
93
+ if (dir === undefined) return null;
94
+ file = path.join(projects, dir, `${sessionId}.jsonl`);
95
+ }
96
+ let text: string;
97
+ try {
98
+ text = fs.readFileSync(file, 'utf8');
99
+ } catch {
100
+ return null;
101
+ }
102
+ let last: number | null = null;
103
+ for (const line of text.split('\n')) {
104
+ if (!line.includes('"local_command"') || !line.includes('Reloaded:')) continue;
105
+ let entry: unknown;
106
+ try {
107
+ entry = JSON.parse(line);
108
+ } catch {
109
+ continue;
110
+ }
111
+ if (at(entry, 'type') !== 'system' || at(entry, 'subtype') !== 'local_command') continue;
112
+ if (!/<local-command-stdout>Reloaded:/.test(str(at(entry, 'content')) ?? '')) continue;
113
+ const when = Date.parse(str(at(entry, 'timestamp')) ?? '');
114
+ if (!Number.isNaN(when) && (last === null || when > last)) last = when;
115
+ }
116
+ return last;
117
+ }
118
+
119
+ /** `2.1.304 (Claude Code)` → `2.1.304`. */
120
+ function versionOf(text: string | null): string | null {
121
+ return text?.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
122
+ }
123
+
124
+ /** Whether version `a` is newer than `b`, part by part. */
125
+ function newer(a: string, b: string): boolean {
126
+ const pa = a.split('.').map(Number);
127
+ const pb = b.split('.').map(Number);
128
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
129
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
130
+ if (d !== 0) return d > 0;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ function readJson(file: string): unknown {
136
+ try {
137
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function safeReaddir(dir: string): string[] {
144
+ try {
145
+ return fs.readdirSync(dir);
146
+ } catch {
147
+ return [];
148
+ }
149
+ }