@vodmal/vdx-cli 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +4 -3
- package/src/ai.ts +282 -49
- package/src/conversations.ts +11 -2
- package/src/hosting.ts +28 -5
- package/src/index.ts +61 -8
- package/src/init.ts +1 -1
- package/src/publish.ts +11 -0
- package/src/session-versions.ts +149 -0
package/README.md
CHANGED
|
@@ -54,8 +54,9 @@ vdx doctor --check [project-path] # one line per hook declared but not running
|
|
|
54
54
|
vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
|
|
55
55
|
|
|
56
56
|
# Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
|
|
57
|
-
vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
|
|
58
|
-
vdx ai --
|
|
57
|
+
vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused | --intercom] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there; --intercom: the project's mail session
|
|
58
|
+
vdx ai --close [--waiting <n>] # inside the mail session: it ends itself; n questions to you still unanswered show on the project's other tmux sessions
|
|
59
|
+
vdx ai --check [project-path] # this session against the profile and the installed Claude Code and plugins — what the plugin's SessionStart hook tells the agent
|
|
59
60
|
|
|
60
61
|
# What a command does and takes; nothing runs (ai: also where launch flags live)
|
|
61
62
|
vdx <command> --help
|
|
@@ -79,6 +80,7 @@ vdx test # → mise run test (which calls `vitest run` or whatever wa
|
|
|
79
80
|
|
|
80
81
|
# 3. Ship a new minor release of a Node lib
|
|
81
82
|
vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP or browser) → commit + tag → push → wait for the registry
|
|
83
|
+
# in your own terminal: without one it releases nothing (--force goes on)
|
|
82
84
|
|
|
83
85
|
# 4. Start the agent here, continuing its last conversation (--new: a new one,
|
|
84
86
|
# next to a running agent; another machine's conversation is continued on
|
|
@@ -143,6 +145,9 @@ the one git keeps for that host (`git credential fill`, never a prompt — log i
|
|
|
143
145
|
once with `git ls-remote https://<host>/scm/<project>/<repo>.git`, a personal
|
|
144
146
|
access token as the password), or `BITBUCKET_TOKEN` when the host is
|
|
145
147
|
`BITBUCKET_HOST`. Branch restrictions are visible to a repository admin only.
|
|
148
|
+
A login the server refuses (a revoked token) is unknown with the command that
|
|
149
|
+
erases it: vdx leaves git's store alone, and sends the login again each audit
|
|
150
|
+
until it is gone.
|
|
146
151
|
Not met comes with the reason; another hosting, no network or no token
|
|
147
152
|
where one is needed leave the axis unknown, with the reason too;
|
|
148
153
|
`VDX_OFFLINE=1` does not ask at all.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
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,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
runsInProject,
|
|
30
31
|
} from './conversations.ts';
|
|
31
32
|
import { at, errorMessage, isStringList, str } from './narrow.ts';
|
|
33
|
+
import { type Lag, sessionLag } from './session-versions.ts';
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* `vdx ai` — start the person's agent in a project, with the flags their
|
|
@@ -68,6 +70,8 @@ export interface AgentProfile {
|
|
|
68
70
|
command: string;
|
|
69
71
|
args?: string[];
|
|
70
72
|
resume_args?: string[];
|
|
73
|
+
/** What a mail session (`vdx ai --intercom`) starts with, right after the command: its first prompt. */
|
|
74
|
+
intercom_args?: string[];
|
|
71
75
|
when?: WhenRule[];
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -130,6 +134,16 @@ const SEAT_ENV_VAR = 'VDX_AI_FROM';
|
|
|
130
134
|
* its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
|
|
131
135
|
*/
|
|
132
136
|
const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
|
|
137
|
+
/**
|
|
138
|
+
* `1` in the environment of the project's mail session (`vdx ai --intercom`): the
|
|
139
|
+
* session letters and wakes go to, so that the person's own session stays quiet.
|
|
140
|
+
* Nobody sits at it; it ends itself with `vdx ai --close`.
|
|
141
|
+
*/
|
|
142
|
+
const INTERCOM_ENV_VAR = 'VDX_INTERCOM';
|
|
143
|
+
/** The tmux session option `vdx ai --close --waiting <n>` sets: questions to the person still unanswered. */
|
|
144
|
+
const WAITING_OPTION = '@vdx_waiting';
|
|
145
|
+
/** A mail session's pane closes this long after `--close`, so the call returns and the turn ends. */
|
|
146
|
+
const CLOSE_DELAY_S = 2;
|
|
133
147
|
const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
|
|
134
148
|
/** Tried in order when no profile names an agent. Started plain, with no flags. */
|
|
135
149
|
const FALLBACK_AGENTS = ['claude', 'codex'] as const;
|
|
@@ -163,7 +177,7 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
163
177
|
if (typeof agent.command !== 'string' || agent.command.trim() === '') {
|
|
164
178
|
fail('agent.command', 'must be a non-empty string');
|
|
165
179
|
}
|
|
166
|
-
for (const key of ['args', 'resume_args'] as const) {
|
|
180
|
+
for (const key of ['args', 'resume_args', 'intercom_args'] as const) {
|
|
167
181
|
if (agent[key] !== undefined && !isStringList(agent[key])) {
|
|
168
182
|
fail(`agent.${key}`, 'must be a list of strings');
|
|
169
183
|
}
|
|
@@ -324,6 +338,10 @@ export interface LaunchPlan {
|
|
|
324
338
|
focused: boolean;
|
|
325
339
|
/** Matched rules a focused session leaves out (`wakes: true`). */
|
|
326
340
|
skipped: string[];
|
|
341
|
+
/** `vdx ai --intercom`: the project's mail session — VDX_INTERCOM=1, a session `<name>-mail`. */
|
|
342
|
+
intercom: boolean;
|
|
343
|
+
/** agent.intercom_args: what a mail session starts with; not required of a running agent. */
|
|
344
|
+
intercomArgs: string[];
|
|
327
345
|
confirm: ConfirmRule[];
|
|
328
346
|
multiplexer: Multiplexer;
|
|
329
347
|
sessionName: string;
|
|
@@ -341,9 +359,11 @@ export function planLaunch(input: {
|
|
|
341
359
|
host: string;
|
|
342
360
|
seat?: string;
|
|
343
361
|
focused?: boolean;
|
|
362
|
+
intercom?: boolean;
|
|
344
363
|
}): LaunchPlan {
|
|
345
364
|
const { environment, agent, projectRoot } = input;
|
|
346
365
|
const focused = input.focused === true;
|
|
366
|
+
const intercom = input.intercom === true && !focused;
|
|
347
367
|
const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
|
|
348
368
|
const args = [...(agent.args ?? [])];
|
|
349
369
|
const matched: string[] = [];
|
|
@@ -372,12 +392,15 @@ export function planLaunch(input: {
|
|
|
372
392
|
matched,
|
|
373
393
|
focused,
|
|
374
394
|
skipped,
|
|
395
|
+
intercom,
|
|
396
|
+
intercomArgs: intercom ? [...(agent.intercom_args ?? [])] : [],
|
|
375
397
|
confirm,
|
|
376
398
|
multiplexer: environment.session?.multiplexer ?? 'none',
|
|
377
|
-
sessionName:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
399
|
+
sessionName:
|
|
400
|
+
renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
|
|
401
|
+
project: input.project,
|
|
402
|
+
host: input.host,
|
|
403
|
+
}) + (intercom ? '-mail' : ''),
|
|
381
404
|
machines: (environment.session?.machines ?? []).filter((m) => m.toLowerCase() !== input.host.toLowerCase()),
|
|
382
405
|
};
|
|
383
406
|
}
|
|
@@ -394,12 +417,13 @@ export function commandLine(command: string, args: string[]): string {
|
|
|
394
417
|
/**
|
|
395
418
|
* The pane runs the agent inside the user's login shell, so the agent sees what
|
|
396
419
|
* `.zshrc` sets up exactly as when it is typed by hand; the tmux server's own
|
|
397
|
-
* environment does not have it. When the agent exits, the pane keeps a shell
|
|
420
|
+
* environment does not have it. When the agent exits, the pane keeps a shell —
|
|
421
|
+
* unless `keepShell` is false (a mail session: its tab goes when it is done).
|
|
398
422
|
*/
|
|
399
|
-
export function tmuxShellCommand(line: string, shell: string | null): string {
|
|
423
|
+
export function tmuxShellCommand(line: string, shell: string | null, keepShell = true): string {
|
|
400
424
|
if (!shell) return line;
|
|
401
425
|
const sh = shellQuote(shell);
|
|
402
|
-
return `exec ${sh} -lic ${shellQuote(`${line}; exec ${sh} -l`)}`;
|
|
426
|
+
return `exec ${sh} -lic ${shellQuote(keepShell ? `${line}; exec ${sh} -l` : line)}`;
|
|
403
427
|
}
|
|
404
428
|
|
|
405
429
|
export interface PaneInfo {
|
|
@@ -722,6 +746,8 @@ export interface AiOptions {
|
|
|
722
746
|
dryRun: boolean;
|
|
723
747
|
/** `--focused`: a session nothing wakes — VDX_FOCUSED=1, the profile's `wakes` rules left out. */
|
|
724
748
|
focused?: boolean;
|
|
749
|
+
/** `--intercom`: the project's mail session — VDX_INTERCOM=1, one per project and machine. */
|
|
750
|
+
intercom?: boolean;
|
|
725
751
|
}
|
|
726
752
|
|
|
727
753
|
export interface AiDeps {
|
|
@@ -737,6 +763,8 @@ export interface AiDeps {
|
|
|
737
763
|
processes: () => ProcInfo[];
|
|
738
764
|
/** Was this agent process started focused; default: its environment (`processFocused`). */
|
|
739
765
|
agentFocused?: (pid: number) => boolean;
|
|
766
|
+
/** Is this agent process a mail session; default: its environment (`processMode`). */
|
|
767
|
+
agentMail?: (pid: number) => boolean;
|
|
740
768
|
/** Default `process.platform`: the folder check is for macOS. */
|
|
741
769
|
platform?: NodeJS.Platform;
|
|
742
770
|
sleep: (ms: number) => void;
|
|
@@ -749,6 +777,28 @@ export interface AiDeps {
|
|
|
749
777
|
ssh?: (args: string[], capture: boolean) => { status: number; stdout: string } | null;
|
|
750
778
|
/** Claude Code processes alive on this machine; read from its session files when not given. */
|
|
751
779
|
liveHere?: () => LiveSession[];
|
|
780
|
+
/** What the agent process `pid` lags behind; read from Claude Code's files when not given. */
|
|
781
|
+
lag?: (pid: number, projectRoot: string, command: string) => Lag | null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function machineLag(deps: AiDeps, pid: number, projectRoot: string, command: string): Lag | null {
|
|
785
|
+
return sessionLag({
|
|
786
|
+
env: deps.env,
|
|
787
|
+
home: deps.home,
|
|
788
|
+
pid,
|
|
789
|
+
projectRoot,
|
|
790
|
+
installedClaude: () => {
|
|
791
|
+
try {
|
|
792
|
+
return execFileSync(command, ['--version'], {
|
|
793
|
+
encoding: 'utf8',
|
|
794
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
795
|
+
timeout: 5_000,
|
|
796
|
+
});
|
|
797
|
+
} catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
});
|
|
752
802
|
}
|
|
753
803
|
|
|
754
804
|
function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
|
|
@@ -821,6 +871,8 @@ function renderPlan(
|
|
|
821
871
|
focusedOf: (pid: number) => boolean,
|
|
822
872
|
/** The session a start would make: next to running agents, one of its own. */
|
|
823
873
|
session: string,
|
|
874
|
+
/** The project's agents this start leaves alone: the mail session for the others, the others for it. */
|
|
875
|
+
aside: AgentPane[] = [],
|
|
824
876
|
): string {
|
|
825
877
|
const lines = [
|
|
826
878
|
`vdx ai — ${plan.projectRoot}`,
|
|
@@ -832,6 +884,12 @@ function renderPlan(
|
|
|
832
884
|
];
|
|
833
885
|
if (plan.focused)
|
|
834
886
|
lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
|
|
887
|
+
if (plan.intercom)
|
|
888
|
+
lines.push(
|
|
889
|
+
` mail: the project's mail session, a new conversation; first: ${
|
|
890
|
+
plan.intercomArgs.length ? plan.intercomArgs.join(' ') : '— (no agent.intercom_args: it waits for a wake)'
|
|
891
|
+
}`,
|
|
892
|
+
);
|
|
835
893
|
for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
|
|
836
894
|
lines.push(
|
|
837
895
|
multiplexer === 'tmux'
|
|
@@ -849,6 +907,11 @@ function renderPlan(
|
|
|
849
907
|
}${focused ? ', focused' : plan.focused ? ', not focused' : ''}`,
|
|
850
908
|
);
|
|
851
909
|
}
|
|
910
|
+
for (const a of aside) {
|
|
911
|
+
lines.push(
|
|
912
|
+
` aside: ${a.pane.session} ${a.pane.paneId} — ${plan.intercom ? 'not the mail session' : 'the mail session'}, left alone`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
852
915
|
}
|
|
853
916
|
return lines.join('\n') + '\n';
|
|
854
917
|
}
|
|
@@ -951,7 +1014,7 @@ function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean
|
|
|
951
1014
|
if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
|
|
952
1015
|
const line = agentLine(plan, plan.args);
|
|
953
1016
|
deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
|
|
954
|
-
deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
|
|
1017
|
+
deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
|
|
955
1018
|
return settle(plan, paneId, deps) === 'ok';
|
|
956
1019
|
}
|
|
957
1020
|
|
|
@@ -996,6 +1059,7 @@ export function remoteAiArgs(input: {
|
|
|
996
1059
|
const flags = [
|
|
997
1060
|
opts.fresh && '--new',
|
|
998
1061
|
opts.focused && '--focused',
|
|
1062
|
+
opts.intercom && '--intercom',
|
|
999
1063
|
opts.restart && '--restart',
|
|
1000
1064
|
opts.detach && '--detach',
|
|
1001
1065
|
opts.dryRun && '--dry-run',
|
|
@@ -1156,10 +1220,11 @@ function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): Co
|
|
|
1156
1220
|
if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
1157
1221
|
const host = plan.host;
|
|
1158
1222
|
const same = sameMachine;
|
|
1223
|
+
// A mail session's conversations are its own: a start of the person's never continues one.
|
|
1159
1224
|
const all =
|
|
1160
1225
|
plan.resumeArgs.length === 0
|
|
1161
1226
|
? []
|
|
1162
|
-
: listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
|
|
1227
|
+
: listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot }).filter((c) => !c.mail);
|
|
1163
1228
|
const root = shellQuote(plan.projectRoot);
|
|
1164
1229
|
// Said from where the person sits: under `vdx ai@<host>` that is not this machine.
|
|
1165
1230
|
const atSeat = same(plan.seat, host);
|
|
@@ -1351,7 +1416,7 @@ interface Prepared {
|
|
|
1351
1416
|
}
|
|
1352
1417
|
|
|
1353
1418
|
/** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
|
|
1354
|
-
function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | number {
|
|
1419
|
+
function prepare(pathArg: string, deps: AiDeps, focused = false, intercom = false): Prepared | number {
|
|
1355
1420
|
const { log } = deps;
|
|
1356
1421
|
|
|
1357
1422
|
const projectRoot = resolveProjectRoot(pathArg);
|
|
@@ -1400,6 +1465,7 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1400
1465
|
host: hostLabel(deps.env),
|
|
1401
1466
|
seat: seatLabel(deps.env),
|
|
1402
1467
|
focused: asFocused,
|
|
1468
|
+
intercom,
|
|
1403
1469
|
});
|
|
1404
1470
|
let plan: LaunchPlan;
|
|
1405
1471
|
try {
|
|
@@ -1411,20 +1477,35 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1411
1477
|
return { projectRoot, environment, profilePath, profileDir, plan, replan };
|
|
1412
1478
|
}
|
|
1413
1479
|
|
|
1414
|
-
/**
|
|
1415
|
-
export
|
|
1416
|
-
|
|
1480
|
+
/** How an agent process was started: `vdx ai --focused`, `vdx ai --intercom`, or neither. */
|
|
1481
|
+
export type AgentMode = 'focused' | 'mail' | 'plain';
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* The mode from the process's environment (`ps -E` on macOS, /proc on Linux). Both marks —
|
|
1485
|
+
* focused: deaf wins, as intercom reads it.
|
|
1486
|
+
*/
|
|
1487
|
+
export function processMode(pid: number): AgentMode {
|
|
1488
|
+
let words: string[];
|
|
1417
1489
|
try {
|
|
1418
1490
|
const environ = `/proc/${pid}/environ`;
|
|
1419
|
-
if (fs.existsSync(environ))
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1491
|
+
if (fs.existsSync(environ)) words = fs.readFileSync(environ, 'utf8').split('\0');
|
|
1492
|
+
else {
|
|
1493
|
+
const out = execFileSync('ps', ['-E', '-ww', '-o', 'command=', '-p', String(pid)], {
|
|
1494
|
+
encoding: 'utf8',
|
|
1495
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1496
|
+
});
|
|
1497
|
+
words = out.split(/\s+/);
|
|
1498
|
+
}
|
|
1425
1499
|
} catch {
|
|
1426
|
-
return
|
|
1500
|
+
return 'plain';
|
|
1427
1501
|
}
|
|
1502
|
+
if (words.includes(`${FOCUSED_ENV_VAR}=1`)) return 'focused';
|
|
1503
|
+
return words.includes(`${INTERCOM_ENV_VAR}=1`) ? 'mail' : 'plain';
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/** Was the process started focused — VDX_FOCUSED=1 in its environment? */
|
|
1507
|
+
export function processFocused(pid: number): boolean {
|
|
1508
|
+
return processMode(pid) === 'focused';
|
|
1428
1509
|
}
|
|
1429
1510
|
|
|
1430
1511
|
/** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
|
|
@@ -1481,9 +1562,23 @@ export function foldersWarning(denied: string[], viaTmux: boolean, fix: string |
|
|
|
1481
1562
|
];
|
|
1482
1563
|
}
|
|
1483
1564
|
|
|
1484
|
-
/**
|
|
1565
|
+
/**
|
|
1566
|
+
* The agent's argv after the command. A mail session's intercom_args go first: its first
|
|
1567
|
+
* prompt after `--dangerously-load-development-channels <servers...>` would be read as a server.
|
|
1568
|
+
*/
|
|
1569
|
+
function launchArgs(plan: LaunchPlan, args: string[]): string[] {
|
|
1570
|
+
return [...plan.intercomArgs, ...args];
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/** The mark of the agent's mode in its environment, if any: `VDX_FOCUSED` or `VDX_INTERCOM`. */
|
|
1574
|
+
function modeEnvVar(plan: LaunchPlan): string | null {
|
|
1575
|
+
return plan.focused ? FOCUSED_ENV_VAR : plan.intercom ? INTERCOM_ENV_VAR : null;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** The agent's command line; its mode's mark goes in front, for the shell in its tmux pane. */
|
|
1485
1579
|
function agentLine(plan: LaunchPlan, args: string[]): string {
|
|
1486
|
-
|
|
1580
|
+
const mark = modeEnvVar(plan);
|
|
1581
|
+
return `${mark ? `${mark}=1 ` : ''}${commandLine(plan.command, launchArgs(plan, args))}`;
|
|
1487
1582
|
}
|
|
1488
1583
|
|
|
1489
1584
|
/** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
|
|
@@ -1504,12 +1599,14 @@ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): P
|
|
|
1504
1599
|
*/
|
|
1505
1600
|
export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
|
|
1506
1601
|
if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
|
|
1507
|
-
// The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1
|
|
1602
|
+
// The hook runs in the agent's environment: a focused session's carries VDX_FOCUSED=1,
|
|
1603
|
+
// a mail session's VDX_INTERCOM=1.
|
|
1508
1604
|
const focused = deps.env[FOCUSED_ENV_VAR] === '1';
|
|
1509
|
-
const
|
|
1605
|
+
const intercom = !focused && deps.env[INTERCOM_ENV_VAR] === '1';
|
|
1606
|
+
const prepared = prepare(pathArg, deps, focused, intercom);
|
|
1510
1607
|
if (typeof prepared === 'number') return prepared;
|
|
1511
1608
|
const { projectRoot, profilePath, plan } = prepared;
|
|
1512
|
-
const vdxAi = `vdx ai${focused ? ' --focused' : ''}`;
|
|
1609
|
+
const vdxAi = `vdx ai${focused ? ' --focused' : intercom ? ' --intercom' : ''}`;
|
|
1513
1610
|
|
|
1514
1611
|
const lines = [
|
|
1515
1612
|
`${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
|
|
@@ -1526,6 +1623,17 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
|
|
|
1526
1623
|
'.',
|
|
1527
1624
|
);
|
|
1528
1625
|
}
|
|
1626
|
+
if (intercom) {
|
|
1627
|
+
// The conversation is known by this line as a mail session's (conversations.ts): keep its opening.
|
|
1628
|
+
lines.push(
|
|
1629
|
+
`${MAIL_LINE_PREFIX} (\`vdx ai --intercom\`): letters and wakes for the project come here, so the user's ` +
|
|
1630
|
+
`own session stays quiet, and nobody sits at this one. When nothing is left for it — right after a check ` +
|
|
1631
|
+
`of its inbox shows nothing to take — it ends itself: \`vdx ai --close\` (the pane closes ${CLOSE_DELAY_S} s ` +
|
|
1632
|
+
`later, so say what was done first). With questions to the user still unanswered: ` +
|
|
1633
|
+
`\`vdx ai --close --waiting <n>\` — the project's other tmux sessions here show the number; a plain ` +
|
|
1634
|
+
`\`--close\` clears it.`,
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1529
1637
|
const own = findOwnAgent(deps.processes(), selfPid, plan.command);
|
|
1530
1638
|
if (!own) {
|
|
1531
1639
|
lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
|
|
@@ -1534,35 +1642,132 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
|
|
|
1534
1642
|
}
|
|
1535
1643
|
const missing = missingArgs(own.args, plan.args);
|
|
1536
1644
|
const why = plan.matched.length ? ` (profile conditions met here: ${plan.matched.join(', ')})` : '';
|
|
1537
|
-
|
|
1645
|
+
// A live session keeps the Claude Code and plugins it started with (vdx-ai, Sidetrack #24).
|
|
1646
|
+
const lag = (deps.lag ?? ((pid, root, command) => machineLag(deps, pid, root, command)))(
|
|
1647
|
+
own.pid,
|
|
1648
|
+
projectRoot,
|
|
1649
|
+
plan.command,
|
|
1650
|
+
);
|
|
1651
|
+
if (missing.length === 0 && !lag) {
|
|
1538
1652
|
lines.push(`✓ this session carries the profile's flags${why}.`);
|
|
1539
1653
|
deps.out(lines.join('\n') + '\n');
|
|
1540
1654
|
return EXIT_OK;
|
|
1541
1655
|
}
|
|
1656
|
+
const problems: string[] = [];
|
|
1657
|
+
if (missing.length > 0) problems.push(`✗ this session runs without ${missing.join(' ')}${why}.`);
|
|
1658
|
+
else lines.push(`✓ this session carries the profile's flags${why}.`);
|
|
1659
|
+
if (lag) problems.push(`✗ ${describeLag(lag)}.`);
|
|
1660
|
+
|
|
1542
1661
|
const root = shellQuote(projectRoot);
|
|
1543
|
-
|
|
1544
|
-
|
|
1662
|
+
const again = [
|
|
1663
|
+
missing.length > 0 ? " with the profile's flags" : '',
|
|
1664
|
+
lag ? `${missing.length > 0 ? ',' : ''} on the installed Claude Code and plugins` : '',
|
|
1665
|
+
].join('');
|
|
1666
|
+
// Plugins alone: /reload-plugins loads them and the conversation goes on in the same process.
|
|
1667
|
+
const reload =
|
|
1668
|
+
missing.length === 0 && lag && !lag.claude
|
|
1669
|
+
? '`/reload-plugins`: it loads the installed plugins and keeps the conversation (Claude Code stays as it is); or '
|
|
1670
|
+
: '';
|
|
1671
|
+
problems.push(
|
|
1672
|
+
`Hand the user the fix — ${reload}` +
|
|
1545
1673
|
(deps.env['TMUX']
|
|
1546
|
-
? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again
|
|
1547
|
-
|
|
1674
|
+
? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
|
|
1675
|
+
// A focused session and the mail session restart into a new conversation.
|
|
1676
|
+
(focused || intercom
|
|
1677
|
+
? ', in a new conversation'
|
|
1678
|
+
: plan.resumeArgs.length
|
|
1679
|
+
? `, resuming the conversation (${plan.resumeArgs.join(' ')})`
|
|
1680
|
+
: '') +
|
|
1548
1681
|
`. Do not run it yourself: it ends this session.`
|
|
1549
1682
|
: `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
|
|
1550
1683
|
);
|
|
1684
|
+
lines.push(problems.join(' '));
|
|
1551
1685
|
deps.out(lines.join('\n') + '\n');
|
|
1552
1686
|
return EXIT_DRIFT;
|
|
1553
1687
|
}
|
|
1554
1688
|
|
|
1689
|
+
/**
|
|
1690
|
+
* `vdx ai --close [--waiting <n>]`: the mail session ends itself — run inside it, by its
|
|
1691
|
+
* agent. Only the pane whose agent is this process's own ancestor and carries VDX_INTERCOM=1
|
|
1692
|
+
* is closed, a few seconds later: the command never ends the person's session. The project's
|
|
1693
|
+
* other tmux sessions here get `@vdx_waiting` = n, the questions to the person still
|
|
1694
|
+
* unanswered — the mail session's tab is about to go; without `--waiting`, none, and the
|
|
1695
|
+
* mark is cleared: the last mail session's word holds.
|
|
1696
|
+
*/
|
|
1697
|
+
export function runAiClose(pathArg: string, waiting: number, deps: AiDeps, selfPid: number = process.pid): number {
|
|
1698
|
+
const { log, tmux } = deps;
|
|
1699
|
+
const prepared = prepare(pathArg, deps, false, true);
|
|
1700
|
+
if (typeof prepared === 'number') return prepared;
|
|
1701
|
+
const { projectRoot, plan } = prepared;
|
|
1702
|
+
const procs = deps.processes();
|
|
1703
|
+
const own = findOwnAgent(procs, selfPid, plan.command);
|
|
1704
|
+
const mailOf = deps.agentMail ?? ((pid: number) => processMode(pid) === 'mail');
|
|
1705
|
+
if (!own || !mailOf(own.pid)) {
|
|
1706
|
+
log(
|
|
1707
|
+
`vdx ai --close: this is not a mail session (\`vdx ai --intercom\`)` +
|
|
1708
|
+
`${own ? '' : ` — no ${plan.command} among the ancestors of this process`}; nothing closed`,
|
|
1709
|
+
);
|
|
1710
|
+
return EXIT_USAGE;
|
|
1711
|
+
}
|
|
1712
|
+
const panes = tmux.panes();
|
|
1713
|
+
const pane = panes.find((p) => processTree(procs, p.panePid).some((x) => x.pid === own.pid));
|
|
1714
|
+
if (!pane) {
|
|
1715
|
+
log('vdx ai --close: this mail session runs outside tmux — nothing to close here; it ends when its agent exits');
|
|
1716
|
+
return EXIT_USAGE;
|
|
1717
|
+
}
|
|
1718
|
+
const others = [
|
|
1719
|
+
...new Set(findAgentPanes(panes, procs, projectRoot, plan.command).map((a) => a.pane.session)),
|
|
1720
|
+
].filter((s) => s !== pane.session);
|
|
1721
|
+
for (const s of others) {
|
|
1722
|
+
// `=name:` — a session option needs the session as a target, `=name` alone is not found.
|
|
1723
|
+
tmux.tryRun(
|
|
1724
|
+
waiting > 0
|
|
1725
|
+
? ['set-option', '-t', `=${s}:`, WAITING_OPTION, String(waiting)]
|
|
1726
|
+
: ['set-option', '-u', '-t', `=${s}:`, WAITING_OPTION],
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
if (waiting > 0)
|
|
1730
|
+
log(
|
|
1731
|
+
others.length
|
|
1732
|
+
? `✓ ${waiting} waiting for the user — shown on ${others.join(', ')}`
|
|
1733
|
+
: `note: ${waiting} waiting for the user, and no other session of this project here to show it`,
|
|
1734
|
+
);
|
|
1735
|
+
if (tmux.tryRun(['run-shell', '-b', '-d', String(CLOSE_DELAY_S), '-C', `kill-pane -t ${pane.paneId}`]) === null) {
|
|
1736
|
+
log(`✗ ${pane.session} ${pane.paneId}: tmux did not take the close — the mail session keeps running`);
|
|
1737
|
+
return EXIT_LAUNCH_FAILED;
|
|
1738
|
+
}
|
|
1739
|
+
log(`✓ ${pane.session} ${pane.paneId}: the mail session closes in ${CLOSE_DELAY_S} s`);
|
|
1740
|
+
return EXIT_OK;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** `this session runs Claude Code 2.1.294 — 2.1.304 is installed; plugins updated since it loaded them: vdm 2.54.0`. */
|
|
1744
|
+
function describeLag(lag: Lag): string {
|
|
1745
|
+
const names = lag.plugins.map((p) => `${p.name} ${p.version}`).join(', ');
|
|
1746
|
+
if (!lag.claude) return `plugins updated since this session loaded them: ${names} — it still runs the ones it loaded`;
|
|
1747
|
+
const claude = `this session runs Claude Code ${lag.claude.runs} — ${lag.claude.installed} is installed`;
|
|
1748
|
+
return lag.plugins.length > 0 ? `${claude}; plugins updated since it loaded them: ${names}` : claude;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1555
1751
|
export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
1556
1752
|
const { log, tmux } = deps;
|
|
1557
1753
|
|
|
1558
|
-
|
|
1754
|
+
if (opts.intercom && opts.focused) {
|
|
1755
|
+
log('vdx ai: --intercom and --focused are different sessions — the mail session, or one nothing wakes; pick one');
|
|
1756
|
+
return EXIT_USAGE;
|
|
1757
|
+
}
|
|
1758
|
+
if (opts.intercom && opts.conversation) {
|
|
1759
|
+
log('vdx ai: --intercom starts the mail session in a new conversation — it takes no --conversation');
|
|
1760
|
+
return EXIT_USAGE;
|
|
1761
|
+
}
|
|
1762
|
+
const prepared = prepare(opts.path, deps, opts.focused === true, opts.intercom === true);
|
|
1559
1763
|
if (typeof prepared === 'number') return prepared;
|
|
1560
1764
|
const { projectRoot, environment, profileDir } = prepared;
|
|
1561
1765
|
let { plan } = prepared;
|
|
1562
1766
|
// The agent started here runs on this machine: it does not inherit where the person sat,
|
|
1563
|
-
// and
|
|
1767
|
+
// and its mode is this start's flag, not the shell vdx runs in.
|
|
1564
1768
|
delete deps.env[SEAT_ENV_VAR];
|
|
1565
1769
|
delete deps.env[FOCUSED_ENV_VAR];
|
|
1770
|
+
delete deps.env[INTERCOM_ENV_VAR];
|
|
1566
1771
|
// Under `vdx ai@<host>` the person is not here: this machine goes by its name.
|
|
1567
1772
|
const atSeat = sameMachine(plan.seat, plan.host);
|
|
1568
1773
|
const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
|
|
@@ -1571,8 +1776,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1571
1776
|
log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
|
|
1572
1777
|
return EXIT_USAGE;
|
|
1573
1778
|
}
|
|
1574
|
-
// A start continues the last conversation unless asked for a new one; a focused session
|
|
1575
|
-
|
|
1779
|
+
// A start continues the last conversation unless asked for a new one; a focused session and
|
|
1780
|
+
// the mail session are new ones.
|
|
1781
|
+
const wantsNew = (opts.fresh || opts.focused === true || plan.intercom) && !opts.conversation;
|
|
1576
1782
|
if (wantsNew) plan = { ...plan, resumeArgs: [] };
|
|
1577
1783
|
|
|
1578
1784
|
let multiplexer = plan.multiplexer;
|
|
@@ -1581,8 +1787,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1581
1787
|
multiplexer = 'none';
|
|
1582
1788
|
}
|
|
1583
1789
|
|
|
1584
|
-
const
|
|
1790
|
+
const agentsHere =
|
|
1585
1791
|
multiplexer === 'tmux' ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command) : [];
|
|
1792
|
+
// The mail session and the person's sessions do not see each other: --intercom is about the
|
|
1793
|
+
// one mail session, every other start leaves it alone — nobody sits at it, and it ends itself.
|
|
1794
|
+
const mailOf = deps.agentMail ?? ((pid: number) => processMode(pid) === 'mail');
|
|
1795
|
+
const running = agentsHere.filter((a) => mailOf(a.proc.pid) === plan.intercom);
|
|
1796
|
+
const aside = agentsHere.filter((a) => !running.includes(a));
|
|
1586
1797
|
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1587
1798
|
const conversationOf = (a: AgentPane) => liveConversationId(deps.env, deps.home, a.proc.pid);
|
|
1588
1799
|
// Which running agent the start is about. A new conversation, or one no agent here is in, starts
|
|
@@ -1590,8 +1801,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1590
1801
|
// one asked for: no second agent in it blindly.
|
|
1591
1802
|
const inConversation = opts.conversation ? running.find((a) => conversationOf(a) === opts.conversation) : undefined;
|
|
1592
1803
|
const unreadable = opts.conversation && !inConversation ? running.find((a) => conversationOf(a) === null) : undefined;
|
|
1804
|
+
// One mail session per project and machine: a running one is the one, never a second next to it.
|
|
1593
1805
|
const alongside =
|
|
1594
|
-
|
|
1806
|
+
!plan.intercom &&
|
|
1807
|
+
running.length > 0 &&
|
|
1808
|
+
!opts.restart &&
|
|
1809
|
+
!inConversation &&
|
|
1810
|
+
!unreadable &&
|
|
1811
|
+
(wantsNew || !!opts.conversation);
|
|
1595
1812
|
let current = inConversation ?? unreadable;
|
|
1596
1813
|
if (!current && !alongside && running.length > 0) {
|
|
1597
1814
|
const picked = pickAgent(running, focusedOf, deps, !opts.dryRun);
|
|
@@ -1611,7 +1828,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1611
1828
|
// What a start continues. A restart keeps the conversation the agent is in.
|
|
1612
1829
|
let choice: ConversationChoice | null = null;
|
|
1613
1830
|
if (!current || opts.restart) {
|
|
1614
|
-
|
|
1831
|
+
// The mail session runs next to the person's agents on any machine by design: nothing to ask.
|
|
1832
|
+
if (plan.intercom) choice = { args: [], lines: [] };
|
|
1833
|
+
else if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
|
|
1615
1834
|
// A restart into a new conversation replaces the agent here, and a new one next to an agent here
|
|
1616
1835
|
// is what was asked for: the other machines are not asked.
|
|
1617
1836
|
else if ((current || alongside) && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
|
|
@@ -1635,7 +1854,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1635
1854
|
: [];
|
|
1636
1855
|
if (opts.dryRun) {
|
|
1637
1856
|
deps.out(
|
|
1638
|
-
renderPlan(
|
|
1857
|
+
renderPlan(
|
|
1858
|
+
plan,
|
|
1859
|
+
multiplexer,
|
|
1860
|
+
running,
|
|
1861
|
+
focusedOf,
|
|
1862
|
+
freeSessionName(plan.sessionName, alongside ? running : []),
|
|
1863
|
+
aside,
|
|
1864
|
+
),
|
|
1639
1865
|
);
|
|
1640
1866
|
for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
|
|
1641
1867
|
for (const l of folders) deps.out(` ${l}\n`);
|
|
@@ -1686,9 +1912,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1686
1912
|
return EXIT_USAGE;
|
|
1687
1913
|
}
|
|
1688
1914
|
for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
|
|
1689
|
-
const
|
|
1915
|
+
const mark = modeEnvVar(plan);
|
|
1916
|
+
const env = mark ? { ...process.env, [mark]: '1' } : undefined;
|
|
1690
1917
|
const startedAt = Date.now();
|
|
1691
|
-
let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], {
|
|
1918
|
+
let res = spawnSync(plan.command, launchArgs(plan, [...plan.args, ...plan.resumeArgs]), {
|
|
1919
|
+
cwd: projectRoot,
|
|
1920
|
+
stdio: 'inherit',
|
|
1921
|
+
env,
|
|
1922
|
+
});
|
|
1692
1923
|
if (at(res.error, 'code') === 'ENOENT') {
|
|
1693
1924
|
log(`vdx ai: \`${plan.command}\` not found on PATH`);
|
|
1694
1925
|
return 127;
|
|
@@ -1696,7 +1927,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1696
1927
|
const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
|
|
1697
1928
|
if (plan.resumeArgs.length > 0 && failedAtOnce) {
|
|
1698
1929
|
log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${agentLine(plan, plan.args)}`);
|
|
1699
|
-
res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit', env });
|
|
1930
|
+
res = spawnSync(plan.command, launchArgs(plan, plan.args), { cwd: projectRoot, stdio: 'inherit', env });
|
|
1700
1931
|
}
|
|
1701
1932
|
return res.status ?? 1;
|
|
1702
1933
|
}
|
|
@@ -1719,20 +1950,20 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1719
1950
|
const unfocused = plan.focused && !runningFocused;
|
|
1720
1951
|
const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
|
|
1721
1952
|
if (!unfocused && missing.length === 0) {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
);
|
|
1953
|
+
const mode = runningFocused ? ', focused' : plan.intercom ? ', the mail session' : '';
|
|
1954
|
+
log(`✓ ${current.pane.session}: already running with the profile${mode} — ${current.proc.args}`);
|
|
1725
1955
|
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1726
1956
|
}
|
|
1727
1957
|
// Drift. In a terminal the person picks: the agent as it runs, or a restart
|
|
1728
1958
|
// with the profile; without one nothing is touched, and the fix is named.
|
|
1729
1959
|
const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
|
|
1960
|
+
const modeFlag = plan.focused ? ' --focused' : plan.intercom ? ' --intercom' : '';
|
|
1730
1961
|
const fix =
|
|
1731
|
-
`vdx ai --restart${
|
|
1962
|
+
`vdx ai --restart${modeFlag}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
|
|
1732
1963
|
(opts.conversation ? ` --conversation ${opts.conversation}` : '');
|
|
1733
1964
|
const effect = plan.resumeArgs.length
|
|
1734
1965
|
? 'stops the running agent, then resumes its conversation'
|
|
1735
|
-
: opts.fresh
|
|
1966
|
+
: opts.fresh || plan.intercom
|
|
1736
1967
|
? 'stops the running agent and starts a new conversation'
|
|
1737
1968
|
: 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
|
|
1738
1969
|
if (!deps.interactive) {
|
|
@@ -1763,7 +1994,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1763
1994
|
if (current) {
|
|
1764
1995
|
const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
|
|
1765
1996
|
log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
|
|
1766
|
-
tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
|
|
1997
|
+
tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell, !plan.intercom));
|
|
1767
1998
|
return finish(
|
|
1768
1999
|
settleResumed(plan, current.pane.paneId, deps),
|
|
1769
2000
|
current.pane.session,
|
|
@@ -1774,8 +2005,10 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1774
2005
|
}
|
|
1775
2006
|
|
|
1776
2007
|
if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
|
|
2008
|
+
if (plan.intercom && plan.intercomArgs.length === 0)
|
|
2009
|
+
log('note: the profile has no agent.intercom_args — the mail session starts idle and waits for a wake');
|
|
1777
2010
|
const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
|
|
1778
|
-
const cmd = tmuxShellCommand(line, deps.shell);
|
|
2011
|
+
const cmd = tmuxShellCommand(line, deps.shell, !plan.intercom);
|
|
1779
2012
|
const session = freeSessionName(plan.sessionName, running);
|
|
1780
2013
|
const reuse = tmux.hasSession(session);
|
|
1781
2014
|
const paneId = reuse ? tmux.newWindow(session, projectRoot, cmd) : tmux.newSession(session, projectRoot, cmd);
|
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,18 +90,35 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
|
|
|
90
90
|
rules marked \`wakes: true\` (a channel flag) are left out. Back
|
|
91
91
|
to it: --focused --conversation <id>. A running agent keeps its
|
|
92
92
|
mode: plain vdx ai attaches to it as it is
|
|
93
|
+
--intercom the project's mail session: a new conversation in a tmux
|
|
94
|
+
session of its own (<name>-mail), one per project and machine —
|
|
95
|
+
a running one is attached to, never a second. The agent, its
|
|
96
|
+
hooks and MCP servers get VDX_INTERCOM=1: letters and wakes for
|
|
97
|
+
the project go there, and your own session stays quiet; the
|
|
98
|
+
profile's \`wakes\` rules stay. It starts with agent.intercom_args
|
|
99
|
+
(its first prompt). Every other start leaves it alone, and its
|
|
100
|
+
conversations are not continued unless named with --conversation.
|
|
101
|
+
Started by the agent that has a letter and nobody to wake:
|
|
102
|
+
vdx ai --intercom --detach <project>
|
|
103
|
+
--close [--waiting <n>]
|
|
104
|
+
inside the mail session: it ends itself — its pane closes 2 s
|
|
105
|
+
later. --waiting <n>: n questions to you still unanswered; the
|
|
106
|
+
project's other tmux sessions here get @vdx_waiting = n (without
|
|
107
|
+
it the mark is cleared). Anywhere else: refused, nothing closed
|
|
93
108
|
--conversation <id>
|
|
94
109
|
continue this Claude Code conversation: attach to the agent in
|
|
95
110
|
it, or start one next to the agents running here
|
|
96
111
|
--restart restart the running agent in its pane — applies a changed profile;
|
|
97
|
-
with --new, --focused or --conversation, it is replaced by that
|
|
112
|
+
with --new, --focused or --conversation, it is replaced by that;
|
|
113
|
+
with --intercom, the mail session gets a new conversation.
|
|
98
114
|
Without it, an agent running without the profile's flags is
|
|
99
115
|
a question in a terminal: Enter attaches to it as is, r
|
|
100
116
|
restarts it, q leaves it
|
|
101
117
|
--detach start without attaching; print the command that attaches
|
|
102
118
|
--dry-run print the plan and the project's running agents; start nothing
|
|
103
|
-
--check for the agent's own session: how the agent here is launched,
|
|
104
|
-
whether this session carries the profile's flags
|
|
119
|
+
--check for the agent's own session: how the agent here is launched,
|
|
120
|
+
whether this session carries the profile's flags, and whether it
|
|
121
|
+
runs the installed Claude Code and plugins (the vdx plugin's
|
|
105
122
|
SessionStart hook); prints nothing without a profile; exit 3 on drift
|
|
106
123
|
-h, --help this help
|
|
107
124
|
|
|
@@ -165,8 +182,9 @@ mise.toml is overwritten only with --force.
|
|
|
165
182
|
Ctrl-C there stops only the wait).
|
|
166
183
|
Nothing in the package's directory changed since the tag of its version: the
|
|
167
184
|
same package under a new number — at a terminal a question, Enter releases nothing.
|
|
185
|
+
Without a terminal it releases nothing: npm would ask for an OTP nobody sees.
|
|
168
186
|
--dry-run runs the pre-flight and prints the plan; --force goes on past a failed
|
|
169
|
-
pre-flight; --no-push leaves the commit and the tag local.
|
|
187
|
+
pre-flight, and without a terminal; --no-push leaves the commit and the tag local.
|
|
170
188
|
`,
|
|
171
189
|
bools: ['dry-run', 'force', 'no-push'],
|
|
172
190
|
positionals: 1,
|
|
@@ -217,11 +235,12 @@ Exit 2 when something is missing.
|
|
|
217
235
|
positionals: 1,
|
|
218
236
|
},
|
|
219
237
|
ai: {
|
|
220
|
-
usage:
|
|
238
|
+
usage:
|
|
239
|
+
'vdx ai[@host] [project_path] [--new | --conversation <id>] [--focused | --intercom] [--restart] [--detach] [--dry-run]',
|
|
221
240
|
help: AI_HELP,
|
|
222
241
|
// `--resume` asked to continue in 0.12; continuing is the default now.
|
|
223
|
-
bools: ['new', 'focused', 'restart', 'resume', 'detach', 'dry-run', 'check'],
|
|
224
|
-
values: ['conversation'],
|
|
242
|
+
bools: ['new', 'focused', 'intercom', 'restart', 'resume', 'detach', 'dry-run', 'check', 'close'],
|
|
243
|
+
values: ['conversation', 'waiting'],
|
|
225
244
|
positionals: 1,
|
|
226
245
|
},
|
|
227
246
|
...Object.fromEntries(
|
|
@@ -411,6 +430,18 @@ function cmdPublish(opts: ParsedArgs): void {
|
|
|
411
430
|
const bump = bumpArg as BumpKind;
|
|
412
431
|
const projectRoot = process.cwd();
|
|
413
432
|
|
|
433
|
+
// npm asks for an OTP or a confirmation in the browser, after the bump: without a
|
|
434
|
+
// terminal nobody answers. Claude Code's Bash tool has none, and `!` there moves a
|
|
435
|
+
// command past 120 s to the background (publish-npm-outage-preflight, 2026-10-22).
|
|
436
|
+
if (!opts.flags['dry-run'] && !opts.flags.force && process.stdin.isTTY !== true) {
|
|
437
|
+
process.stderr.write(
|
|
438
|
+
`vdx publish: no terminal here — npm asks for a one-time password or a confirmation in the browser, ` +
|
|
439
|
+
`and nobody could answer. Run it in your own terminal: vdx publish ${bump}. ` +
|
|
440
|
+
`--dry-run shows the plan; --force releases without a terminal (an automation token)\n`,
|
|
441
|
+
);
|
|
442
|
+
process.exit(2);
|
|
443
|
+
}
|
|
444
|
+
|
|
414
445
|
const manifest = loadManifest(projectRoot);
|
|
415
446
|
const overrides = loadOverrides(projectRoot);
|
|
416
447
|
const rubric = loadRubric(DEFAULT_RUBRIC);
|
|
@@ -601,6 +632,27 @@ function cmdAi(opts: ParsedArgs): void {
|
|
|
601
632
|
}
|
|
602
633
|
process.exit(runAiCheck(opts.positionals[0] ?? '.', defaultDeps()));
|
|
603
634
|
}
|
|
635
|
+
if (opts.flags.close === true || opts.flags.waiting !== undefined) {
|
|
636
|
+
// Run inside the mail session, about that session: nothing to start, no other machine.
|
|
637
|
+
const others = Object.keys(opts.flags).filter((f) => f !== 'close' && f !== 'waiting');
|
|
638
|
+
if (opts.flags.close !== true || others.length > 0 || opts.cmd !== 'ai') {
|
|
639
|
+
process.stderr.write(
|
|
640
|
+
opts.flags.close !== true
|
|
641
|
+
? 'vdx ai: --waiting goes with --close\n'
|
|
642
|
+
: `vdx ai: --close takes only the project path and --waiting (not ${opts.cmd !== 'ai' ? opts.cmd : `--${others.join(', --')}`})\n`,
|
|
643
|
+
);
|
|
644
|
+
process.exit(2);
|
|
645
|
+
}
|
|
646
|
+
const raw = opts.flags.waiting;
|
|
647
|
+
const waiting = raw === undefined ? 0 : typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
648
|
+
if (Number.isNaN(waiting)) {
|
|
649
|
+
process.stderr.write(
|
|
650
|
+
`vdx ai: --waiting takes the number of questions to the user (0, 1, 2…), not "${String(raw)}"\n`,
|
|
651
|
+
);
|
|
652
|
+
process.exit(2);
|
|
653
|
+
}
|
|
654
|
+
process.exit(runAiClose(opts.positionals[0] ?? '.', waiting, defaultDeps()));
|
|
655
|
+
}
|
|
604
656
|
const conversation = typeof opts.flags.conversation === 'string' ? opts.flags.conversation : undefined;
|
|
605
657
|
if (conversation !== undefined && !isConversationId(conversation)) {
|
|
606
658
|
process.stderr.write(`vdx ai: --conversation takes a conversation id (a UUID), not "${conversation}"\n`);
|
|
@@ -618,6 +670,7 @@ function cmdAi(opts: ParsedArgs): void {
|
|
|
618
670
|
detach: opts.flags.detach === true,
|
|
619
671
|
dryRun: opts.flags['dry-run'] === true,
|
|
620
672
|
focused: opts.flags.focused === true,
|
|
673
|
+
intercom: opts.flags.intercom === true,
|
|
621
674
|
};
|
|
622
675
|
const deps = { ...defaultDeps(), version: readCliVersion() };
|
|
623
676
|
// `ai@m3`: the same command on another machine. Its own label runs here.
|
package/src/init.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { at } from './narrow.ts';
|
|
|
6
6
|
export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
7
7
|
export type Verb = (typeof STANDARD_VERBS)[number];
|
|
8
8
|
|
|
9
|
-
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.
|
|
9
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.6.1';
|
|
10
10
|
|
|
11
11
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
12
12
|
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
package/src/publish.ts
CHANGED
|
@@ -199,6 +199,17 @@ export function checkNpmAuth(registry: string | null, npm: NpmRunner): Preflight
|
|
|
199
199
|
fix,
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
|
+
// A 5xx is the registry's trouble, not the login: 0.35.0 met E503 during an npm
|
|
203
|
+
// incident on 2026-10-22, and the line read as a failed login.
|
|
204
|
+
const outage = /\bE(5\d\d)\b/.exec(stderr);
|
|
205
|
+
if (outage) {
|
|
206
|
+
const npmjs = !registry || registry.includes('registry.npmjs.org');
|
|
207
|
+
return {
|
|
208
|
+
name: 'npm-auth',
|
|
209
|
+
ok: false,
|
|
210
|
+
message: `${where} is not answering (E${outage[1]}) — trouble on its side, not the login${npmjs ? '; status: https://status.npmjs.org' : ''}. Run vdx publish again once it answers`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
202
213
|
return {
|
|
203
214
|
name: 'npm-auth',
|
|
204
215
|
ok: false,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { claudeConfigDir, projectDirName } from './conversations.ts';
|
|
4
|
+
import { at, dict, list, str } from './narrow.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a running Claude Code session lags behind: a live session keeps the
|
|
8
|
+
* Claude Code and the plugins it started with, and takes new ones only when it
|
|
9
|
+
* starts again or by `/reload-plugins` (docs/tasks/vdx-ai, Sidetrack #24).
|
|
10
|
+
* Read from Claude Code's own files — its session file, `installed_plugins.json`,
|
|
11
|
+
* the settings that enable a plugin, the transcript — which are its internals,
|
|
12
|
+
* not an API.
|
|
13
|
+
*/
|
|
14
|
+
export interface Lag {
|
|
15
|
+
/** Claude Code: the version the session runs and the one installed now; null when they agree. */
|
|
16
|
+
claude: { runs: string; installed: string } | null;
|
|
17
|
+
/** Enabled plugins updated after the session loaded its plugins: their name and the installed version. */
|
|
18
|
+
plugins: Array<{ name: string; version: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LagInput {
|
|
22
|
+
env: NodeJS.ProcessEnv;
|
|
23
|
+
home: string;
|
|
24
|
+
/** The session's Claude Code process. */
|
|
25
|
+
pid: number;
|
|
26
|
+
projectRoot: string;
|
|
27
|
+
/** `claude --version` of the Claude Code a new start would run; null when it does not answer. */
|
|
28
|
+
installedClaude: () => string | null;
|
|
29
|
+
/** Default `Date.now()`. */
|
|
30
|
+
now?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long what a session loaded may stand before newer counts as a lag. On
|
|
35
|
+
* 2026-10-21 all 14 live sessions on lft were behind, an echelon release minutes
|
|
36
|
+
* old among them; the harm is a session that runs old code for days (twelve, in
|
|
37
|
+
* ai-dev-plugins' count). A day lets an update arrive without every session's
|
|
38
|
+
* next compaction calling for a reload.
|
|
39
|
+
*/
|
|
40
|
+
const LAG_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/** What the session lags behind; null when nothing — or when its session file is not here to say. */
|
|
43
|
+
export function sessionLag(input: LagInput): Lag | null {
|
|
44
|
+
const now = input.now ?? Date.now();
|
|
45
|
+
const config = claudeConfigDir(input.env, input.home);
|
|
46
|
+
const session = readJson(path.join(config, 'sessions', `${input.pid}.json`));
|
|
47
|
+
const startedAt = at(session, 'startedAt');
|
|
48
|
+
if (typeof startedAt !== 'number' || now - startedAt < LAG_AFTER_MS) return null;
|
|
49
|
+
|
|
50
|
+
const runs = str(at(session, 'version'));
|
|
51
|
+
const installed = versionOf(input.installedClaude());
|
|
52
|
+
const claude = runs && installed && newer(installed, runs) ? { runs, installed } : null;
|
|
53
|
+
|
|
54
|
+
let plugins = updatedPlugins(config, input.projectRoot).filter((p) => p.at > startedAt);
|
|
55
|
+
if (plugins.length > 0) {
|
|
56
|
+
// A /reload-plugins loaded what was installed then: the transcript keeps when (its "Reloaded: …" line).
|
|
57
|
+
const sessionId = str(at(session, 'sessionId'));
|
|
58
|
+
const reloaded = sessionId ? lastReload(config, input.projectRoot, sessionId) : null;
|
|
59
|
+
if (reloaded !== null) plugins = now - reloaded < LAG_AFTER_MS ? [] : plugins.filter((p) => p.at > reloaded);
|
|
60
|
+
}
|
|
61
|
+
if (!claude && plugins.length === 0) return null;
|
|
62
|
+
return { claude, plugins: plugins.map(({ name, version }) => ({ name, version })) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Enabled plugins and when each was last updated (ms) — the user's and this project's. */
|
|
66
|
+
function updatedPlugins(config: string, projectRoot: string): Array<{ name: string; version: string; at: number }> {
|
|
67
|
+
const records = dict(at(readJson(path.join(config, 'plugins', 'installed_plugins.json')), 'plugins')) ?? {};
|
|
68
|
+
// The nearest settings that name a plugin decide: the project's local, the project's, the user's.
|
|
69
|
+
const layers = [
|
|
70
|
+
path.join(projectRoot, '.claude', 'settings.local.json'),
|
|
71
|
+
path.join(projectRoot, '.claude', 'settings.json'),
|
|
72
|
+
path.join(config, 'settings.json'),
|
|
73
|
+
].map((f) => dict(at(readJson(f), 'enabledPlugins')) ?? {});
|
|
74
|
+
const out: Array<{ name: string; version: string; at: number }> = [];
|
|
75
|
+
for (const [key, entries] of Object.entries(records)) {
|
|
76
|
+
if (layers.map((l) => l[key]).find((v) => typeof v === 'boolean') !== true) continue;
|
|
77
|
+
const entry = list(entries).find((e) => at(e, 'scope') === 'user' || at(e, 'projectPath') === projectRoot);
|
|
78
|
+
const when = Date.parse(str(at(entry, 'lastUpdated')) ?? '');
|
|
79
|
+
const version = str(at(entry, 'version'));
|
|
80
|
+
if (Number.isNaN(when) || !version) continue;
|
|
81
|
+
out.push({ name: key.split('@')[0] ?? key, version, at: when });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** When the session last ran /reload-plugins (ms), from its transcript; null — never, or no transcript here. */
|
|
87
|
+
function lastReload(config: string, projectRoot: string, sessionId: string): number | null {
|
|
88
|
+
const projects = path.join(config, 'projects');
|
|
89
|
+
let file = path.join(projects, projectDirName(projectRoot), `${sessionId}.jsonl`);
|
|
90
|
+
if (!fs.existsSync(file)) {
|
|
91
|
+
// The transcript sits under the directory the session started in, which may be another.
|
|
92
|
+
const dir = safeReaddir(projects).find((d) => fs.existsSync(path.join(projects, d, `${sessionId}.jsonl`)));
|
|
93
|
+
if (dir === undefined) return null;
|
|
94
|
+
file = path.join(projects, dir, `${sessionId}.jsonl`);
|
|
95
|
+
}
|
|
96
|
+
let text: string;
|
|
97
|
+
try {
|
|
98
|
+
text = fs.readFileSync(file, 'utf8');
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
let last: number | null = null;
|
|
103
|
+
for (const line of text.split('\n')) {
|
|
104
|
+
if (!line.includes('"local_command"') || !line.includes('Reloaded:')) continue;
|
|
105
|
+
let entry: unknown;
|
|
106
|
+
try {
|
|
107
|
+
entry = JSON.parse(line);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (at(entry, 'type') !== 'system' || at(entry, 'subtype') !== 'local_command') continue;
|
|
112
|
+
if (!/<local-command-stdout>Reloaded:/.test(str(at(entry, 'content')) ?? '')) continue;
|
|
113
|
+
const when = Date.parse(str(at(entry, 'timestamp')) ?? '');
|
|
114
|
+
if (!Number.isNaN(when) && (last === null || when > last)) last = when;
|
|
115
|
+
}
|
|
116
|
+
return last;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `2.1.304 (Claude Code)` → `2.1.304`. */
|
|
120
|
+
function versionOf(text: string | null): string | null {
|
|
121
|
+
return text?.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether version `a` is newer than `b`, part by part. */
|
|
125
|
+
function newer(a: string, b: string): boolean {
|
|
126
|
+
const pa = a.split('.').map(Number);
|
|
127
|
+
const pb = b.split('.').map(Number);
|
|
128
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
129
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
130
|
+
if (d !== 0) return d > 0;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readJson(file: string): unknown {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function safeReaddir(dir: string): string[] {
|
|
144
|
+
try {
|
|
145
|
+
return fs.readdirSync(dir);
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|