@vodmal/vdx-cli 0.35.0 → 0.37.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/src/ai.ts CHANGED
@@ -22,12 +22,14 @@ import {
22
22
  liveConversationId,
23
23
  liveSessionsHere,
24
24
  liveStartedAt,
25
+ MAIL_LINE_PREFIX,
25
26
  machineLine,
26
27
  type MachineReport,
27
28
  machineOf,
28
29
  parseMachineReport,
29
30
  runsInProject,
30
31
  } from './conversations.ts';
32
+ import { describeMemory, fixProjectMemory, memoryNeedsFix, memoryState } from './memory.ts';
31
33
  import { at, errorMessage, isStringList, str } from './narrow.ts';
32
34
  import { type Lag, sessionLag } from './session-versions.ts';
33
35
 
@@ -69,6 +71,8 @@ export interface AgentProfile {
69
71
  command: string;
70
72
  args?: string[];
71
73
  resume_args?: string[];
74
+ /** What a mail session (`vdx ai --intercom`) starts with, right after the command: its first prompt. */
75
+ intercom_args?: string[];
72
76
  when?: WhenRule[];
73
77
  }
74
78
 
@@ -112,6 +116,8 @@ interface GitProfile {
112
116
  /** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
113
117
  author_pool?: string[];
114
118
  hooks?: PersonalHook[];
119
+ /** Hosts whose repos are the owner's alone: Claude Code's memory goes under git there (claude-memory-in-project). */
120
+ personal_hosts?: string[];
115
121
  }
116
122
 
