@vodmal/vdx-cli 0.34.0 → 0.35.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 +1 -1
- package/package.json +1 -1
- package/src/ai.ts +56 -4
- package/src/index.ts +3 -2
- package/src/session-versions.ts +149 -0
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ vdx doctor --fix # write the profile's personal hooks (git.ho
|
|
|
55
55
|
|
|
56
56
|
# Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
|
|
57
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 --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
|
|
58
|
+
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
59
|
|
|
60
60
|
# What a command does and takes; nothing runs (ai: also where launch flags live)
|
|
61
61
|
vdx <command> --help
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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/src/ai.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
runsInProject,
|
|
30
30
|
} from './conversations.ts';
|
|
31
31
|
import { at, errorMessage, isStringList, str } from './narrow.ts';
|
|
32
|
+
import { type Lag, sessionLag } from './session-versions.ts';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* `vdx ai` — start the person's agent in a project, with the flags their
|
|
@@ -749,6 +750,28 @@ export interface AiDeps {
|
|
|
749
750
|
ssh?: (args: string[], capture: boolean) => { status: number; stdout: string } | null;
|
|
750
751
|
/** Claude Code processes alive on this machine; read from its session files when not given. */
|
|
751
752
|
liveHere?: () => LiveSession[];
|
|
753
|
+
/** What the agent process `pid` lags behind; read from Claude Code's files when not given. */
|
|
754
|
+
lag?: (pid: number, projectRoot: string, command: string) => Lag | null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function machineLag(deps: AiDeps, pid: number, projectRoot: string, command: string): Lag | null {
|
|
758
|
+
return sessionLag({
|
|
759
|
+
env: deps.env,
|
|
760
|
+
home: deps.home,
|
|
761
|
+
pid,
|
|
762
|
+
projectRoot,
|
|
763
|
+
installedClaude: () => {
|
|
764
|
+
try {
|
|
765
|
+
return execFileSync(command, ['--version'], {
|
|
766
|
+
encoding: 'utf8',
|
|
767
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
768
|
+
timeout: 5_000,
|
|
769
|
+
});
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
});
|
|
752
775
|
}
|
|
753
776
|
|
|
754
777
|
function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
|
|
@@ -1534,24 +1557,53 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
|
|
|
1534
1557
|
}
|
|
1535
1558
|
const missing = missingArgs(own.args, plan.args);
|
|
1536
1559
|
const why = plan.matched.length ? ` (profile conditions met here: ${plan.matched.join(', ')})` : '';
|
|
1537
|
-
|
|
1560
|
+
// A live session keeps the Claude Code and plugins it started with (vdx-ai, Sidetrack #24).
|
|
1561
|
+
const lag = (deps.lag ?? ((pid, root, command) => machineLag(deps, pid, root, command)))(
|
|
1562
|
+
own.pid,
|
|
1563
|
+
projectRoot,
|
|
1564
|
+
plan.command,
|
|
1565
|
+
);
|
|
1566
|
+
if (missing.length === 0 && !lag) {
|
|
1538
1567
|
lines.push(`✓ this session carries the profile's flags${why}.`);
|
|
1539
1568
|
deps.out(lines.join('\n') + '\n');
|
|
1540
1569
|
return EXIT_OK;
|
|
1541
1570
|
}
|
|
1571
|
+
const problems: string[] = [];
|
|
1572
|
+
if (missing.length > 0) problems.push(`✗ this session runs without ${missing.join(' ')}${why}.`);
|
|
1573
|
+
else lines.push(`✓ this session carries the profile's flags${why}.`);
|
|
1574
|
+
if (lag) problems.push(`✗ ${describeLag(lag)}.`);
|
|
1575
|
+
|
|
1542
1576
|
const root = shellQuote(projectRoot);
|
|
1543
|
-
|
|
1544
|
-
|
|
1577
|
+
const again = [
|
|
1578
|
+
missing.length > 0 ? " with the profile's flags" : '',
|
|
1579
|
+
lag ? `${missing.length > 0 ? ',' : ''} on the installed Claude Code and plugins` : '',
|
|
1580
|
+
].join('');
|
|
1581
|
+
// Plugins alone: /reload-plugins loads them and the conversation goes on in the same process.
|
|
1582
|
+
const reload =
|
|
1583
|
+
missing.length === 0 && lag && !lag.claude
|
|
1584
|
+
? '`/reload-plugins`: it loads the installed plugins and keeps the conversation (Claude Code stays as it is); or '
|
|
1585
|
+
: '';
|
|
1586
|
+
problems.push(
|
|
1587
|
+
`Hand the user the fix — ${reload}` +
|
|
1545
1588
|
(deps.env['TMUX']
|
|
1546
|
-
? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again
|
|
1589
|
+
? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
|
|
1547
1590
|
(plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
|
|
1548
1591
|
`. Do not run it yourself: it ends this session.`
|
|
1549
1592
|
: `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
|
|
1550
1593
|
);
|
|
1594
|
+
lines.push(problems.join(' '));
|
|
1551
1595
|
deps.out(lines.join('\n') + '\n');
|
|
1552
1596
|
return EXIT_DRIFT;
|
|
1553
1597
|
}
|
|
1554
1598
|
|
|
1599
|
+
/** `this session runs Claude Code 2.1.294 — 2.1.304 is installed; plugins updated since it loaded them: vdm 2.54.0`. */
|
|
1600
|
+
function describeLag(lag: Lag): string {
|
|
1601
|
+
const names = lag.plugins.map((p) => `${p.name} ${p.version}`).join(', ');
|
|
1602
|
+
if (!lag.claude) return `plugins updated since this session loaded them: ${names} — it still runs the ones it loaded`;
|
|
1603
|
+
const claude = `this session runs Claude Code ${lag.claude.runs} — ${lag.claude.installed} is installed`;
|
|
1604
|
+
return lag.plugins.length > 0 ? `${claude}; plugins updated since it loaded them: ${names}` : claude;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1555
1607
|
export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
1556
1608
|
const { log, tmux } = deps;
|
|
1557
1609
|
|
package/src/index.ts
CHANGED
|
@@ -100,8 +100,9 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
|
|
|
100
100
|
restarts it, q leaves it
|
|
101
101
|
--detach start without attaching; print the command that attaches
|
|
102
102
|
--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
|
|
103
|
+
--check for the agent's own session: how the agent here is launched,
|
|
104
|
+
whether this session carries the profile's flags, and whether it
|
|
105
|
+
runs the installed Claude Code and plugins (the vdx plugin's
|
|
105
106
|
SessionStart hook); prints nothing without a profile; exit 3 on drift
|
|
106
107
|
-h, --help this help
|
|
107
108
|
|
|
@@ -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
|
+
}
|