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