@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/README.md +11 -3
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +4 -3
- package/src/ai.ts +291 -46
- package/src/conversations.ts +11 -2
- package/src/doctor.ts +88 -5
- package/src/hosting.ts +28 -5
- package/src/index.ts +107 -11
- package/src/init.ts +1 -1
- package/src/memory.ts +367 -0
- package/src/publish.ts +11 -0
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:
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1438
|
-
export
|
|
1439
|
-
|
|
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))
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1627
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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], {
|
|
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
|
-
|
|
1775
|
-
|
|
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${
|
|
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);
|