@vodmal/vdx-cli 0.24.0 → 0.25.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 +3 -2
- package/package.json +5 -1
- package/rubric/vdx-rubric.yaml +32 -24
- package/src/ai.ts +199 -53
- package/src/audit.ts +19 -9
- package/src/author.ts +27 -8
- package/src/clone-checks.ts +13 -8
- package/src/conversations.ts +23 -4
- package/src/doctor.ts +9 -11
- package/src/evaluator.ts +1 -1
- package/src/facts.ts +51 -11
- package/src/gha.ts +4 -1
- package/src/hooks.ts +47 -12
- package/src/index.ts +34 -28
- package/src/init.ts +14 -50
- package/src/mcp-server.ts +2 -5
- package/src/personal-hooks.ts +16 -5
- package/src/predicates.ts +2 -5
- package/src/publish.ts +11 -6
- package/src/report.ts +1 -3
- package/src/run.ts +2 -6
- package/src/scoring.ts +2 -8
- package/src/vocabulary.ts +7 -2
package/README.md
CHANGED
|
@@ -77,8 +77,9 @@ vdx test # → mise run test (which calls `vitest run` or whatever wa
|
|
|
77
77
|
# 3. Ship a new minor release of a Node lib
|
|
78
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
|
-
# 4. Start the agent here, continuing its last conversation (--new: a new one
|
|
81
|
-
# another machine's conversation is continued on
|
|
80
|
+
# 4. Start the agent here, continuing its last conversation (--new: a new one,
|
|
81
|
+
# next to a running agent; another machine's conversation is continued on
|
|
82
|
+
# that machine);
|
|
82
83
|
# re-run to attach; an agent without the profile's flags is a question:
|
|
83
84
|
# Enter attaches as is, r restarts it; --restart applies a changed profile
|
|
84
85
|
vdx ai
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"audit": "tsx src/index.ts audit",
|
|
40
40
|
"smoke": "bash smoke.sh",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
|
+
"format:check": "prettier --check .",
|
|
43
|
+
"check": "npm run typecheck && npm run format:check",
|
|
44
|
+
"fix": "prettier --write .",
|
|
42
45
|
"test": "vitest run",
|
|
43
46
|
"test:unit": "vitest run tests/unit",
|
|
44
47
|
"coverage": "vitest run --coverage"
|
|
@@ -58,6 +61,7 @@
|
|
|
58
61
|
"@types/marked-terminal": "^6.1.1",
|
|
59
62
|
"@types/node": "^22.0.0",
|
|
60
63
|
"@vitest/coverage-v8": "^4.1.7",
|
|
64
|
+
"prettier": "^3.9.10",
|
|
61
65
|
"typescript": "^5.6.0",
|
|
62
66
|
"vitest": "^4.1.7"
|
|
63
67
|
},
|
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.4.0"
|
|
15
15
|
description: "Personal maturity rubric for multi-stack projects (vodmal)"
|
|
16
16
|
owner: vodmal
|
|
17
17
|
homepage: "https://github.com/vodmal/vdx-rubric"
|
|
@@ -35,7 +35,7 @@ axes:
|
|
|
35
35
|
|
|
36
36
|
- id: lifecycle-interface
|
|
37
37
|
class: critical
|
|
38
|
-
description: "Стандартный словарь глаголов up/down/build/test/check/fix"
|
|
38
|
+
description: "Стандартный словарь глаголов up/down/build/test/check/fix (up/down — не у библиотек)"
|
|
39
39
|
storage: level
|
|
40
40
|
default_target: L4
|
|
41
41
|
fact_sources: [mise.toml, composer.json, package.json, Makefile]
|
|
@@ -59,14 +59,39 @@ axes:
|
|
|
59
59
|
- has_task: check
|
|
60
60
|
- has_task: fix
|
|
61
61
|
L3:
|
|
62
|
+
# v1.4.0: up/down — глаголы приложения. Библиотека (lib-intent, D12)
|
|
63
|
+
# выпускается, а не поднимается: от неё L3 требует только остальные четыре.
|
|
62
64
|
requires:
|
|
63
65
|
all_of:
|
|
64
|
-
- has_task: up
|
|
65
|
-
- has_task: down
|
|
66
66
|
- has_task: build
|
|
67
67
|
- has_task: test
|
|
68
68
|
- has_task: check
|
|
69
69
|
- has_task: fix
|
|
70
|
+
- any_of:
|
|
71
|
+
# lib-intent — проект заявляет, что публикует артефакт (O35).
|
|
72
|
+
# Одно определение на сет: release-artifact.applies_when — ссылка сюда.
|
|
73
|
+
- &lib_intent
|
|
74
|
+
any_of:
|
|
75
|
+
# Node-сторона: НЕ private И есть хоть один entry-point/publish-signal.
|
|
76
|
+
- all_of:
|
|
77
|
+
- has_file: package.json
|
|
78
|
+
- not:
|
|
79
|
+
config_value: { path: package.json, jsonpath: "private", equals: true }
|
|
80
|
+
- any_of:
|
|
81
|
+
- config_value: { path: package.json, jsonpath: "publishConfig", op: present }
|
|
82
|
+
- config_value: { path: package.json, jsonpath: "bin", op: present }
|
|
83
|
+
- config_value: { path: package.json, jsonpath: "main", op: present }
|
|
84
|
+
- config_value: { path: package.json, jsonpath: "exports", op: present }
|
|
85
|
+
- config_value: { path: package.json, jsonpath: "module", op: present }
|
|
86
|
+
# PHP-сторона: composer.json есть, name задан, type ≠ project (project = app).
|
|
87
|
+
- all_of:
|
|
88
|
+
- has_file: composer.json
|
|
89
|
+
- config_value: { path: composer.json, jsonpath: "name", op: present }
|
|
90
|
+
- not:
|
|
91
|
+
config_value: { path: composer.json, jsonpath: "type", op: equals, equals: "project" }
|
|
92
|
+
- all_of:
|
|
93
|
+
- has_task: up
|
|
94
|
+
- has_task: down
|
|
70
95
|
L4:
|
|
71
96
|
requires:
|
|
72
97
|
any_of:
|
|
@@ -564,26 +589,9 @@ axes:
|
|
|
564
589
|
applies_to: [node, php, ruby, python]
|
|
565
590
|
# O35 (v0.3.1): ось применяется только если проект декларирует намерение
|
|
566
591
|
# публиковать артефакт. Чистые app'ы (private:true в package.json или
|
|
567
|
-
# composer type:project) → excluded.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
# Node-сторона: НЕ private И есть хоть один entry-point/publish-signal.
|
|
571
|
-
- all_of:
|
|
572
|
-
- has_file: package.json
|
|
573
|
-
- not:
|
|
574
|
-
config_value: { path: package.json, jsonpath: "private", equals: true }
|
|
575
|
-
- any_of:
|
|
576
|
-
- config_value: { path: package.json, jsonpath: "publishConfig", op: present }
|
|
577
|
-
- config_value: { path: package.json, jsonpath: "bin", op: present }
|
|
578
|
-
- config_value: { path: package.json, jsonpath: "main", op: present }
|
|
579
|
-
- config_value: { path: package.json, jsonpath: "exports", op: present }
|
|
580
|
-
- config_value: { path: package.json, jsonpath: "module", op: present }
|
|
581
|
-
# PHP-сторона: composer.json есть, name задан, type ≠ project (project = app).
|
|
582
|
-
- all_of:
|
|
583
|
-
- has_file: composer.json
|
|
584
|
-
- config_value: { path: composer.json, jsonpath: "name", op: present }
|
|
585
|
-
- not:
|
|
586
|
-
config_value: { path: composer.json, jsonpath: "type", op: equals, equals: "project" }
|
|
592
|
+
# composer type:project) → excluded. Определение lib-intent — в
|
|
593
|
+
# lifecycle-interface.L3 (якорь, v1.4.0).
|
|
594
|
+
applies_when: *lib_intent
|
|
587
595
|
storage: level
|
|
588
596
|
default_target: L4
|
|
589
597
|
fact_sources:
|
package/src/ai.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Predicate } from './rubric.ts';
|
|
|
12
12
|
import {
|
|
13
13
|
type Conversation,
|
|
14
14
|
describeConversation,
|
|
15
|
+
describeAge,
|
|
15
16
|
describeLive,
|
|
16
17
|
isMachineLabel,
|
|
17
18
|
LIVE_SESSIONS_SCRIPT,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
listConversations,
|
|
21
22
|
liveConversationId,
|
|
22
23
|
liveSessionsHere,
|
|
24
|
+
liveStartedAt,
|
|
23
25
|
machineLine,
|
|
24
26
|
type MachineReport,
|
|
25
27
|
machineOf,
|
|
@@ -221,10 +223,14 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
221
223
|
if (session.machines !== undefined) {
|
|
222
224
|
if (!isStringList(session.machines)) fail('session.machines', 'must be a list of machine names');
|
|
223
225
|
session.machines.forEach((m, i) => {
|
|
224
|
-
if (!isMachineLabel(m))
|
|
226
|
+
if (!isMachineLabel(m))
|
|
227
|
+
fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
|
|
225
228
|
});
|
|
226
229
|
}
|
|
227
|
-
if (
|
|
230
|
+
if (
|
|
231
|
+
session.folders_fix !== undefined &&
|
|
232
|
+
(typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')
|
|
233
|
+
) {
|
|
228
234
|
fail('session.folders_fix', 'must be a non-empty string');
|
|
229
235
|
}
|
|
230
236
|
}
|
|
@@ -250,7 +256,8 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
250
256
|
if (events.length === 0 || !events.every((e) => typeof e === 'string' && GIT_HOOK_EVENTS.has(e))) {
|
|
251
257
|
fail(`${at}.event`, 'must be a git hook event (pre-commit, pre-push, …) or a list of them');
|
|
252
258
|
}
|
|
253
|
-
if (typeof h.command !== 'string' || h.command.trim() === '')
|
|
259
|
+
if (typeof h.command !== 'string' || h.command.trim() === '')
|
|
260
|
+
fail(`${at}.command`, 'must be a non-empty string');
|
|
254
261
|
if (h.when_exists !== undefined && (typeof h.when_exists !== 'string' || h.when_exists === '')) {
|
|
255
262
|
fail(`${at}.when_exists`, 'must be a path');
|
|
256
263
|
}
|
|
@@ -519,7 +526,11 @@ export function projectIdentity(projectRoot: string): string {
|
|
|
519
526
|
encoding: 'utf8',
|
|
520
527
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
521
528
|
}).trim();
|
|
522
|
-
const slug = url
|
|
529
|
+
const slug = url
|
|
530
|
+
.replace(/\/+$/, '')
|
|
531
|
+
.replace(/\.git$/, '')
|
|
532
|
+
.split(/[/:]/)
|
|
533
|
+
.pop();
|
|
523
534
|
if (slug) return slug.toLowerCase();
|
|
524
535
|
} catch {
|
|
525
536
|
/* no git or no origin */
|
|
@@ -552,7 +563,10 @@ export function resolveProjectName(input: {
|
|
|
552
563
|
src.file.replace(/^~(?=$|\/)/, input.home).replace(/\{repo\}/g, input.repo),
|
|
553
564
|
);
|
|
554
565
|
// Readers take paths relative to a project root; from "/" an absolute path stays itself.
|
|
555
|
-
const value = resolveConfigPath(
|
|
566
|
+
const value = resolveConfigPath(
|
|
567
|
+
readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file),
|
|
568
|
+
src.jsonpath,
|
|
569
|
+
);
|
|
556
570
|
if (typeof value === 'string' && value.trim()) {
|
|
557
571
|
const shown = file.startsWith(input.home + path.sep) ? `~${file.slice(input.home.length)}` : file;
|
|
558
572
|
return { name: value.trim(), source: `${shown} ${src.jsonpath}` };
|
|
@@ -675,9 +689,7 @@ export class Tmux {
|
|
|
675
689
|
}
|
|
676
690
|
|
|
677
691
|
attach(session: string, insideTmux: boolean): number {
|
|
678
|
-
const args = insideTmux
|
|
679
|
-
? ['switch-client', '-t', `=${session}`]
|
|
680
|
-
: ['attach-session', '-t', `=${session}`];
|
|
692
|
+
const args = insideTmux ? ['switch-client', '-t', `=${session}`] : ['attach-session', '-t', `=${session}`];
|
|
681
693
|
return spawnSync('tmux', this.argv(args), { stdio: 'inherit' }).status ?? 1;
|
|
682
694
|
}
|
|
683
695
|
|
|
@@ -804,7 +816,8 @@ export function renderPlan(
|
|
|
804
816
|
` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
|
|
805
817
|
` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
|
|
806
818
|
];
|
|
807
|
-
if (plan.focused)
|
|
819
|
+
if (plan.focused)
|
|
820
|
+
lines.push(` focused: nothing wakes it; left out: ${plan.skipped.length ? plan.skipped.join(', ') : '—'}`);
|
|
808
821
|
for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
|
|
809
822
|
lines.push(
|
|
810
823
|
multiplexer === 'tmux'
|
|
@@ -828,7 +841,10 @@ export function renderPlan(
|
|
|
828
841
|
|
|
829
842
|
function lastLines(screen: string | null, n: number): string {
|
|
830
843
|
const lines = (screen ?? '').split('\n').filter((l) => l.trim() !== '');
|
|
831
|
-
return lines
|
|
844
|
+
return lines
|
|
845
|
+
.slice(-n)
|
|
846
|
+
.map((l) => ` | ${l}`)
|
|
847
|
+
.join('\n');
|
|
832
848
|
}
|
|
833
849
|
|
|
834
850
|
function findAgentInPane(plan: LaunchPlan, paneId: string, deps: AiDeps): ProcInfo | null {
|
|
@@ -981,7 +997,12 @@ export function remoteAiArgs(input: {
|
|
|
981
997
|
}
|
|
982
998
|
|
|
983
999
|
/** Where neighbour repos live: the profile's `git.author_pool`, else the project's parent directory. */
|
|
984
|
-
export function authorPoolDirs(
|
|
1000
|
+
export function authorPoolDirs(
|
|
1001
|
+
environment: Environment,
|
|
1002
|
+
projectRoot: string,
|
|
1003
|
+
home: string,
|
|
1004
|
+
profileDir: string,
|
|
1005
|
+
): string[] {
|
|
985
1006
|
const listed = environment.git?.author_pool;
|
|
986
1007
|
if (!listed?.length) return [path.dirname(projectRoot)];
|
|
987
1008
|
return listed.map((d) => path.resolve(profileDir, d.replace(/^~(?=$|\/)/, home)));
|
|
@@ -1025,7 +1046,13 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
|
|
|
1025
1046
|
return;
|
|
1026
1047
|
}
|
|
1027
1048
|
const pick: Author | undefined =
|
|
1028
|
-
a === ''
|
|
1049
|
+
a === ''
|
|
1050
|
+
? top[0]
|
|
1051
|
+
: /^\d+$/.test(a)
|
|
1052
|
+
? top[Number(a) - 1]
|
|
1053
|
+
: a.includes('@')
|
|
1054
|
+
? { name: top[0]!.name, email: a }
|
|
1055
|
+
: undefined;
|
|
1029
1056
|
if (pick) {
|
|
1030
1057
|
if (writeAuthor(projectRoot, pick)) deps.log(`✓ ${pick.name} <${pick.email}> → .git/config`);
|
|
1031
1058
|
else deps.log(`✗ could not write .git/config — ${command(pick)}`);
|
|
@@ -1040,7 +1067,9 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: s
|
|
|
1040
1067
|
const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
|
|
1041
1068
|
const tty = deps.interactive && !opts.detach && !opts.dryRun;
|
|
1042
1069
|
const args = remoteAiArgs({ host, from, projectPath, home: deps.home, version: deps.version ?? '', opts, tty });
|
|
1043
|
-
deps.log(
|
|
1070
|
+
deps.log(
|
|
1071
|
+
`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`,
|
|
1072
|
+
);
|
|
1044
1073
|
const res = (deps.ssh ?? runSsh)(args, false);
|
|
1045
1074
|
if (res === null) {
|
|
1046
1075
|
deps.log('vdx ai: `ssh` not found on PATH');
|
|
@@ -1113,7 +1142,10 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1113
1142
|
if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
1114
1143
|
const host = plan.host;
|
|
1115
1144
|
const same = sameMachine;
|
|
1116
|
-
const all =
|
|
1145
|
+
const all =
|
|
1146
|
+
plan.resumeArgs.length === 0
|
|
1147
|
+
? []
|
|
1148
|
+
: listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
|
|
1117
1149
|
const root = shellQuote(plan.projectRoot);
|
|
1118
1150
|
// Said from where the person sits: under `vdx ai@<host>` that is not this machine.
|
|
1119
1151
|
const atSeat = same(plan.seat, host);
|
|
@@ -1124,7 +1156,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1124
1156
|
for (const m of [...plan.machines, ...all.map((c) => c.machine)]) {
|
|
1125
1157
|
if (m && !same(m, host) && ![...reach.keys()].some((k) => same(k, m))) reach.set(m, askMachine(m, deps));
|
|
1126
1158
|
}
|
|
1127
|
-
const silent = [...reach]
|
|
1159
|
+
const silent = [...reach]
|
|
1160
|
+
.filter(([, r]) => r === null)
|
|
1161
|
+
.map(([m]) => `note: ${m} does not answer over ssh — what runs there is not known`);
|
|
1128
1162
|
const elsewhere = [...reach].flatMap(([m, r]) =>
|
|
1129
1163
|
(r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
|
|
1130
1164
|
);
|
|
@@ -1139,7 +1173,9 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1139
1173
|
!l.tmux ? '' : same(m, plan.seat) ? ` — attach: vdx ai ${rootOn(m)}` : ` — there: vdx ai@${m} ${root}`;
|
|
1140
1174
|
const fresh = (lines: string[]): ConversationChoice => {
|
|
1141
1175
|
if (elsewhere.length === 0) return { args: [], lines };
|
|
1142
|
-
const agents = elsewhere.map(
|
|
1176
|
+
const agents = elsewhere.map(
|
|
1177
|
+
({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`,
|
|
1178
|
+
);
|
|
1143
1179
|
return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
|
|
1144
1180
|
};
|
|
1145
1181
|
if (plan.resumeArgs.length === 0) return fresh(silent);
|
|
@@ -1167,15 +1203,26 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1167
1203
|
if (here) {
|
|
1168
1204
|
return {
|
|
1169
1205
|
args: [],
|
|
1170
|
-
askHere: {
|
|
1206
|
+
askHere: {
|
|
1207
|
+
id: c.id,
|
|
1208
|
+
why: `It runs ${on(host)} right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it`,
|
|
1209
|
+
},
|
|
1171
1210
|
lines: [`chosen: ${show(c)}`],
|
|
1172
1211
|
};
|
|
1173
1212
|
}
|
|
1174
1213
|
if (isOurs(c)) return { args: resumeId(c.id), lines: [`continuing ${show(c)}`] };
|
|
1175
|
-
if (m)
|
|
1214
|
+
if (m)
|
|
1215
|
+
return {
|
|
1216
|
+
args: [],
|
|
1217
|
+
remote: { host: m, id: c.id, reachable: reach.get(m) != null },
|
|
1218
|
+
lines: [`continuing on ${m}: ${show(c)}`],
|
|
1219
|
+
};
|
|
1176
1220
|
return {
|
|
1177
1221
|
args: [],
|
|
1178
|
-
askHere: {
|
|
1222
|
+
askHere: {
|
|
1223
|
+
id: c.id,
|
|
1224
|
+
why: `Its machine is unknown (never typed in ${on(host)}); if it runs there, both machines will write to it`,
|
|
1225
|
+
},
|
|
1179
1226
|
lines: [`chosen: ${show(c)}`],
|
|
1180
1227
|
};
|
|
1181
1228
|
};
|
|
@@ -1188,7 +1235,12 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1188
1235
|
if (canAsk && deps.interactive) {
|
|
1189
1236
|
deps.log('conversations of this project:');
|
|
1190
1237
|
found.forEach((c, i) => {
|
|
1191
|
-
const marks = [
|
|
1238
|
+
const marks = [
|
|
1239
|
+
i === 0 && 'newest',
|
|
1240
|
+
c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`),
|
|
1241
|
+
!machine(c) && 'machine unknown',
|
|
1242
|
+
...running(c),
|
|
1243
|
+
];
|
|
1192
1244
|
const m = marks.filter(Boolean);
|
|
1193
1245
|
deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
|
|
1194
1246
|
});
|
|
@@ -1215,7 +1267,8 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1215
1267
|
`${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
|
|
1216
1268
|
(now.length ? `, ${now.join(', ')}` : '');
|
|
1217
1269
|
if (runsHere(c)) return `note: ${which} — not taken: it would have two sessions`;
|
|
1218
|
-
if (m && same(m, plan.seat))
|
|
1270
|
+
if (m && same(m, plan.seat))
|
|
1271
|
+
return `note: ${m} has ${which} — continue it where you are: vdx ai ${rootOn(m)} --conversation ${c.id}`;
|
|
1219
1272
|
return m
|
|
1220
1273
|
? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
|
|
1221
1274
|
: `note: ${which} is of an unknown machine (never typed in ${on(host)}) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
|
|
@@ -1228,6 +1281,51 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1228
1281
|
return fresh([`no idle conversation ${atSeat ? 'of this machine' : `on ${host}`} — starting a new one`, ...notes]);
|
|
1229
1282
|
}
|
|
1230
1283
|
|
|
1284
|
+
/**
|
|
1285
|
+
* Several agents of the project run here and the start is about one of them: a person
|
|
1286
|
+
* picks — Enter takes the newest; without a terminal, or under --dry-run, it is the newest.
|
|
1287
|
+
* null — the person left it.
|
|
1288
|
+
*/
|
|
1289
|
+
function pickAgent(
|
|
1290
|
+
running: AgentPane[],
|
|
1291
|
+
focusedOf: (pid: number) => boolean,
|
|
1292
|
+
deps: AiDeps,
|
|
1293
|
+
canAsk: boolean,
|
|
1294
|
+
): AgentPane | null {
|
|
1295
|
+
if (running.length === 1) return running[0]!;
|
|
1296
|
+
const startedAt = (a: AgentPane) => liveStartedAt(deps.env, deps.home, a.proc.pid);
|
|
1297
|
+
const sorted = [...running].sort((a, b) => (startedAt(b) ?? 0) - (startedAt(a) ?? 0));
|
|
1298
|
+
const show = (a: AgentPane) => {
|
|
1299
|
+
const id = liveConversationId(deps.env, deps.home, a.proc.pid);
|
|
1300
|
+
const at = startedAt(a);
|
|
1301
|
+
const marks = [
|
|
1302
|
+
id && `conversation ${id.slice(0, 8)}`,
|
|
1303
|
+
focusedOf(a.proc.pid) && 'focused',
|
|
1304
|
+
at !== null && `started ${describeAge(new Date(at))}`,
|
|
1305
|
+
].filter(Boolean);
|
|
1306
|
+
return `${a.pane.session} ${a.pane.paneId}${marks.length ? ` — ${marks.join(', ')}` : ''}`;
|
|
1307
|
+
};
|
|
1308
|
+
// Start times come from Claude Code's session files; without them, tmux's order.
|
|
1309
|
+
const newest = startedAt(sorted[0]!) !== null ? 'the newest' : 'the first';
|
|
1310
|
+
if (!canAsk || !deps.interactive) {
|
|
1311
|
+
deps.log(`note: ${sorted.length} agents of this project run here — taking ${newest}, ${show(sorted[0]!)}`);
|
|
1312
|
+
return sorted[0]!;
|
|
1313
|
+
}
|
|
1314
|
+
deps.log('agents of this project running here:');
|
|
1315
|
+
sorted.forEach((a, i) =>
|
|
1316
|
+
deps.log(` ${i + 1}) ${show(a)}${i === 0 && newest === 'the newest' ? ' ← newest' : ''}`),
|
|
1317
|
+
);
|
|
1318
|
+
for (let tries = 0; tries < 2; tries++) {
|
|
1319
|
+
const answer = deps.ask('Which one? [1]: ');
|
|
1320
|
+
if (answer === null) return null;
|
|
1321
|
+
const a = answer.trim();
|
|
1322
|
+
const agent = a === '' ? sorted[0] : /^\d+$/.test(a) ? sorted[Number(a) - 1] : undefined;
|
|
1323
|
+
if (agent) return agent;
|
|
1324
|
+
deps.log(` 1–${sorted.length}`);
|
|
1325
|
+
}
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1231
1329
|
interface Prepared {
|
|
1232
1330
|
projectRoot: string;
|
|
1233
1331
|
environment: Environment;
|
|
@@ -1326,10 +1424,23 @@ export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
|
|
|
1326
1424
|
*/
|
|
1327
1425
|
export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
|
|
1328
1426
|
const dirs = GUARDED_FOLDERS.map((f) => path.join(home, f));
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1427
|
+
if (tmux) {
|
|
1428
|
+
// `ls`, not `[ -d ]`: a denied folder still passes -d. The answer comes back in a
|
|
1429
|
+
// file: tmux before 3.5 prints run-shell's output into a pane, not to the caller.
|
|
1430
|
+
const file = path.join(os.tmpdir(), `vdx-folders-${process.pid}-${Date.now()}`);
|
|
1431
|
+
const checks = dirs.map(
|
|
1432
|
+
(d) => `{ [ ! -d ${shellQuote(d)} ] || ls ${shellQuote(d)} >/dev/null 2>&1 || echo ${shellQuote(d)}; }`,
|
|
1433
|
+
);
|
|
1434
|
+
try {
|
|
1435
|
+
if (tmux.tryRun(['run-shell', `{ ${checks.join('; ')}; } > ${shellQuote(file)}`]) !== null) {
|
|
1436
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
1437
|
+
}
|
|
1438
|
+
} catch {
|
|
1439
|
+
// no answer from the server: this process's own check
|
|
1440
|
+
} finally {
|
|
1441
|
+
fs.rmSync(file, { force: true });
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1333
1444
|
return dirs.filter((d) => {
|
|
1334
1445
|
try {
|
|
1335
1446
|
fs.readdirSync(d);
|
|
@@ -1445,8 +1556,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1445
1556
|
log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
|
|
1446
1557
|
return EXIT_USAGE;
|
|
1447
1558
|
}
|
|
1448
|
-
// A start continues the last conversation unless asked for a new one.
|
|
1449
|
-
|
|
1559
|
+
// A start continues the last conversation unless asked for a new one; a focused session is a new one.
|
|
1560
|
+
const wantsNew = (opts.fresh || opts.focused === true) && !opts.conversation;
|
|
1561
|
+
if (wantsNew) plan = { ...plan, resumeArgs: [] };
|
|
1450
1562
|
|
|
1451
1563
|
let multiplexer = plan.multiplexer;
|
|
1452
1564
|
if (multiplexer === 'tmux' && !tmux.available()) {
|
|
@@ -1455,13 +1567,27 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1455
1567
|
}
|
|
1456
1568
|
|
|
1457
1569
|
const running =
|
|
1458
|
-
multiplexer === 'tmux'
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1570
|
+
multiplexer === 'tmux' ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command) : [];
|
|
1571
|
+
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1572
|
+
const conversationOf = (a: AgentPane) => liveConversationId(deps.env, deps.home, a.proc.pid);
|
|
1573
|
+
// Which running agent the start is about. A new conversation, or one no agent here is in, starts
|
|
1574
|
+
// next to them; --restart replaces one. An agent whose conversation vdx cannot read may be in the
|
|
1575
|
+
// one asked for: no second agent in it blindly.
|
|
1576
|
+
const inConversation = opts.conversation ? running.find((a) => conversationOf(a) === opts.conversation) : undefined;
|
|
1577
|
+
const unreadable = opts.conversation && !inConversation ? running.find((a) => conversationOf(a) === null) : undefined;
|
|
1578
|
+
const alongside =
|
|
1579
|
+
running.length > 0 && !opts.restart && !inConversation && !unreadable && (wantsNew || !!opts.conversation);
|
|
1580
|
+
let current = inConversation ?? unreadable;
|
|
1581
|
+
if (!current && !alongside && running.length > 0) {
|
|
1582
|
+
const picked = pickAgent(running, focusedOf, deps, !opts.dryRun);
|
|
1583
|
+
if (picked === null) {
|
|
1584
|
+
log('nothing done');
|
|
1585
|
+
return EXIT_LAUNCH_FAILED;
|
|
1586
|
+
}
|
|
1587
|
+
current = picked;
|
|
1588
|
+
}
|
|
1462
1589
|
// A running agent keeps its mode: plain `vdx ai` takes a focused one as it is,
|
|
1463
1590
|
// and the rules it leaves out are not missing from it.
|
|
1464
|
-
const focusedOf = deps.agentFocused ?? processFocused;
|
|
1465
1591
|
const runningFocused = current ? focusedOf(current.proc.pid) : false;
|
|
1466
1592
|
if (current && !opts.restart && runningFocused && !plan.focused) {
|
|
1467
1593
|
plan = { ...prepared.replan(true), resumeArgs: plan.resumeArgs };
|
|
@@ -1471,8 +1597,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1471
1597
|
let choice: ConversationChoice | null = null;
|
|
1472
1598
|
if (!current || opts.restart) {
|
|
1473
1599
|
if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
|
|
1474
|
-
// A restart into a new conversation replaces the agent here
|
|
1475
|
-
|
|
1600
|
+
// A restart into a new conversation replaces the agent here, and a new one next to an agent here
|
|
1601
|
+
// is what was asked for: the other machines are not asked.
|
|
1602
|
+
else if ((current || alongside) && plan.resumeArgs.length === 0) choice = { args: [], lines: [] };
|
|
1476
1603
|
else if (current && isClaude(plan.command)) {
|
|
1477
1604
|
const live = liveConversationId(deps.env, deps.home, current.proc.pid);
|
|
1478
1605
|
choice = live
|
|
@@ -1484,8 +1611,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1484
1611
|
|
|
1485
1612
|
const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
|
|
1486
1613
|
// Said before a start or an attach, never in the way: the agent runs, without those files.
|
|
1487
|
-
const denied =
|
|
1488
|
-
|
|
1614
|
+
const denied =
|
|
1615
|
+
(deps.platform ?? process.platform) === 'darwin'
|
|
1616
|
+
? unreadableFolders(multiplexer === 'tmux' ? tmux : null, deps.home)
|
|
1617
|
+
: [];
|
|
1618
|
+
const folders = denied.length
|
|
1619
|
+
? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home)
|
|
1620
|
+
: [];
|
|
1489
1621
|
if (opts.dryRun) {
|
|
1490
1622
|
deps.out(renderPlan(plan, multiplexer, running, focusedOf));
|
|
1491
1623
|
for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
|
|
@@ -1497,7 +1629,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1497
1629
|
for (const l of folders) log(l);
|
|
1498
1630
|
if (choice?.abort) return EXIT_LAUNCH_FAILED;
|
|
1499
1631
|
if (choice?.elsewhere && !current) {
|
|
1500
|
-
const answer = deps.interactive
|
|
1632
|
+
const answer = deps.interactive
|
|
1633
|
+
? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `)
|
|
1634
|
+
: null;
|
|
1501
1635
|
if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
|
|
1502
1636
|
log('nothing started');
|
|
1503
1637
|
return EXIT_LAUNCH_FAILED;
|
|
@@ -1550,34 +1684,35 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1550
1684
|
return res.status ?? 1;
|
|
1551
1685
|
}
|
|
1552
1686
|
|
|
1553
|
-
if (
|
|
1687
|
+
if (alongside) {
|
|
1688
|
+
const others = running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ');
|
|
1554
1689
|
log(
|
|
1555
|
-
`note: ${running.length}
|
|
1556
|
-
|
|
1690
|
+
`note: ${others} keep${running.length === 1 ? 's' : ''} running ${plan.command} — starting another one next to it, ` +
|
|
1691
|
+
`in ${opts.conversation ? `conversation ${opts.conversation.slice(0, 8)}` : 'a new conversation'}`,
|
|
1557
1692
|
);
|
|
1558
1693
|
}
|
|
1559
1694
|
|
|
1560
1695
|
if (current && !opts.restart) {
|
|
1561
|
-
if (
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
`note: ${current.pane.session} is in ${live ? `conversation ${live.slice(0, 8)}` : 'another conversation'} — ` +
|
|
1567
|
-
`to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
|
|
1568
|
-
);
|
|
1569
|
-
}
|
|
1696
|
+
if (current === unreadable) {
|
|
1697
|
+
log(
|
|
1698
|
+
`note: ${current.pane.session} runs ${plan.command} in a conversation vdx cannot read — attaching to it; ` +
|
|
1699
|
+
`to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
|
|
1700
|
+
);
|
|
1570
1701
|
}
|
|
1571
1702
|
const unfocused = plan.focused && !runningFocused;
|
|
1572
1703
|
const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
|
|
1573
1704
|
if (!unfocused && missing.length === 0) {
|
|
1574
|
-
log(
|
|
1705
|
+
log(
|
|
1706
|
+
`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`,
|
|
1707
|
+
);
|
|
1575
1708
|
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1576
1709
|
}
|
|
1577
1710
|
// Drift. In a terminal the person picks: the agent as it runs, or a restart
|
|
1578
1711
|
// with the profile; without one nothing is touched, and the fix is named.
|
|
1579
1712
|
const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
|
|
1580
|
-
const fix =
|
|
1713
|
+
const fix =
|
|
1714
|
+
`vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
|
|
1715
|
+
(opts.conversation ? ` --conversation ${opts.conversation}` : '');
|
|
1581
1716
|
const effect = plan.resumeArgs.length
|
|
1582
1717
|
? 'stops the running agent, then resumes its conversation'
|
|
1583
1718
|
: opts.fresh
|
|
@@ -1589,7 +1724,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1589
1724
|
return EXIT_DRIFT;
|
|
1590
1725
|
}
|
|
1591
1726
|
log(`⚠ ${drift}`);
|
|
1592
|
-
log(
|
|
1727
|
+
log(
|
|
1728
|
+
` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`,
|
|
1729
|
+
);
|
|
1593
1730
|
log(` r restart it ${unfocused ? 'focused' : 'with the profile'}: ${effect}`);
|
|
1594
1731
|
log(` q leave it; later: ${fix}`);
|
|
1595
1732
|
const answer = deps.ask(`${opts.detach ? 'Leave it as is' : 'Attach as is'}? [Enter/r/q]: `);
|
|
@@ -1601,13 +1738,22 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1601
1738
|
log('nothing done');
|
|
1602
1739
|
return EXIT_DRIFT;
|
|
1603
1740
|
}
|
|
1741
|
+
// As --restart: the conversation the agent is in, not the folder's newest — another agent's, maybe.
|
|
1742
|
+
const live = isClaude(plan.command) && plan.resumeArgs.length > 0 ? conversationOf(current) : null;
|
|
1743
|
+
if (live) plan = { ...plan, resumeArgs: resumeId(live) };
|
|
1604
1744
|
}
|
|
1605
1745
|
|
|
1606
1746
|
if (current) {
|
|
1607
1747
|
const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
|
|
1608
1748
|
log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
|
|
1609
1749
|
tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
|
|
1610
|
-
return finish(
|
|
1750
|
+
return finish(
|
|
1751
|
+
settleResumed(plan, current.pane.paneId, deps),
|
|
1752
|
+
current.pane.session,
|
|
1753
|
+
current.pane.paneId,
|
|
1754
|
+
opts,
|
|
1755
|
+
deps,
|
|
1756
|
+
);
|
|
1611
1757
|
}
|
|
1612
1758
|
|
|
1613
1759
|
if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
|