117
123
  export interface Environment {
@@ -131,6 +137,16 @@ const SEAT_ENV_VAR = 'VDX_AI_FROM';
131
137
  * its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
132
138
  */
133
139
  const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
140
+ /**
141
+ * `1` in the environment of the project's mail session (`vdx ai --intercom`): the
142
+ * session letters and wakes go to, so that the person's own session stays quiet.
143
+ * Nobody sits at it; it ends itself with `vdx ai --close`.
144
+ */
145
+ const INTERCOM_ENV_VAR = 'VDX_INTERCOM';
146
+ /** The tmux session option `vdx ai --close --waiting <n>` sets: questions to the person still unanswered. */
147
+ const WAITING_OPTION = '@vdx_waiting';
148
+ /** A mail session's pane closes this long after `--close`, so the call returns and the turn ends. */
149
+ const CLOSE_DELAY_S = 2;
134
150
  const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
135
151
  /** Tried in order when no profile names an agent. Started plain, with no flags. */
136
152
  const FALLBACK_AGENTS = ['claude', 'codex'] as const;
@@ -164,7 +180,7 @@ export function parseEnvironment(text: string, source: string): Environment {
164
180
  if (typeof agent.command !== 'string' || agent.command.trim() === '') {
165
181
  fail('agent.command', 'must be a non-empty string');
166
182
  }
167
- for (const key of ['args', 'resume_args'] as const) {
183
+ for (const key of ['args', 'resume_args', 'intercom_args'] as const) {
168
184
  if (agent[key] !== undefined && !isStringList(agent[key])) {
169
185
  fail(`agent.${key}`, 'must be a list of strings');
170
186
  }
@@ -239,6 +255,9 @@ export function parseEnvironment(text: string, source: string): Environment {
239
255
  if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
240
256
  fail('git.author_pool', 'must be a list of directories');
241
257
  }
258
+ if (gitDoc.personal_hosts !== undefined && !isStringList(gitDoc.personal_hosts)) {
259
+ fail('git.personal_hosts', 'must be a list of host names');
260
+ }
242
261
  if (gitDoc.hooks !== undefined) {
243
262
  if (!Array.isArray(gitDoc.hooks)) fail('git.hooks', 'must be a list');
244
263
  const names = new Set<string>();
@@ -325,6 +344,10 @@ export interface LaunchPlan {
325
344
  focused: boolean;
326
345
  /** Matched rules a focused session leaves out (`wakes: true`). */
327
346
  skipped: string[];
347
+ /** `vdx ai --intercom`: the project's mail session — VDX_INTERCOM=1, a session `<name>-mail`. */
348
+ intercom: boolean;
349
+ /** agent.intercom_args: what a mail session starts with; not required of a running agent. */
350
+ intercomArgs: string[];
328
351
  confirm: ConfirmRule[];
329
352
  multiplexer: Multiplexer;
330
353
  sessionName: string;
@@ -342,9 +365,11 @@ export function planLaunch(input: {
342
365
  host: string;
343
366
  seat?: string;
344
367
  focused?: boolean;
368
+ intercom?: boolean;
345
369
  }): LaunchPlan {
346
370
  const { environment, agent, projectRoot } = input;
347
371
  const focused = input.focused === true;
372
+ const intercom = input.intercom === true && !focused;
348
373
  const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
349
374
  const args = [...(agent.args ?? [])];
350
375
  const matched: string[] = [];
@@ -373,12 +398,15 @@ export function planLaunch(input: {
373
398
  matched,
374
399
  focused,
375
400
  skipped,
401
+ intercom,
402
+ intercomArgs: intercom ? [...(agent.intercom_args ?? [])] : [],
376
403
  confirm,
377
404
  multiplexer: environment.session?.multiplexer ?? 'none',
378
- sessionName: renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
379
- project: input.project,
380
- host: input.host,
381
- }),
405
+ sessionName:
406
+ renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
407
+ project: input.project,
408
+ host: input.host,
409
+ }) + (intercom ? '-mail' : ''),
382
410
  machines: (environment.session?.machines ?? []).filter((m) => m.toLowerCase() !== input.host.toLowerCase()),
383
411
  };
384
412
  }
@@ -395,12 +423,13 @@ export function commandLine(command: string, args: string[]): string {
395
423
  /**
396
424
  * The pane runs the agent inside the user's login shell, so the agent sees what
397
425
  * `.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.
426
+ * environment does not have it. When the agent exits, the pane keeps a shell —
427
+ * unless `keepShell` is false (a mail session: its tab goes when it is done).
399
428
  */
400
- export function tmuxShellCommand(line: string, shell: string | null): string {
429
+ export function tmuxShellCommand(line: string, shell: string | null, keepShell = true): string {
401
430
  if (!shell) return line;
402
431
  const sh = shellQuote(shell);
403
- return `exec ${sh} -lic ${shellQuote(`${line}; exec ${sh} -l`)}`;
432
+ return `exec ${sh} -lic ${shellQuote(keepShell ? `${line}; exec ${sh} -l` : line)}`;
404
433
  }
405
434
 
406
435
  export interface PaneInfo {
@@ -512,7 +541,8 @@ export function missingArgs(procArgs: string, required: string[]): string[] {
512
541
  return required.filter((a) => (/\s/.test(a) ? !procArgs.includes(a) : !tokens.has(a)));
513
542
  }
514
543
 
515
- function resolveProjectRoot(p: string): string | null {
544
+ /** The git checkout `p` is in (its top), else the directory itself; null when `p` is no directory. */
545
+ export function resolveProjectRoot(p: string): string | null {
516
546
  const abs = path.resolve(p);
517
547
  try {
518
548
  if (!fs.statSync(abs).isDirectory()) return null;
@@ -723,6 +753,8 @@ export interface AiOptions {
723
753
  dryRun: boolean;
724
754
  /** `--focused`: a session nothing wakes — VDX_FOCUSED=1, the profile's `wakes` rules left out. */
725
755
  focused?: boolean;
756
+ /** `--intercom`: the project's mail session — VDX_INTERCOM=1, one per project and machine. */
757
+ intercom?: boolean;
726
758
  }
727
759
 
728
760
  export interface AiDeps {
@@ -738,6 +770,8 @@ export interface AiDeps {
738
770
  processes: () => ProcInfo[];
739
771
  /** Was this agent process started focused; default: its environment (`processFocused`). */
740
772
  agentFocused?: (pid: number) => boolean;
773
+ /** Is this agent process a mail session; default: its environment (`processMode`). */
774
+ agentMail?: (pid: number) => boolean;
741
775
  /** Default `process.platform`: the folder check is for macOS. */
742
776
  platform?: NodeJS.Platform;
743
777
  sleep: (ms: number) => void;
@@ -844,6 +878,8 @@ function renderPlan(
844
878
  focusedOf: (pid: number) => boolean,
845
879
  /** The session a start would make: next to running agents, one of its own. */
846
880
  session: string,
881
+ /** The project's agents this start leaves alone: the mail session for the others, the others for it. */
882
+ aside: AgentPane[] = [],
847
883
  ): string {
848
884
  const lines = [
849
885
  `vdx ai — ${plan.projectRoot}`,
@@ -855,6 +891,12 @@ function renderPlan(
855
891
  ];
856
892
  if (plan.focused)
857
893
  lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
894
+ if (plan.intercom)
895
+ lines.push(
896
+ ` mail: the project's mail session, a new conversation; first: ${
897
+ plan.intercomArgs.length ? plan.intercomArgs.join(' ') : '— (no agent.intercom_args: it waits for a wake)'
898
+ }`,
899
+ );
858
900
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
859
901
  lines.push(
860
902
  multiplexer === 'tmux'
@@ -872,6 +914,11 @@ function renderPlan(
872
914
  }${focused ? ', focused' : plan.focused ? ', not focused' : ''}`,
873
915
  );
874
916
  }
917
+ for (const a of aside) {
918
+ lines.push(
919
+ ` aside: ${a.pane.session} ${a.pane.paneId} — ${plan.intercom ? 'not the mail session' : 'the mail session'}, left alone`,
920
+ );
921
+ }
875
922
  }
876
923
  return lines.join('\n') + '\n';
877
924
  }
@@ -974,7 +1021,7 @@ function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean
974
1021
  if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
975
1022
  const line = agentLine(plan, plan.args);
976
1023
  deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
977
- deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
1024
+ deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
978
1025
  return settle(plan, paneId, deps) === 'ok';
979
1026
  }
980
1027
 
@@ -1019,6 +1066,7 @@ export function remoteAiArgs(input: {
1019
1066
  const flags = [
1020
1067
  opts.fresh && '--new',
1021
1068
  opts.focused && '--focused',
1069
+ opts.intercom && '--intercom',
1022
1070
  opts.restart && '--restart',
1023
1071
  opts.detach && '--detach',
1024
1072
  opts.dryRun && '--dry-run',
@@ -1099,6 +1147,61 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
1099
1147
  }
1100
1148
  }
1101
1149
 
1150
+ /**
1151
+ * Claude Code's memory of the project lives inside it, `.claude/memory/`
1152
+ * (claude-memory-in-project): a start says when it does not, and at a terminal asks
1153
+ * to put it there — Enter does. With an agent of the project running it only says so:
1154
+ * that session keeps the old place until it restarts. Without a terminal it names
1155
+ * `vdx doctor --fix`, the same move.
1156
+ */
1157
+ export function ensureMemory(
1158
+ projectRoot: string,
1159
+ command: string,
1160
+ personalHosts: string[],
1161
+ deps: AiDeps,
1162
+ dryRun: boolean,
1163
+ agentRunning: boolean,
1164
+ ): void {
1165
+ if (!isClaude(command)) return;
1166
+ const ctx = { env: deps.env, home: deps.home };
1167
+ const state = memoryState(projectRoot, ctx);
1168
+ if (dryRun) {
1169
+ deps.out(` memory: ${describeMemory(state, ctx)}\n`);
1170
+ return;
1171
+ }
1172
+ if (!memoryNeedsFix(state)) return;
1173
+ const fix = `vdx doctor --fix ${shellQuote(projectRoot)}`;
1174
+ deps.log(`⚠ ${path.basename(projectRoot)}: Claude memory ${describeMemory(state, ctx)}`);
1175
+ if (!deps.interactive || agentRunning) {
1176
+ deps.log(` ${agentRunning ? 'once the running agent of the project ends' : 'put it there'}: ${fix}`);
1177
+ return;
1178
+ }
1179
+ const question =
1180
+ state.kind === 'outside'
1181
+ ? 'Move it into .claude/memory/ now?'
1182
+ : state.kind === 'leftovers'
1183
+ ? 'Set the old place aside?'
1184
+ : state.kind === 'moved' && state.conversations
1185
+ ? 'Point it here, and bring the conversations along?'
1186
+ : 'Point it here?';
1187
+ const answer = deps.ask(` ${question} [Y/n] `);
1188
+ if (answer === null || /^n(o)?$/i.test(answer.trim())) {
1189
+ deps.log(` skipped — later: ${fix}`);
1190
+ return;
1191
+ }
1192
+ try {
1193
+ const done = fixProjectMemory(projectRoot, state, ctx, {
1194
+ ask: deps.ask,
1195
+ log: deps.log,
1196
+ today: new Date().toLocaleDateString('sv-SE'),
1197
+ personalHosts,
1198
+ });
1199
+ for (const d of done) deps.log(`✓ ${d}`);
1200
+ } catch (e) {
1201
+ deps.log(`✗ ${errorMessage(e)} — ${fix}`);
1202
+ }
1203
+ }
1204
+
1102
1205
  /** `from` — where the person sits; a vdx that `vdx ai@<host>` started passes its own on. */
1103
1206
  export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: string = seatLabel(deps.env)): number {
1104
1207
  const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
@@ -1179,10 +1282,11 @@ function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): Co
1179
1282
  if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1180
1283
  const host = plan.host;
1181
1284
  const same = sameMachine;
1285
+ // A mail session's conversations are its own: a start of the person's never continues one.
1182
1286
  const all =
1183
1287
  plan.resumeArgs.length === 0
1184
1288
  ? []
1185
- : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
1289
+ : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot }).filter((c) => !c.mail);
1186
1290
  const root = shellQuote(plan.projectRoot);
1187
1291
  // Said from where the person sits: under `vdx ai@<host>` that is not this machine.
1188
1292
  const atSeat = same(plan.seat, host);
@@ -1374,7 +1478,7 @@ interface Prepared {
1374
1478
  }
1375
1479
 
1376
1480
  /** 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 {
1481
+ function prepare(pathArg: string, deps: AiDeps, focused = false, intercom = false): Prepared | number {
1378
1482
  const { log } = deps;
1379
1483
 
1380
1484
  const projectRoot = resolveProjectRoot(pathArg);
@@ -1423,6 +1527,7 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1423
1527
  host: hostLabel(deps.env),
1424
1528
  seat: seatLabel(deps.env),
1425
1529
  focused: asFocused,
1530
+ intercom,
1426
1531
  });
1427
1532
  let plan: LaunchPlan;
1428
1533
  try {
@@ -1434,20 +1539,35 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1434
1539
  return { projectRoot, environment, profilePath, profileDir, plan, replan };
1435
1540
  }
1436
1541
 
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`;
1542
+ /** How an agent process was started: `vdx ai --focused`, `vdx ai --intercom`, or neither. */
1543
+ export type AgentMode = 'focused' | 'mail' | 'plain';
1544
+
1545
+ /**
1546
+ * The mode from the process's environment (`ps -E` on macOS, /proc on Linux). Both marks —
1547
+ * focused: deaf wins, as intercom reads it.
1548
+ */
1549
+ export function processMode(pid: number): AgentMode {
1550
+ let words: string[];
1440
1551
  try {
1441
1552
  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);
1553
+ if (fs.existsSync(environ)) words = fs.readFileSync(environ, 'utf8').split('\0');
1554
+ else {
1555
+ const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
1556
+ encoding: 'utf8',
1557
+ stdio: ['ignore', 'pipe', 'ignore'],
1558
+ });
1559
+ words = out.split(/\s+/);
1560
+ }
1448
1561
  } catch {
1449
- return false;
1562
+ return 'plain';
1450
1563
  }
1564
+ if (words.includes(`${FOCUSED_ENV_VAR}=1`)) return 'focused';
1565
+ return words.includes(`${INTERCOM_ENV_VAR}=1`) ? 'mail' : 'plain';
1566
+ }
1567
+
1568
+ /** Was the process started focused — VDX_FOCUSED=1 in its environment? */
1569
+ export function processFocused(pid: number): boolean {
1570
+ return processMode(pid) === 'focused';
1451
1571
  }
1452
1572
 
1453
1573
  /** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
@@ -1504,9 +1624,23 @@ export function foldersWarning(denied: string[], viaTmux: boolean, fix: string |
1504
1624
  ];
1505
1625
  }
1506
1626
 
1507
- /** The agent's command line; a focused one carries VDX_FOCUSED=1 in front, for the shell in its tmux pane. */
1627
+ /**
1628
+ * The agent's argv after the command. A mail session's intercom_args go first: its first
1629
+ * prompt after `--dangerously-load-development-channels <servers...>` would be read as a server.
1630
+ */
1631
+ function launchArgs(plan: LaunchPlan, args: string[]): string[] {
1632
+ return [...plan.intercomArgs, ...args];
1633
+ }
1634
+
1635
+ /** The mark of the agent's mode in its environment, if any: `VDX_FOCUSED` or `VDX_INTERCOM`. */
1636
+ function modeEnvVar(plan: LaunchPlan): string | null {
1637
+ return plan.focused ? FOCUSED_ENV_VAR : plan.intercom ? INTERCOM_ENV_VAR : null;
1638
+ }
1639
+
1640
+ /** The agent's command line; its mode's mark goes in front, for the shell in its tmux pane. */
1508
1641
  function agentLine(plan: LaunchPlan, args: string[]): string {
1509
- return `${plan.focused ? `${FOCUSED_ENV_VAR}=1 ` : ''}${commandLine(plan.command, args)}`;
1642
+ const mark = modeEnvVar(plan);
1643
+ return `${mark ? `${mark}=1 ` : ''}${commandLine(plan.command, launchArgs(plan, args))}`;
1510
1644
  }
1511
1645
 
1512
1646
  /** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
@@ -1527,12 +1661,14 @@ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): P
1527
1661
  */
1528
1662
  export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
1529
1663
  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.
1664
+ // The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1,
1665
+ // a mail session's VDX_INTERCOM=1.
1531
1666
  const focused = deps.env[FOCUSED_ENV_VAR] === '1';
1532
- const prepared = prepare(pathArg, deps, focused);
1667
+ const intercom = !focused && deps.env[INTERCOM_ENV_VAR] === '1';
1668
+ const prepared = prepare(pathArg, deps, focused, intercom);
1533
1669
  if (typeof prepared === 'number') return prepared;
1534
1670
  const { projectRoot, profilePath, plan } = prepared;
1535
- const vdxAi = `vdx ai${focused ? ' --focused' : ''}`;
1671
+ const vdxAi = `vdx ai${focused ? ' --focused' : intercom ? ' --intercom' : ''}`;
1536
1672
 
1537
1673
  const lines = [
1538
1674
  `${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
@@ -1549,6 +1685,17 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1549
1685
  '.',
1550
1686
  );
1551
1687
  }
1688
+ if (intercom) {
1689
+ // The conversation is known by this line as a mail session's (conversations.ts): keep its opening.
1690
+ lines.push(
1691
+ `${MAIL_LINE_PREFIX} (\`vdx ai --intercom\`): letters and wakes for the project come here, so the user's ` +
1692
+ `own session stays quiet, and nobody sits at this one. When nothing is left for it — right after a check ` +
1693
+ `of its inbox shows nothing to take — it ends itself: \`vdx ai --close\` (the pane closes ${CLOSE_DELAY_S} s ` +
1694
+ `later, so say what was done first). With questions to the user still unanswered: ` +
1695
+ `\`vdx ai --close --waiting <n>\` — the project's other tmux sessions here show the number; a plain ` +
1696
+ `\`--close\` clears it.`,
1697
+ );
1698
+ }
1552
1699
  const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1553
1700
  if (!own) {
1554
1701
  lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
@@ -1587,7 +1734,12 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1587
1734
  `Hand the user the fix — ${reload}` +
1588
1735
  (deps.env['TMUX']
1589
1736
  ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
1590
- (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1737
+ // A focused session and the mail session restart into a new conversation.
1738
+ (focused || intercom
1739
+ ? ', in a new conversation'
1740
+ : plan.resumeArgs.length
1741
+ ? `, resuming the conversation (${plan.resumeArgs.join(' ')})`
1742
+ : '') +
1591
1743
  `. Do not run it yourself: it ends this session.`
1592
1744
  : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
1593
1745
  );
@@ -1596,6 +1748,60 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1596
1748
  return EXIT_DRIFT;
1597
1749
  }
1598
1750
 
1751
+ /**
1752
+ * `vdx ai --close [--waiting <n>]`: the mail session ends itself — run inside it, by its
1753
+ * agent. Only the pane whose agent is this process's own ancestor and carries VDX_INTERCOM=1
1754
+ * is closed, a few seconds later: the command never ends the person's session. The project's
1755
+ * other tmux sessions here get `@vdx_waiting` = n, the questions to the person still
1756
+ * unanswered — the mail session's tab is about to go; without `--waiting`, none, and the
1757
+ * mark is cleared: the last mail session's word holds.
1758
+ */
1759
+ export function runAiClose(pathArg: string, waiting: number, deps: AiDeps, selfPid: number = process.pid): number {
1760
+ const { log, tmux } = deps;
1761
+ const prepared = prepare(pathArg, deps, false, true);
1762
+ if (typeof prepared === 'number') return prepared;
1763
+ const { projectRoot, plan } = prepared;
1764
+ const procs = deps.processes();
1765
+ const own = findOwnAgent(procs, selfPid, plan.command);
1766
+ const mailOf = deps.agentMail ?? ((pid: number) => processMode(pid) === 'mail');
1767
+ if (!own || !mailOf(own.pid)) {
1768
+ log(
1769
+ `vdx ai --close: this is not a mail session (\`vdx ai --intercom\`)` +
1770
+ `${own ? '' : ` — no ${plan.command} among the ancestors of this process`}; nothing closed`,
1771
+ );
1772
+ return EXIT_USAGE;
1773
+ }
1774
+ const panes = tmux.panes();
1775
+ const pane = panes.find((p) => processTree(procs, p.panePid).some((x) => x.pid === own.pid));
1776
+ if (!pane) {
1777
+ log('vdx ai --close: this mail session runs outside tmux — nothing to close here; it ends when its agent exits');
1778
+ return EXIT_USAGE;
1779
+ }
1780
+ const others = [
1781
+ ...new Set(findAgentPanes(panes, procs, projectRoot, plan.command).map((a) => a.pane.session)),
1782
+ ].filter((s) => s !== pane.session);
1783
+ for (const s of others) {
1784
+ // `=name:` — a session option needs the session as a target, `=name` alone is not found.
1785
+ tmux.tryRun(
1786
+ waiting > 0
1787
+ ? ['set-option', '-t', `=${s}:`, WAITING_OPTION, String(waiting)]
1788
+ : ['set-option', '-u', '-t', `=${s}:`, WAITING_OPTION],
1789
+ );
1790
+ }
1791
+ if (waiting > 0)
1792
+ log(
1793
+ others.length
1794
+ ? `✓ ${waiting} waiting for the user — shown on ${others.join(', ')}`
1795
+ : `note: ${waiting} waiting for the user, and no other session of this project here to show it`,
1796
+ );
1797
+ if (tmux.tryRun(['run-shell', '-b', '-d', String(CLOSE_DELAY_S), '-C', `kill-pane -t ${pane.paneId}`]) === null) {
1798
+ log(`✗ ${pane.session} ${pane.paneId}: tmux did not take the close — the mail session keeps running`);
1799
+ return EXIT_LAUNCH_FAILED;
1800
+ }
1801
+ log(`✓ ${pane.session} ${pane.paneId}: the mail session closes in ${CLOSE_DELAY_S} s`);
1802
+ return EXIT_OK;
1803
+ }
1804
+
1599
1805
  /** `this session runs Claude Code 2.1.294 — 2.1.304 is installed; plugins updated since it loaded them: vdm 2.54.0`. */
1600
1806
  function describeLag(lag: Lag): string {
1601
1807
  const names = lag.plugins.map((p) => `${p.name} ${p.version}`).join(', ');
@@ -1607,14 +1813,23 @@ function describeLag(lag: Lag): string {
1607
1813
  export function runAi(opts: AiOptions, deps: AiDeps): number {
1608
1814
  const { log, tmux } = deps;
1609
1815
 
1610
- const prepared = prepare(opts.path, deps, opts.focused === true);
1816
+ if (opts.intercom && opts.focused) {
1817
+ log('vdx ai: --intercom and --focused are different sessions — the mail session, or one nothing wakes; pick one');
1818
+ return EXIT_USAGE;
1819
+ }
1820
+ if (opts.intercom && opts.conversation) {
1821
+ log('vdx ai: --intercom starts the mail session in a new conversation — it takes no --conversation');
1822
+ return EXIT_USAGE;
1823
+ }
1824
+ const prepared = prepare(opts.path, deps, opts.focused === true, opts.intercom === true);
1611
1825
  if (typeof prepared === 'number') return prepared;
1612
1826
  const { projectRoot, environment, profileDir } = prepared;
1613
1827
  let { plan } = prepared;
1614
1828
  // 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.
1829
+ // and its mode is this start's flag, not the shell vdx runs in.
1616
1830
  delete deps.env[SEAT_ENV_VAR];
1617
1831
  delete deps.env[FOCUSED_ENV_VAR];
1832
+ delete deps.env[INTERCOM_ENV_VAR];
1618
1833
  // Under `vdx ai@<host>` the person is not here: this machine goes by its name.
1619
1834
  const atSeat = sameMachine(plan.seat, plan.host);
1620
1835
  const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
@@ -1623,8 +1838,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1623
1838
  log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
1624
1839
  return EXIT_USAGE;
1625
1840
  }
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;
1841
+ // A start continues the last conversation unless asked for a new one; a focused session and
1842
+ // the mail session are new ones.
1843
+ const wantsNew = (opts.fresh || opts.focused === true || plan.intercom) && !opts.conversation;
1628
1844
  if (wantsNew) plan = { ...plan, resumeArgs: [] };
1629
1845
 
1630
1846
  let multiplexer = plan.multiplexer;
@@ -1633,8 +1849,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1633
1849
  multiplexer = 'none';
1634
1850
  }
1635
1851
 
1636
- const running =
1852
+ const agentsHere =
1637
1853
  multiplexer === 'tmux' ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command) : [];
1854
+ // The mail session and the person's sessions do not see each other: --intercom is about the
1855
+ // one mail session, every other start leaves it alone — nobody sits at it, and it ends itself.
1856
+ const mailOf = deps.agentMail ?? ((pid: number) => processMode(pid) === 'mail');
1857
+ const running = agentsHere.filter((a) => mailOf(a.proc.pid) === plan.intercom);
1858
+ const aside = agentsHere.filter((a) => !running.includes(a));
1638
1859
  const focusedOf = deps.agentFocused ?? processFocused;
1639
1860
  const conversationOf = (a: AgentPane) => liveConversationId(deps.env, deps.home, a.proc.pid);
1640
1861
  // Which running agent the start is about. A new conversation, or one no agent here is in, starts
@@ -1642,8 +1863,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1642
1863
  // one asked for: no second agent in it blindly.
1643
1864
  const inConversation = opts.conversation ? running.find((a) => conversationOf(a) === opts.conversation) : undefined;
1644
1865
  const unreadable = opts.conversation && !inConversation ? running.find((a) => conversationOf(a) === null) : undefined;
1866
+ // One mail session per project and machine: a running one is the one, never a second next to it.
1645
1867
  const alongside =
1646
- running.length > 0 && !opts.restart && !inConversation && !unreadable && (wantsNew || !!opts.conversation);
1868
+ !plan.intercom &&
1869
+ running.length > 0 &&
1870
+ !opts.restart &&
1871
+ !inConversation &&
1872
+ !unreadable &&
1873
+ (wantsNew || !!opts.conversation);
1647
1874
  let current = inConversation ?? unreadable;
1648
1875
  if (!current && !alongside && running.length > 0) {
1649
1876
  const picked = pickAgent(running, focusedOf, deps, !opts.dryRun);
@@ -1663,7 +1890,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1663
1890
  // What a start continues. A restart keeps the conversation the agent is in.
1664
1891
  let choice: ConversationChoice | null = null;
1665
1892
  if (!current || opts.restart) {
1666
- if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1893
+ // The mail session runs next to the person's agents on any machine by design: nothing to ask.
1894
+ if (plan.intercom) choice = { args: [], lines: [] };
1895
+ else if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
1667
1896
  // A restart into a new conversation replaces the agent here, and a new one next to an agent here
1668
1897
  // is what was asked for: the other machines are not asked.
1669
1898
  else if ((current || alongside) && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
@@ -1687,11 +1916,19 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1687
1916
  : [];
1688
1917
  if (opts.dryRun) {
1689
1918
  deps.out(
1690
- renderPlan(plan, multiplexer, running, focusedOf, freeSessionName(plan.sessionName, alongside ? running : [])),
1919
+ renderPlan(
1920
+ plan,
1921
+ multiplexer,
1922
+ running,
1923
+ focusedOf,
1924
+ freeSessionName(plan.sessionName, alongside ? running : []),
1925
+ aside,
1926
+ ),
1691
1927
  );
1692
1928
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1693
1929
  for (const l of folders) deps.out(` ${l}\n`);
1694
1930
  ensureAuthor(projectRoot, poolDirs, deps, true);
1931
+ ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, true, agentsHere.length > 0);
1695
1932
  return EXIT_OK;
1696
1933
  }
1697
1934
  for (const l of choice?.lines ?? []) log(l);
@@ -1727,6 +1964,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1727
1964
  plan = { ...plan, resumeArgs: resumeId(id) };
1728
1965
  }
1729
1966
  ensureAuthor(projectRoot, poolDirs, deps, false);
1967
+ ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, false, agentsHere.length > 0);
1730
1968
 
1731
1969
  if (multiplexer === 'none') {
1732
1970
  if (opts.restart) {
@@ -1738,9 +1976,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1738
1976
  return EXIT_USAGE;
1739
1977
  }
1740
1978
  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;
1979
+ const mark = modeEnvVar(plan);
1980
+ const env = mark ? { ...process.env, [mark]: '1' } : undefined;
1742
1981
  const startedAt = Date.now();
1743
- let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
1982
+ let res = spawnSync(plan.command, launchArgs(plan, [...plan.args, ...plan.resumeArgs]), {
1983
+ cwd: projectRoot,
1984
+ stdio: 'inherit',
1985
+ env,
1986
+ });
1744
1987
  if (at(res.error, 'code') === 'ENOENT') {
1745
1988
  log(`vdx ai: \`${plan.command}\` not found on PATH`);
1746
1989
  return 127;
@@ -1748,7 +1991,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1748
1991
  const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
1749
1992
  if (plan.resumeArgs.length > 0 && failedAtOnce) {
1750
1993
  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 });
1994
+ res = spawnSync(plan.command, launchArgs(plan, plan.args), { cwd: projectRoot, stdio: 'inherit', env });
1752
1995
  }
1753
1996
  return res.status ?? 1;
1754
1997
  }
@@ -1771,20 +2014,20 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1771
2014
  const unfocused = plan.focused && !runningFocused;
1772
2015
  const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
1773
2016
  if (!unfocused && missing.length === 0) {
1774
- log(
1775
- `✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`,
1776
- );
2017
+ const mode = runningFocused ? ', focused' : plan.intercom ? ', the mail session' : '';
2018
+ log(`✓ ${current.pane.session}: already running with the profile${mode} — ${current.proc.args}`);
1777
2019
  return finish(true, current.pane.session, current.pane.paneId, opts, deps);
1778
2020
  }
1779
2021
  // Drift. In a terminal the person picks: the agent as it runs, or a restart
1780
2022
  // with the profile; without one nothing is touched, and the fix is named.
1781
2023
  const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
2024
+ const modeFlag = plan.focused ? ' --focused' : plan.intercom ? ' --intercom' : '';
1782
2025
  const fix =
1783
- `vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
2026
+ `vdx ai --restart${modeFlag}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1784
2027
  (opts.conversation ? ` --conversation ${opts.conversation}` : '');
1785
2028
  const effect = plan.resumeArgs.length
1786
2029
  ? 'stops the running agent, then resumes its conversation'
1787
- : opts.fresh
2030
+ : opts.fresh || plan.intercom
1788
2031
  ? 'stops the running agent and starts a new conversation'
1789
2032
  : 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
1790
2033
  if (!deps.interactive) {
@@ -1815,7 +2058,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1815
2058
  if (current) {
1816
2059
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1817
2060
  log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
1818
- tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
2061
+ tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
1819
2062
  return finish(
1820
2063
  settleResumed(plan, current.pane.paneId, deps),
1821
2064
  current.pane.session,
@@ -1826,8 +2069,10 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1826
2069
  }
1827
2070
 
1828
2071
  if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
2072
+ if (plan.intercom && plan.intercomArgs.length === 0)
2073
+ log('note: the profile has no agent.intercom_args — the mail session starts idle and waits for a wake');
1829
2074
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1830
- const cmd = tmuxShellCommand(line, deps.shell);
2075
+ const cmd = tmuxShellCommand(line, deps.shell, !plan.intercom);
1831
2076
  const session = freeSessionName(plan.sessionName, running);
1832
2077
  const reuse = tmux.hasSession(session);
1833
2078
  const paneId = reuse ? tmux.newWindow(session, projectRoot, cmd) : tmux.newSession(session, projectRoot, cmd);