@vodmal/vdx-cli 0.22.1 → 0.24.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 +4 -3
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +1 -1
- package/src/ai.ts +183 -33
- package/src/index.ts +16 -5
- package/src/init.ts +1 -1
- package/src/publish.ts +13 -5
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ vdx doctor --check [project-path] # one line per hook declared but not running
|
|
|
51
51
|
vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
|
|
52
52
|
|
|
53
53
|
# Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
|
|
54
|
-
vdx ai[@host] [project-path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
|
|
54
|
+
vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
|
|
55
55
|
vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
|
|
56
56
|
|
|
57
57
|
# What a command does and takes; nothing runs (ai: also where launch flags live)
|
|
@@ -75,11 +75,12 @@ vdx init --stack node
|
|
|
75
75
|
vdx test # → mise run test (which calls `vitest run` or whatever was detected)
|
|
76
76
|
|
|
77
77
|
# 3. Ship a new minor release of a Node lib
|
|
78
|
-
vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP) → commit + tag → push → wait for the registry
|
|
78
|
+
vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP or browser) → commit + tag → push → wait for the registry
|
|
79
79
|
|
|
80
80
|
# 4. Start the agent here, continuing its last conversation (--new: a new one;
|
|
81
81
|
# another machine's conversation is continued on that machine);
|
|
82
|
-
# re-run to attach
|
|
82
|
+
# re-run to attach; an agent without the profile's flags is a question:
|
|
83
|
+
# Enter attaches as is, r restarts it; --restart applies a changed profile
|
|
83
84
|
vdx ai
|
|
84
85
|
|
|
85
86
|
# 5. The same on another machine (an ssh host with vdx), attaching from here
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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
package/src/ai.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface WhenRule {
|
|
|
57
57
|
if: Predicate;
|
|
58
58
|
args?: string[];
|
|
59
59
|
confirm?: ConfirmRule[];
|
|
60
|
+
/** The rule is there to wake the agent: a focused session (`vdx ai --focused`) leaves it out. */
|
|
61
|
+
wakes?: boolean;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export interface AgentProfile {
|
|
@@ -82,6 +84,8 @@ export interface SessionProfile {
|
|
|
82
84
|
project_names?: NameSource[];
|
|
83
85
|
/** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
|
|
84
86
|
machines?: string[];
|
|
87
|
+
/** What to do when a session cannot read ~/Downloads, ~/Desktop or ~/Documents (macOS): shown after the warning. */
|
|
88
|
+
folders_fix?: string;
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
/**
|
|
@@ -118,6 +122,11 @@ export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
|
|
|
118
122
|
export const HOST_ENV_VAR = 'VDX_HOST';
|
|
119
123
|
/** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
|
|
120
124
|
export const SEAT_ENV_VAR = 'VDX_AI_FROM';
|
|
125
|
+
/**
|
|
126
|
+
* `1` in the environment of an agent started with `vdx ai --focused`, and so of
|
|
127
|
+
* its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
|
|
128
|
+
*/
|
|
129
|
+
export const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
|
|
121
130
|
export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
|
|
122
131
|
/** Tried in order when no profile names an agent. Started plain, with no flags. */
|
|
123
132
|
export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
|
|
@@ -170,6 +179,7 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
170
179
|
if (rule.args !== undefined && !isStringList(rule.args)) {
|
|
171
180
|
fail(`${at}.args`, 'must be a list of strings');
|
|
172
181
|
}
|
|
182
|
+
if (rule.wakes !== undefined && typeof rule.wakes !== 'boolean') fail(`${at}.wakes`, 'must be true or false');
|
|
173
183
|
if (rule.confirm !== undefined) {
|
|
174
184
|
if (!Array.isArray(rule.confirm)) fail(`${at}.confirm`, 'must be a list');
|
|
175
185
|
rule.confirm.forEach((c, j) => {
|
|
@@ -214,6 +224,9 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
214
224
|
if (!isMachineLabel(m)) fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
|
|
215
225
|
});
|
|
216
226
|
}
|
|
227
|
+
if (session.folders_fix !== undefined && (typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')) {
|
|
228
|
+
fail('session.folders_fix', 'must be a non-empty string');
|
|
229
|
+
}
|
|
217
230
|
}
|
|
218
231
|
|
|
219
232
|
const gitDoc = envDoc.git;
|
|
@@ -289,6 +302,10 @@ export interface LaunchPlan {
|
|
|
289
302
|
args: string[];
|
|
290
303
|
resumeArgs: string[];
|
|
291
304
|
matched: string[];
|
|
305
|
+
/** `vdx ai --focused`: the agent gets VDX_FOCUSED=1, and the rules that wake it are left out. */
|
|
306
|
+
focused: boolean;
|
|
307
|
+
/** Matched rules a focused session leaves out (`wakes: true`). */
|
|
308
|
+
skipped: string[];
|
|
292
309
|
confirm: ConfirmRule[];
|
|
293
310
|
multiplexer: Multiplexer;
|
|
294
311
|
sessionName: string;
|
|
@@ -305,14 +322,21 @@ export function planLaunch(input: {
|
|
|
305
322
|
projectSource?: string;
|
|
306
323
|
host: string;
|
|
307
324
|
seat?: string;
|
|
325
|
+
focused?: boolean;
|
|
308
326
|
}): LaunchPlan {
|
|
309
327
|
const { environment, agent, projectRoot } = input;
|
|
328
|
+
const focused = input.focused === true;
|
|
310
329
|
const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
|
|
311
330
|
const args = [...(agent.args ?? [])];
|
|
312
331
|
const matched: string[] = [];
|
|
332
|
+
const skipped: string[] = [];
|
|
313
333
|
const confirm: ConfirmRule[] = [];
|
|
314
334
|
for (const rule of agent.when ?? []) {
|
|
315
335
|
if (!evalPredicate(rule.if, ctx)) continue;
|
|
336
|
+
if (focused && rule.wakes) {
|
|
337
|
+
skipped.push(rule.id);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
316
340
|
matched.push(rule.id);
|
|
317
341
|
args.push(...(rule.args ?? []));
|
|
318
342
|
confirm.push(...(rule.confirm ?? []));
|
|
@@ -328,6 +352,8 @@ export function planLaunch(input: {
|
|
|
328
352
|
args,
|
|
329
353
|
resumeArgs: [...(agent.resume_args ?? [])],
|
|
330
354
|
matched,
|
|
355
|
+
focused,
|
|
356
|
+
skipped,
|
|
331
357
|
confirm,
|
|
332
358
|
multiplexer: environment.session?.multiplexer ?? 'none',
|
|
333
359
|
sessionName: renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
|
|
@@ -670,6 +696,8 @@ export interface AiOptions {
|
|
|
670
696
|
conversation?: string;
|
|
671
697
|
detach: boolean;
|
|
672
698
|
dryRun: boolean;
|
|
699
|
+
/** `--focused`: a session nothing wakes — VDX_FOCUSED=1, the profile's `wakes` rules left out. */
|
|
700
|
+
focused?: boolean;
|
|
673
701
|
}
|
|
674
702
|
|
|
675
703
|
export interface AiDeps {
|
|
@@ -683,6 +711,10 @@ export interface AiDeps {
|
|
|
683
711
|
out: (text: string) => void;
|
|
684
712
|
onPath: (bin: string) => boolean;
|
|
685
713
|
processes: () => ProcInfo[];
|
|
714
|
+
/** Was this agent process started focused; default: its environment (`processFocused`). */
|
|
715
|
+
agentFocused?: (pid: number) => boolean;
|
|
716
|
+
/** Default `process.platform`: the folder check is for macOS. */
|
|
717
|
+
platform?: NodeJS.Platform;
|
|
686
718
|
sleep: (ms: number) => void;
|
|
687
719
|
confirmTimeoutMs: number;
|
|
688
720
|
/** One line from the person at the terminal, or null when there is none. */
|
|
@@ -758,15 +790,21 @@ export const EXIT_LAUNCH_FAILED = 4;
|
|
|
758
790
|
*/
|
|
759
791
|
export const NOTHING_TO_RESUME_MS = 30_000;
|
|
760
792
|
|
|
761
|
-
export function renderPlan(
|
|
793
|
+
export function renderPlan(
|
|
794
|
+
plan: LaunchPlan,
|
|
795
|
+
multiplexer: Multiplexer,
|
|
796
|
+
running: AgentPane[],
|
|
797
|
+
focusedOf: (pid: number) => boolean = () => false,
|
|
798
|
+
): string {
|
|
762
799
|
const lines = [
|
|
763
800
|
`vdx ai — ${plan.projectRoot}`,
|
|
764
801
|
` name: ${plan.project} (${plan.projectSource})`,
|
|
765
802
|
` profile: ${plan.profilePath ?? `none — built-in default (${FALLBACK_AGENTS.join(' or ')}, no flags, no tmux)`}`,
|
|
766
|
-
` agent: ${
|
|
803
|
+
` agent: ${agentLine(plan, plan.args)}`,
|
|
767
804
|
` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
|
|
768
805
|
` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
|
|
769
806
|
];
|
|
807
|
+
if (plan.focused) lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
|
|
770
808
|
for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
|
|
771
809
|
lines.push(
|
|
772
810
|
multiplexer === 'tmux'
|
|
@@ -777,10 +815,11 @@ export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running:
|
|
|
777
815
|
if (running.length === 0) lines.push(' running: —');
|
|
778
816
|
for (const r of running) {
|
|
779
817
|
const missing = missingArgs(r.proc.args, plan.args);
|
|
818
|
+
const focused = focusedOf(r.proc.pid);
|
|
780
819
|
lines.push(
|
|
781
820
|
` running: ${r.pane.session} ${r.pane.paneId} — ${
|
|
782
821
|
missing.length ? `missing ${missing.join(' ')}` : 'matches the profile'
|
|
783
|
-
}`,
|
|
822
|
+
}${focused ? ', focused' : plan.focused ? ', not focused' : ''}`,
|
|
784
823
|
);
|
|
785
824
|
}
|
|
786
825
|
}
|
|
@@ -880,7 +919,7 @@ function settle(plan: LaunchPlan, paneId: string, deps: AiDeps): SettleResult {
|
|
|
880
919
|
function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean {
|
|
881
920
|
const result = settle(plan, paneId, deps);
|
|
882
921
|
if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
|
|
883
|
-
const line =
|
|
922
|
+
const line = agentLine(plan, plan.args);
|
|
884
923
|
deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
|
|
885
924
|
deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
|
|
886
925
|
return settle(plan, paneId, deps) === 'ok';
|
|
@@ -926,6 +965,7 @@ export function remoteAiArgs(input: {
|
|
|
926
965
|
const { host, opts, version } = input;
|
|
927
966
|
const flags = [
|
|
928
967
|
opts.fresh && '--new',
|
|
968
|
+
opts.focused && '--focused',
|
|
929
969
|
opts.restart && '--restart',
|
|
930
970
|
opts.detach && '--detach',
|
|
931
971
|
opts.dryRun && '--dry-run',
|
|
@@ -1194,10 +1234,12 @@ interface Prepared {
|
|
|
1194
1234
|
profilePath: string | null;
|
|
1195
1235
|
profileDir: string;
|
|
1196
1236
|
plan: LaunchPlan;
|
|
1237
|
+
/** The same plan for a focused session or a plain one. */
|
|
1238
|
+
replan: (focused: boolean) => LaunchPlan;
|
|
1197
1239
|
}
|
|
1198
1240
|
|
|
1199
1241
|
/** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
|
|
1200
|
-
function prepare(pathArg: string, deps: AiDeps): Prepared | number {
|
|
1242
|
+
function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | number {
|
|
1201
1243
|
const { log } = deps;
|
|
1202
1244
|
|
|
1203
1245
|
const projectRoot = resolveProjectRoot(pathArg);
|
|
@@ -1235,9 +1277,8 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
|
|
|
1235
1277
|
profileDir,
|
|
1236
1278
|
});
|
|
1237
1279
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
plan = planLaunch({
|
|
1280
|
+
const replan = (asFocused: boolean) =>
|
|
1281
|
+
planLaunch({
|
|
1241
1282
|
environment,
|
|
1242
1283
|
profilePath,
|
|
1243
1284
|
agent,
|
|
@@ -1246,12 +1287,77 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
|
|
|
1246
1287
|
projectSource: named.source,
|
|
1247
1288
|
host: hostLabel(deps.env),
|
|
1248
1289
|
seat: seatLabel(deps.env),
|
|
1290
|
+
focused: asFocused,
|
|
1249
1291
|
});
|
|
1292
|
+
let plan: LaunchPlan;
|
|
1293
|
+
try {
|
|
1294
|
+
plan = replan(focused);
|
|
1250
1295
|
} catch (e: any) {
|
|
1251
1296
|
log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
|
|
1252
1297
|
return EXIT_USAGE;
|
|
1253
1298
|
}
|
|
1254
|
-
return { projectRoot, environment, profilePath, profileDir, plan };
|
|
1299
|
+
return { projectRoot, environment, profilePath, profileDir, plan, replan };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** Was the process started focused — VDX_FOCUSED=1 in its environment (`ps -E` on macOS, /proc on Linux)? */
|
|
1303
|
+
export function processFocused(pid: number): boolean {
|
|
1304
|
+
const mark = `${FOCUSED_ENV_VAR}=1`;
|
|
1305
|
+
try {
|
|
1306
|
+
const environ = `/proc/${pid}/environ`;
|
|
1307
|
+
if (fs.existsSync(environ)) return fs.readFileSync(environ, 'utf8').split('\0').includes(mark);
|
|
1308
|
+
const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
|
|
1309
|
+
encoding: 'utf8',
|
|
1310
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1311
|
+
});
|
|
1312
|
+
return out.split(/\s+/).includes(mark);
|
|
1313
|
+
} catch {
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
|
|
1319
|
+
export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* The guarded folders an agent started here could not read. Through the tmux server
|
|
1323
|
+
* (`run-shell`): its sessions have the server's rights, not vdx's. Without a server, or
|
|
1324
|
+
* without tmux, this process's own — the agent gets the rights of this terminal. A folder
|
|
1325
|
+
* that does not exist is skipped.
|
|
1326
|
+
*/
|
|
1327
|
+
export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
|
|
1328
|
+
const dirs = GUARDED_FOLDERS.map((f) => path.join(home, f));
|
|
1329
|
+
// `ls`, not `[ -d ]`: a denied folder still passes -d.
|
|
1330
|
+
const script = dirs.map((d) => `{ [ ! -d ${shellQuote(d)} ] || ls ${shellQuote(d)} >/dev/null 2>&1 || echo ${shellQuote(d)}; }`).join('; ');
|
|
1331
|
+
const out = tmux?.tryRun(['run-shell', script]);
|
|
1332
|
+
if (out !== undefined && out !== null) return out.split('\n').filter(Boolean);
|
|
1333
|
+
return dirs.filter((d) => {
|
|
1334
|
+
try {
|
|
1335
|
+
fs.readdirSync(d);
|
|
1336
|
+
return false;
|
|
1337
|
+
} catch (e: any) {
|
|
1338
|
+
return e?.code === 'EPERM' || e?.code === 'EACCES';
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** One warning and its fix — the profile's `session.folders_fix`, else what works anywhere. */
|
|
1344
|
+
export function foldersWarning(denied: string[], viaTmux: boolean, fix: string | undefined, home: string): string[] {
|
|
1345
|
+
const names = denied.map((d) => (d.startsWith(home + path.sep) ? `~${d.slice(home.length)}` : d)).join(', ');
|
|
1346
|
+
return [
|
|
1347
|
+
`⚠ this session cannot read ${names} — macOS holds the denial ${viaTmux ? 'in the tmux server' : 'for this terminal'}; ` +
|
|
1348
|
+
`files there are out of the agent's reach`,
|
|
1349
|
+
` fix: ${
|
|
1350
|
+
fix ??
|
|
1351
|
+
(viaTmux
|
|
1352
|
+
? 'restart the tmux server (it closes every session)'
|
|
1353
|
+
: 'allow this terminal app in System Settings → Privacy & Security → Files and Folders')
|
|
1354
|
+
}`,
|
|
1355
|
+
];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** The agent's command line; a focused one carries VDX_FOCUSED=1 in front, for the shell in its tmux pane. */
|
|
1359
|
+
function agentLine(plan: LaunchPlan, args: string[]): string {
|
|
1360
|
+
return `${plan.focused ? `${FOCUSED_ENV_VAR}=1 ` : ''}${commandLine(plan.command, args)}`;
|
|
1255
1361
|
}
|
|
1256
1362
|
|
|
1257
1363
|
/** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
|
|
@@ -1272,9 +1378,12 @@ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): P
|
|
|
1272
1378
|
*/
|
|
1273
1379
|
export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
|
|
1274
1380
|
if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
|
|
1275
|
-
|
|
1381
|
+
// The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1.
|
|
1382
|
+
const focused = deps.env[FOCUSED_ENV_VAR] === '1';
|
|
1383
|
+
const prepared = prepare(pathArg, deps, focused);
|
|
1276
1384
|
if (typeof prepared === 'number') return prepared;
|
|
1277
1385
|
const { projectRoot, profilePath, plan } = prepared;
|
|
1386
|
+
const vdxAi = `vdx ai${focused ? ' --focused' : ''}`;
|
|
1278
1387
|
|
|
1279
1388
|
const lines = [
|
|
1280
1389
|
`${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
|
|
@@ -1283,6 +1392,14 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
|
|
|
1283
1392
|
`condition in the profile, not an instruction to type. A project's agent on another machine: ` +
|
|
1284
1393
|
`\`vdx ai@<machine>\`. More: \`vdx ai --help\`.`,
|
|
1285
1394
|
];
|
|
1395
|
+
if (focused) {
|
|
1396
|
+
lines.push(
|
|
1397
|
+
`· this session is focused (\`vdx ai --focused\`): it works on what the user gave it — nothing should wake it, ` +
|
|
1398
|
+
`and it does not read its inbox unless the user asks` +
|
|
1399
|
+
(plan.skipped.length ? `; the profile's ${plan.skipped.join(', ')} is left out` : '') +
|
|
1400
|
+
'.',
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1286
1403
|
const own = findOwnAgent(deps.processes(), selfPid, plan.command);
|
|
1287
1404
|
if (!own) {
|
|
1288
1405
|
lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
|
|
@@ -1300,10 +1417,10 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
|
|
|
1300
1417
|
lines.push(
|
|
1301
1418
|
`✗ this session runs without ${missing.join(' ')}${why}. Hand the user the fix — ` +
|
|
1302
1419
|
(deps.env['TMUX']
|
|
1303
|
-
?
|
|
1420
|
+
? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
|
|
1304
1421
|
(plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
|
|
1305
1422
|
`. Do not run it yourself: it ends this session.`
|
|
1306
|
-
: `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs
|
|
1423
|
+
: `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
|
|
1307
1424
|
);
|
|
1308
1425
|
deps.out(lines.join('\n') + '\n');
|
|
1309
1426
|
return EXIT_DRIFT;
|
|
@@ -1312,12 +1429,14 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
|
|
|
1312
1429
|
export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
1313
1430
|
const { log, tmux } = deps;
|
|
1314
1431
|
|
|
1315
|
-
const prepared = prepare(opts.path, deps);
|
|
1432
|
+
const prepared = prepare(opts.path, deps, opts.focused === true);
|
|
1316
1433
|
if (typeof prepared === 'number') return prepared;
|
|
1317
1434
|
const { projectRoot, environment, profileDir } = prepared;
|
|
1318
1435
|
let { plan } = prepared;
|
|
1319
|
-
// The agent started here runs on this machine: it does not inherit where the person sat
|
|
1436
|
+
// The agent started here runs on this machine: it does not inherit where the person sat,
|
|
1437
|
+
// and it is focused by this start's flag, not by the shell vdx runs in.
|
|
1320
1438
|
delete deps.env[SEAT_ENV_VAR];
|
|
1439
|
+
delete deps.env[FOCUSED_ENV_VAR];
|
|
1321
1440
|
// Under `vdx ai@<host>` the person is not here: this machine goes by its name.
|
|
1322
1441
|
const atSeat = sameMachine(plan.seat, plan.host);
|
|
1323
1442
|
const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
|
|
@@ -1340,6 +1459,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1340
1459
|
? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
|
|
1341
1460
|
: [];
|
|
1342
1461
|
const current = running[0];
|
|
1462
|
+
// A running agent keeps its mode: plain `vdx ai` takes a focused one as it is,
|
|
1463
|
+
// and the rules it leaves out are not missing from it.
|
|
1464
|
+
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1465
|
+
const runningFocused = current ? focusedOf(current.proc.pid) : false;
|
|
1466
|
+
if (current && !opts.restart && runningFocused && !plan.focused) {
|
|
1467
|
+
plan = { ...prepared.replan(true), resumeArgs: plan.resumeArgs };
|
|
1468
|
+
}
|
|
1343
1469
|
|
|
1344
1470
|
// What a start continues. A restart keeps the conversation the agent is in.
|
|
1345
1471
|
let choice: ConversationChoice | null = null;
|
|
@@ -1357,13 +1483,18 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1357
1483
|
}
|
|
1358
1484
|
|
|
1359
1485
|
const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
|
|
1486
|
+
// Said before a start or an attach, never in the way: the agent runs, without those files.
|
|
1487
|
+
const denied = (deps.platform ?? process.platform) === 'darwin' ? unreadableFolders(multiplexer === 'tmux' ? tmux : null, deps.home) : [];
|
|
1488
|
+
const folders = denied.length ? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home) : [];
|
|
1360
1489
|
if (opts.dryRun) {
|
|
1361
|
-
deps.out(renderPlan(plan, multiplexer, running));
|
|
1490
|
+
deps.out(renderPlan(plan, multiplexer, running, focusedOf));
|
|
1362
1491
|
for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
|
|
1492
|
+
for (const l of folders) deps.out(` ${l}\n`);
|
|
1363
1493
|
ensureAuthor(projectRoot, poolDirs, deps, true);
|
|
1364
1494
|
return EXIT_OK;
|
|
1365
1495
|
}
|
|
1366
1496
|
for (const l of choice?.lines ?? []) log(l);
|
|
1497
|
+
for (const l of folders) log(l);
|
|
1367
1498
|
if (choice?.abort) return EXIT_LAUNCH_FAILED;
|
|
1368
1499
|
if (choice?.elsewhere && !current) {
|
|
1369
1500
|
const answer = deps.interactive ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
|
|
@@ -1404,16 +1535,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1404
1535
|
return EXIT_USAGE;
|
|
1405
1536
|
}
|
|
1406
1537
|
for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
|
|
1538
|
+
const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
|
|
1407
1539
|
const startedAt = Date.now();
|
|
1408
|
-
let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit' });
|
|
1540
|
+
let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
|
|
1409
1541
|
if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
|
|
1410
1542
|
log(`vdx ai: \`${plan.command}\` not found on PATH`);
|
|
1411
1543
|
return 127;
|
|
1412
1544
|
}
|
|
1413
1545
|
const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
|
|
1414
1546
|
if (plan.resumeArgs.length > 0 && failedAtOnce) {
|
|
1415
|
-
log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${
|
|
1416
|
-
res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit' });
|
|
1547
|
+
log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${agentLine(plan, plan.args)}`);
|
|
1548
|
+
res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit', env });
|
|
1417
1549
|
}
|
|
1418
1550
|
return res.status ?? 1;
|
|
1419
1551
|
}
|
|
@@ -1436,32 +1568,50 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1436
1568
|
);
|
|
1437
1569
|
}
|
|
1438
1570
|
}
|
|
1439
|
-
const
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
log(
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1571
|
+
const unfocused = plan.focused && !runningFocused;
|
|
1572
|
+
const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
|
|
1573
|
+
if (!unfocused && missing.length === 0) {
|
|
1574
|
+
log(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
|
|
1575
|
+
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1576
|
+
}
|
|
1577
|
+
// Drift. In a terminal the person picks: the agent as it runs, or a restart
|
|
1578
|
+
// with the profile; without one nothing is touched, and the fix is named.
|
|
1579
|
+
const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
|
|
1580
|
+
const fix = `vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}`;
|
|
1581
|
+
const effect = plan.resumeArgs.length
|
|
1582
|
+
? 'stops the running agent, then resumes its conversation'
|
|
1583
|
+
: opts.fresh
|
|
1584
|
+
? 'stops the running agent and starts a new conversation'
|
|
1585
|
+
: 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
|
|
1586
|
+
if (!deps.interactive) {
|
|
1587
|
+
log(`✗ ${drift}`);
|
|
1588
|
+
log(` ${unfocused ? 'to focus it' : 'to apply the profile'}: ${fix} (${effect})`);
|
|
1589
|
+
return EXIT_DRIFT;
|
|
1590
|
+
}
|
|
1591
|
+
log(`⚠ ${drift}`);
|
|
1592
|
+
log(` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`);
|
|
1593
|
+
log(` r restart it ${unfocused ? 'focused' : 'with the profile'}: ${effect}`);
|
|
1594
|
+
log(` q leave it; later: ${fix}`);
|
|
1595
|
+
const answer = deps.ask(`${opts.detach ? 'Leave it as is' : 'Attach as is'}? [Enter/r/q]: `);
|
|
1596
|
+
if (answer === '') {
|
|
1597
|
+
log(`→ ${current.pane.session}: as is — ${current.proc.args}`);
|
|
1598
|
+
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1599
|
+
}
|
|
1600
|
+
if (answer?.toLowerCase() !== 'r') {
|
|
1601
|
+
log('nothing done');
|
|
1450
1602
|
return EXIT_DRIFT;
|
|
1451
1603
|
}
|
|
1452
|
-
log(`✓ ${current.pane.session}: already running with the profile — ${current.proc.args}`);
|
|
1453
|
-
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1454
1604
|
}
|
|
1455
1605
|
|
|
1456
1606
|
if (current) {
|
|
1457
|
-
const line =
|
|
1607
|
+
const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
|
|
1458
1608
|
log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
|
|
1459
1609
|
tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
|
|
1460
1610
|
return finish(settleResumed(plan, current.pane.paneId, deps), current.pane.session, current.pane.paneId, opts, deps);
|
|
1461
1611
|
}
|
|
1462
1612
|
|
|
1463
1613
|
if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
|
|
1464
|
-
const line =
|
|
1614
|
+
const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
|
|
1465
1615
|
const cmd = tmuxShellCommand(line, deps.shell);
|
|
1466
1616
|
const reuse = tmux.hasSession(plan.sessionName);
|
|
1467
1617
|
const paneId = reuse
|
package/src/index.ts
CHANGED
|
@@ -76,9 +76,17 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
|
|
|
76
76
|
project_path the project (default: cwd); its git toplevel is used
|
|
77
77
|
@host the same command on <host> over ssh: its vdx, profile and tmux
|
|
78
78
|
--new a new conversation instead of continuing the last one
|
|
79
|
+
--focused a session nothing wakes: the agent, its hooks and MCP servers get
|
|
80
|
+
VDX_FOCUSED=1 — for the plugins that wake sessions to leave it
|
|
81
|
+
alone — and the profile's rules marked \`wakes: true\` (a channel
|
|
82
|
+
flag) are left out. A running agent keeps its mode; --restart
|
|
83
|
+
applies this one
|
|
79
84
|
--conversation <id>
|
|
80
85
|
continue this Claude Code conversation
|
|
81
|
-
--restart restart the running agent in its pane — applies a changed profile
|
|
86
|
+
--restart restart the running agent in its pane — applies a changed profile.
|
|
87
|
+
Without it, an agent running without the profile's flags is
|
|
88
|
+
a question in a terminal: Enter attaches to it as is, r
|
|
89
|
+
restarts it, q leaves it
|
|
82
90
|
--detach start without attaching; print the command that attaches
|
|
83
91
|
--dry-run print the plan and the project's running agents; start nothing
|
|
84
92
|
--check for the agent's own session: how the agent here is launched, and
|
|
@@ -97,7 +105,8 @@ machine's. A new conversation (--new, or none to continue) while an agent of
|
|
|
97
105
|
the project runs on another machine starts only after a yes.
|
|
98
106
|
|
|
99
107
|
Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
|
|
100
|
-
3 the running agent lacks profile flags
|
|
108
|
+
3 the running agent lacks profile flags, or is not focused under --focused,
|
|
109
|
+
and was left as is — no terminal, or q (rerun with --restart); 4 the start
|
|
101
110
|
was not confirmed.
|
|
102
111
|
Profile format: https://github.com/VoDmAl/vdx/blob/main/docs/specs/environment-format.md
|
|
103
112
|
`;
|
|
@@ -128,7 +137,8 @@ mise.toml is overwritten only with --force.
|
|
|
128
137
|
pre-flight checks — at a terminal an expired npm login is renewed (\`npm login\`)
|
|
129
138
|
and the checks run again;
|
|
130
139
|
the version bump in package.json and package-lock.json;
|
|
131
|
-
npm publish (npm asks for an OTP
|
|
140
|
+
npm publish (npm asks for an OTP or a confirmation in the browser; Ctrl-C
|
|
141
|
+
there puts the version back);
|
|
132
142
|
git commit and tag, then \`git push --follow-tags\` to the branch's upstream;
|
|
133
143
|
a wait until the registry shows the new version (up to 3 min).
|
|
134
144
|
--dry-run runs the pre-flight and prints the plan; --force goes on past a failed
|
|
@@ -156,10 +166,10 @@ Exit 2 when something is missing.
|
|
|
156
166
|
positionals: 1,
|
|
157
167
|
},
|
|
158
168
|
ai: {
|
|
159
|
-
usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run]',
|
|
169
|
+
usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run]',
|
|
160
170
|
help: AI_HELP,
|
|
161
171
|
// `--resume` asked to continue in 0.12; continuing is the default now.
|
|
162
|
-
bools: ['new', 'restart', 'resume', 'detach', 'dry-run', 'check'],
|
|
172
|
+
bools: ['new', 'focused', 'restart', 'resume', 'detach', 'dry-run', 'check'],
|
|
163
173
|
values: ['conversation'],
|
|
164
174
|
positionals: 1,
|
|
165
175
|
},
|
|
@@ -500,6 +510,7 @@ function cmdAi(opts: ParsedArgs): void {
|
|
|
500
510
|
conversation,
|
|
501
511
|
detach: opts.flags.detach === true,
|
|
502
512
|
dryRun: opts.flags['dry-run'] === true,
|
|
513
|
+
focused: opts.flags.focused === true,
|
|
503
514
|
};
|
|
504
515
|
const deps = { ...defaultDeps(), version: readCliVersion() };
|
|
505
516
|
// `ai@m3`: the same command on another machine. Its own label runs here.
|
package/src/init.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
13
13
|
export type Verb = (typeof STANDARD_VERBS)[number];
|
|
14
14
|
|
|
15
|
-
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.
|
|
15
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.3.0';
|
|
16
16
|
|
|
17
17
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
18
18
|
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
package/src/publish.ts
CHANGED
|
@@ -481,7 +481,7 @@ export function waitForRegistry(name: string, version: string, deps: PublishDeps
|
|
|
481
481
|
* or there is none. The pre-flight refused a branch behind its upstream.
|
|
482
482
|
* 5. Wait until the registry shows the version.
|
|
483
483
|
*
|
|
484
|
-
* On npm publish failure → restore the original files.
|
|
484
|
+
* On npm publish failure or interruption (Ctrl-C at its prompt) → restore the original files.
|
|
485
485
|
* Git failures after a successful npm publish leave the user in a
|
|
486
486
|
* partially-released state; they fix it manually (commit/tag/push what's
|
|
487
487
|
* needed) — we don't try to unpublish.
|
|
@@ -511,14 +511,22 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
511
511
|
`✓ bumped ${bumped.map((f) => path.relative(plan.projectRoot, f)).join(' and ')}: ${plan.currentVersion} → ${plan.newVersion}`,
|
|
512
512
|
);
|
|
513
513
|
|
|
514
|
-
// 2. npm publish (interactive — OTP
|
|
515
|
-
|
|
514
|
+
// 2. npm publish (interactive — npm asks for an OTP or a confirmation in the browser).
|
|
515
|
+
// Ctrl-C at that prompt reaches npm and vdx alike: vdx holds out while npm runs, so the
|
|
516
|
+
// interrupted npm comes back as an error and the bump is put back — not left for the next run.
|
|
517
|
+
deps.log('→ running `npm publish` (npm may ask for an OTP or a confirmation in the browser)...');
|
|
518
|
+
const holdOut = () => {};
|
|
519
|
+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
520
|
+
for (const s of signals) process.on(s, holdOut);
|
|
516
521
|
try {
|
|
517
522
|
deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
|
|
518
523
|
} catch (e: any) {
|
|
519
524
|
for (const [file, text] of originals) fs.writeFileSync(file, text, 'utf8');
|
|
520
|
-
|
|
521
|
-
|
|
525
|
+
const what = e?.signal ? `interrupted (${e.signal})` : `failed (exit ${e?.status ?? '?'})`;
|
|
526
|
+
deps.log(`✗ npm publish ${what}; reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
|
|
527
|
+
throw new Error(`npm publish ${e?.signal ? 'interrupted' : 'failed'}`);
|
|
528
|
+
} finally {
|
|
529
|
+
for (const s of signals) process.off(s, holdOut);
|
|
522
530
|
}
|
|
523
531
|
deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
|
|
524
532
|
|