@vodmal/vdx-cli 0.25.0 → 0.27.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 +2 -2
- package/package.json +16 -6
- package/rubric/vdx-rubric.yaml +45 -9
- package/src/ai.ts +48 -49
- package/src/author.ts +1 -1
- package/src/clone-checks.ts +1 -1
- package/src/conversations.ts +33 -26
- package/src/doctor.ts +10 -9
- package/src/evaluator.ts +22 -21
- package/src/facts.ts +24 -49
- package/src/gha.ts +36 -28
- package/src/hooks.ts +34 -34
- package/src/index.ts +22 -13
- package/src/init.ts +12 -20
- package/src/manifest.ts +25 -4
- package/src/mcp-server.ts +39 -35
- package/src/narrow.ts +56 -0
- package/src/personal-hooks.ts +2 -8
- package/src/predicates.ts +10 -11
- package/src/publish.ts +28 -26
- package/src/rubric.ts +6 -6
- package/src/run.ts +3 -2
- package/src/vocabulary.ts +10 -14
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ vdx ai@m3
|
|
|
91
91
|
`vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
|
|
92
92
|
`~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
|
|
93
93
|
[docs/specs/environment-format.md](../docs/specs/environment-format.md).
|
|
94
|
-
The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else the
|
|
94
|
+
The session's `{project}` is `[_.vdx] name` (or the older `[vdx]`) from the project's `mise.toml`, else the
|
|
95
95
|
first file in the profile's `session.project_names` that has one, else the repo
|
|
96
96
|
name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
|
|
97
97
|
profile and tmux, the path taken under its home directory; `--detach` only
|
|
@@ -126,7 +126,7 @@ npm run typecheck
|
|
|
126
126
|
## Structure
|
|
127
127
|
|
|
128
128
|
- `src/rubric.ts` — types + YAML loader
|
|
129
|
-
- `src/manifest.ts` — parser for `[vdx]` block in `mise.toml` + `.vdx-overrides.yml`
|
|
129
|
+
- `src/manifest.ts` — parser for the `[_.vdx]` block in `mise.toml` (the older `[vdx]` too) + `.vdx-overrides.yml`
|
|
130
130
|
- `src/facts.ts` — fact source loaders (tasks, packages, configs, sub-package detection)
|
|
131
131
|
- `src/predicates.ts` — predicate registry; true / false / unknown with a reason (schema 0.3)
|
|
132
132
|
- `src/evaluator.ts` — recursive evaluator + sugar notation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|
|
@@ -34,16 +34,21 @@
|
|
|
34
34
|
"LICENSE"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
+
"prepare": "git config core.hooksPath .githooks || true",
|
|
37
38
|
"vdx": "tsx src/index.ts",
|
|
38
39
|
"vdx-mcp": "tsx src/mcp-server.ts",
|
|
39
40
|
"audit": "tsx src/index.ts audit",
|
|
40
41
|
"smoke": "bash smoke.sh",
|
|
41
42
|
"typecheck": "tsc --noEmit",
|
|
42
43
|
"format:check": "prettier --check .",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"knip": "knip",
|
|
46
|
+
"check": "npm run typecheck && npm run format:check && npm run lint && npm run knip",
|
|
47
|
+
"check:code:security": "npm audit --omit=dev",
|
|
48
|
+
"fix": "eslint --fix . && prettier --write .",
|
|
45
49
|
"test": "vitest run",
|
|
46
50
|
"test:unit": "vitest run tests/unit",
|
|
51
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
47
52
|
"coverage": "vitest run --coverage"
|
|
48
53
|
},
|
|
49
54
|
"dependencies": {
|
|
@@ -57,18 +62,23 @@
|
|
|
57
62
|
"zod": "^3.23.0"
|
|
58
63
|
},
|
|
59
64
|
"devDependencies": {
|
|
65
|
+
"@eslint/js": "^10.0.1",
|
|
60
66
|
"@types/js-yaml": "^4.0.9",
|
|
61
67
|
"@types/marked-terminal": "^6.1.1",
|
|
62
68
|
"@types/node": "^22.0.0",
|
|
63
|
-
"@vitest/coverage-v8": "^4.1.
|
|
69
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
70
|
+
"eslint": "^10.12.0",
|
|
71
|
+
"eslint-config-prettier": "^10.1.8",
|
|
72
|
+
"knip": "^6.41.0",
|
|
64
73
|
"prettier": "^3.9.10",
|
|
65
74
|
"typescript": "^5.6.0",
|
|
66
|
-
"
|
|
75
|
+
"typescript-eslint": "^8.71.2",
|
|
76
|
+
"vitest": "^4.1.11"
|
|
67
77
|
},
|
|
68
78
|
"publishConfig": {
|
|
69
79
|
"access": "public"
|
|
70
80
|
},
|
|
71
81
|
"engines": {
|
|
72
|
-
"node": ">=
|
|
82
|
+
"node": ">=22"
|
|
73
83
|
}
|
|
74
84
|
}
|
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.4.
|
|
14
|
+
version: "1.4.2"
|
|
15
15
|
description: "Personal maturity rubric for multi-stack projects (vodmal)"
|
|
16
16
|
owner: vodmal
|
|
17
17
|
homepage: "https://github.com/vodmal/vdx-rubric"
|
|
@@ -193,17 +193,24 @@ axes:
|
|
|
193
193
|
predicate: { tsc_flag: { name: noImplicitOverride, equals: true } }
|
|
194
194
|
- id: eslint_flat
|
|
195
195
|
weight: 1.0
|
|
196
|
-
|
|
196
|
+
# Имена flat-конфига — те, что ищет ESLint 10 (lib/config/config-loader.js).
|
|
197
|
+
# Одно определение на сет: code-style ссылается сюда (v1.4.1).
|
|
198
|
+
predicate: &eslint_flat_config
|
|
197
199
|
any_of:
|
|
198
200
|
- has_file: eslint.config.js
|
|
199
201
|
- has_file: eslint.config.mjs
|
|
202
|
+
- has_file: eslint.config.cjs
|
|
200
203
|
- has_file: eslint.config.ts
|
|
204
|
+
- has_file: eslint.config.mts
|
|
205
|
+
- has_file: eslint.config.cts
|
|
201
206
|
- id: eslint_type_checked
|
|
202
207
|
weight: 1.0
|
|
203
208
|
predicate:
|
|
204
209
|
file_contains:
|
|
205
|
-
paths: [eslint.config.js, eslint.config.mjs, eslint.config.ts]
|
|
206
|
-
|
|
210
|
+
paths: [eslint.config.js, eslint.config.mjs, eslint.config.cjs, eslint.config.ts, eslint.config.mts, eslint.config.cts]
|
|
211
|
+
# typescript-eslint во flat-конфиге — recommendedTypeChecked / strictTypeChecked,
|
|
212
|
+
# в .eslintrc было recommended-type-checked (v1.4.1).
|
|
213
|
+
pattern: "(recommended|strict)-?[Tt]ype-?[Cc]hecked"
|
|
207
214
|
level_thresholds: { L4: 0.85, L3: 0.65, L2: 0.35, L1: 0.10 }
|
|
208
215
|
|
|
209
216
|
- id: ci
|
|
@@ -316,9 +323,31 @@ axes:
|
|
|
316
323
|
- has_file: ecs.php
|
|
317
324
|
- has_file: .php-cs-fixer.php
|
|
318
325
|
- has_file: phpcs.xml.dist
|
|
319
|
-
|
|
320
|
-
-
|
|
321
|
-
|
|
326
|
+
# Форматтер: имена конфига — те, что ищет Prettier 3.9, и Biome (v1.4.1).
|
|
327
|
+
- &formatter_config
|
|
328
|
+
any_of:
|
|
329
|
+
- has_file: .prettierrc
|
|
330
|
+
- has_file: .prettierrc.json
|
|
331
|
+
- has_file: .prettierrc.yaml
|
|
332
|
+
- has_file: .prettierrc.yml
|
|
333
|
+
- has_file: .prettierrc.json5
|
|
334
|
+
- has_file: .prettierrc.toml
|
|
335
|
+
- has_file: .prettierrc.js
|
|
336
|
+
- has_file: .prettierrc.mjs
|
|
337
|
+
- has_file: .prettierrc.cjs
|
|
338
|
+
- has_file: .prettierrc.ts
|
|
339
|
+
- has_file: .prettierrc.mts
|
|
340
|
+
- has_file: .prettierrc.cts
|
|
341
|
+
- has_file: prettier.config.js
|
|
342
|
+
- has_file: prettier.config.mjs
|
|
343
|
+
- has_file: prettier.config.cjs
|
|
344
|
+
- has_file: prettier.config.ts
|
|
345
|
+
- has_file: prettier.config.mts
|
|
346
|
+
- has_file: prettier.config.cts
|
|
347
|
+
- config_value: { path: package.json, jsonpath: "prettier", op: present }
|
|
348
|
+
- has_file: biome.json
|
|
349
|
+
- has_file: biome.jsonc
|
|
350
|
+
- *eslint_flat_config
|
|
322
351
|
L3:
|
|
323
352
|
requires:
|
|
324
353
|
any_of:
|
|
@@ -333,8 +362,8 @@ axes:
|
|
|
333
362
|
- has_file: ecs.php
|
|
334
363
|
- has_file: phpcs.xml.dist
|
|
335
364
|
- all_of:
|
|
336
|
-
-
|
|
337
|
-
-
|
|
365
|
+
- *formatter_config
|
|
366
|
+
- *eslint_flat_config
|
|
338
367
|
|
|
339
368
|
- id: dependency-hygiene
|
|
340
369
|
class: supporting
|
|
@@ -544,10 +573,17 @@ axes:
|
|
|
544
573
|
requires:
|
|
545
574
|
any_of:
|
|
546
575
|
- file_contains: { path: scripts/docker-up.sh, pattern: "(traefik|nginx-proxy)" }
|
|
576
|
+
# Манифест vdx — в [_.vdx] (vdx 0.26+) или в старом [vdx]; оба места — с v1.4.2.
|
|
577
|
+
- config_value: { path: mise.toml, jsonpath: "_.vdx.shared_infra.precheck", equals: true }
|
|
547
578
|
- config_value: { path: mise.toml, jsonpath: "vdx.shared_infra.precheck", equals: true }
|
|
548
579
|
L4:
|
|
549
580
|
requires:
|
|
550
581
|
any_of:
|
|
582
|
+
- config_value:
|
|
583
|
+
path: mise.toml
|
|
584
|
+
jsonpath: "_.vdx.shared_infra.provider"
|
|
585
|
+
op: matches
|
|
586
|
+
pattern: "github.com/.+@v\\d+"
|
|
551
587
|
- config_value:
|
|
552
588
|
path: mise.toml
|
|
553
589
|
jsonpath: "vdx.shared_infra.provider"
|
package/src/ai.ts
CHANGED
|
@@ -6,7 +6,7 @@ import YAML from 'js-yaml';
|
|
|
6
6
|
import { evalPredicate } from './evaluator.ts';
|
|
7
7
|
import { GIT_HOOK_EVENTS } from './hooks.ts';
|
|
8
8
|
import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { loadManifestAt } from './manifest.ts';
|
|
10
10
|
import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
|
|
11
11
|
import type { Predicate } from './rubric.ts';
|
|
12
12
|
import {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
parseMachineReport,
|
|
29
29
|
runsInProject,
|
|
30
30
|
} from './conversations.ts';
|
|
31
|
+
import { at, errorMessage, isStringList, str } from './narrow.ts';
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* `vdx ai` — start the person's agent in a project, with the flags their
|
|
@@ -48,12 +49,12 @@ import {
|
|
|
48
49
|
*/
|
|
49
50
|
|
|
50
51
|
/** A prompt a conditional flag is known to cause, and the tmux keys that answer it. */
|
|
51
|
-
|
|
52
|
+
interface ConfirmRule {
|
|
52
53
|
screen: string;
|
|
53
54
|
keys: string[];
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
interface WhenRule {
|
|
57
58
|
id: string;
|
|
58
59
|
description?: string;
|
|
59
60
|
if: Predicate;
|
|
@@ -70,7 +71,7 @@ export interface AgentProfile {
|
|
|
70
71
|
when?: WhenRule[];
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
type Multiplexer = 'tmux' | 'none';
|
|
74
75
|
|
|
75
76
|
/** A place a project's short name may come from: a structured file and a dot path into it. */
|
|
76
77
|
export interface NameSource {
|
|
@@ -79,10 +80,10 @@ export interface NameSource {
|
|
|
79
80
|
jsonpath: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
interface SessionProfile {
|
|
83
84
|
multiplexer?: Multiplexer;
|
|
84
85
|
name?: string;
|
|
85
|
-
/** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
|
|
86
|
+
/** Tried after `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml; the first non-empty string wins. */
|
|
86
87
|
project_names?: NameSource[];
|
|
87
88
|
/** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
|
|
88
89
|
machines?: string[];
|
|
@@ -106,7 +107,7 @@ export interface PersonalHook {
|
|
|
106
107
|
description?: string;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
interface GitProfile {
|
|
110
111
|
/** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
|
|
111
112
|
author_pool?: string[];
|
|
112
113
|
hooks?: PersonalHook[];
|
|
@@ -120,19 +121,19 @@ export interface Environment {
|
|
|
120
121
|
git?: GitProfile;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
|
|
125
|
+
const HOST_ENV_VAR = 'VDX_HOST';
|
|
125
126
|
/** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
|
|
126
|
-
|
|
127
|
+
const SEAT_ENV_VAR = 'VDX_AI_FROM';
|
|
127
128
|
/**
|
|
128
129
|
* `1` in the environment of an agent started with `vdx ai --focused`, and so of
|
|
129
130
|
* its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
|
|
130
131
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
|
|
133
|
+
const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
|
|
133
134
|
/** Tried in order when no profile names an agent. Started plain, with no flags. */
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
const FALLBACK_AGENTS = ['claude', 'codex'] as const;
|
|
136
|
+
const DEFAULT_SESSION_NAME = '{project}';
|
|
136
137
|
|
|
137
138
|
export function resolveEnvironmentPath(
|
|
138
139
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -144,13 +145,9 @@ export function resolveEnvironmentPath(
|
|
|
144
145
|
return fs.existsSync(user) ? user : null;
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
function isStringList(v: unknown): v is string[] {
|
|
148
|
-
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
149
|
-
}
|
|
150
|
-
|
|
151
148
|
/** Parse and validate a profile. Throws with the offending key named. */
|
|
152
149
|
export function parseEnvironment(text: string, source: string): Environment {
|
|
153
|
-
const data = YAML.load(text)
|
|
150
|
+
const data: unknown = YAML.load(text);
|
|
154
151
|
if (data === null || data === undefined) return {};
|
|
155
152
|
if (typeof data !== 'object' || Array.isArray(data)) {
|
|
156
153
|
throw new Error(`${source}: expected a YAML mapping at the top level`);
|
|
@@ -271,8 +268,8 @@ export function loadEnvironment(file: string): Environment {
|
|
|
271
268
|
let text: string;
|
|
272
269
|
try {
|
|
273
270
|
text = fs.readFileSync(file, 'utf8');
|
|
274
|
-
} catch (e
|
|
275
|
-
throw new Error(`cannot read profile ${file}: ${e
|
|
271
|
+
} catch (e) {
|
|
272
|
+
throw new Error(`cannot read profile ${file}: ${str(at(e, 'code')) ?? errorMessage(e)}`, { cause: e });
|
|
276
273
|
}
|
|
277
274
|
return parseEnvironment(text, file);
|
|
278
275
|
}
|
|
@@ -372,7 +369,7 @@ export function planLaunch(input: {
|
|
|
372
369
|
}
|
|
373
370
|
|
|
374
371
|
export function shellQuote(s: string): string {
|
|
375
|
-
if (s !== '' && /^[A-Za-z0-9_
|
|
372
|
+
if (s !== '' && /^[A-Za-z0-9_/.:@%+=,-]+$/.test(s)) return s;
|
|
376
373
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
377
374
|
}
|
|
378
375
|
|
|
@@ -404,7 +401,7 @@ export interface ProcInfo {
|
|
|
404
401
|
args: string;
|
|
405
402
|
}
|
|
406
403
|
|
|
407
|
-
|
|
404
|
+
const PANE_FORMAT = '#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_path}';
|
|
408
405
|
|
|
409
406
|
export function parsePanes(out: string): PaneInfo[] {
|
|
410
407
|
const panes: PaneInfo[] = [];
|
|
@@ -465,7 +462,7 @@ function realpathOr(p: string): string {
|
|
|
465
462
|
}
|
|
466
463
|
}
|
|
467
464
|
|
|
468
|
-
|
|
465
|
+
function isInside(child: string, parent: string): boolean {
|
|
469
466
|
return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
470
467
|
}
|
|
471
468
|
|
|
@@ -500,7 +497,7 @@ export function missingArgs(procArgs: string, required: string[]): string[] {
|
|
|
500
497
|
return required.filter((a) => (/\s/.test(a) ? !procArgs.includes(a) : !tokens.has(a)));
|
|
501
498
|
}
|
|
502
499
|
|
|
503
|
-
|
|
500
|
+
function resolveProjectRoot(p: string): string | null {
|
|
504
501
|
const abs = path.resolve(p);
|
|
505
502
|
try {
|
|
506
503
|
if (!fs.statSync(abs).isDirectory()) return null;
|
|
@@ -544,7 +541,7 @@ export interface ProjectName {
|
|
|
544
541
|
}
|
|
545
542
|
|
|
546
543
|
/**
|
|
547
|
-
* `{project}`: `[vdx] name` in the project's mise.toml, else the first profile
|
|
544
|
+
* `{project}`: `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml, else the first profile
|
|
548
545
|
* source holding a non-empty string, else the repository name. A source that is
|
|
549
546
|
* missing or unreadable is skipped — a name is a label, not a reason to fail.
|
|
550
547
|
*/
|
|
@@ -555,8 +552,9 @@ export function resolveProjectName(input: {
|
|
|
555
552
|
home: string;
|
|
556
553
|
profileDir: string;
|
|
557
554
|
}): ProjectName {
|
|
558
|
-
const
|
|
559
|
-
|
|
555
|
+
const found = loadManifestAt(input.projectRoot);
|
|
556
|
+
const own = found?.manifest.name;
|
|
557
|
+
if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: `mise.toml [${found!.place}] name` };
|
|
560
558
|
for (const src of input.sources) {
|
|
561
559
|
const file = path.resolve(
|
|
562
560
|
input.profileDir,
|
|
@@ -582,12 +580,12 @@ export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
582
580
|
}
|
|
583
581
|
|
|
584
582
|
/** The machine the person sits at: the one whose `vdx ai@<host>` started this vdx, else this one. */
|
|
585
|
-
|
|
583
|
+
function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
586
584
|
return env[SEAT_ENV_VAR]?.trim() || hostLabel(env);
|
|
587
585
|
}
|
|
588
586
|
|
|
589
587
|
/** Machine labels compare without case: `M3` in the profile is `m3` in a transcript. */
|
|
590
|
-
|
|
588
|
+
function sameMachine(a: string | null, b: string | null): boolean {
|
|
591
589
|
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
|
592
590
|
}
|
|
593
591
|
|
|
@@ -739,9 +737,9 @@ export interface AiDeps {
|
|
|
739
737
|
liveHere?: () => LiveSession[];
|
|
740
738
|
}
|
|
741
739
|
|
|
742
|
-
|
|
740
|
+
function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
|
|
743
741
|
const res = spawnSync('ssh', args, { stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'inherit', encoding: 'utf8' });
|
|
744
|
-
if ((res.error
|
|
742
|
+
if (at(res.error, 'code') === 'ENOENT') return null;
|
|
745
743
|
return { status: res.status ?? 1, stdout: res.stdout ?? '' };
|
|
746
744
|
}
|
|
747
745
|
|
|
@@ -750,7 +748,7 @@ export function sleepSync(ms: number): void {
|
|
|
750
748
|
}
|
|
751
749
|
|
|
752
750
|
/** Read one line from the terminal, synchronously: the prompt runs before tmux takes it. */
|
|
753
|
-
|
|
751
|
+
function askLine(prompt: string): string | null {
|
|
754
752
|
process.stderr.write(prompt);
|
|
755
753
|
const buf = Buffer.alloc(256);
|
|
756
754
|
let text = '';
|
|
@@ -758,8 +756,8 @@ export function askLine(prompt: string): string | null {
|
|
|
758
756
|
let n: number;
|
|
759
757
|
try {
|
|
760
758
|
n = fs.readSync(0, buf, 0, buf.length, null);
|
|
761
|
-
} catch (e
|
|
762
|
-
if (e
|
|
759
|
+
} catch (e) {
|
|
760
|
+
if (at(e, 'code') === 'EAGAIN') {
|
|
763
761
|
sleepSync(50);
|
|
764
762
|
continue;
|
|
765
763
|
}
|
|
@@ -800,9 +798,9 @@ export const EXIT_LAUNCH_FAILED = 4;
|
|
|
800
798
|
* a start with the resume args had nothing to resume (`claude --continue` exits
|
|
801
799
|
* 1 at once); the window covers answering the channel prompt that comes first.
|
|
802
800
|
*/
|
|
803
|
-
|
|
801
|
+
const NOTHING_TO_RESUME_MS = 30_000;
|
|
804
802
|
|
|
805
|
-
|
|
803
|
+
function renderPlan(
|
|
806
804
|
plan: LaunchPlan,
|
|
807
805
|
multiplexer: Multiplexer,
|
|
808
806
|
running: AgentPane[],
|
|
@@ -1085,7 +1083,7 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: s
|
|
|
1085
1083
|
* The Claude Code sessions alive on `host` right now; null when it does not
|
|
1086
1084
|
* answer over ssh (BatchMode: a password prompt counts as no answer).
|
|
1087
1085
|
*/
|
|
1088
|
-
|
|
1086
|
+
function askMachine(host: string, deps: AiDeps): MachineReport | null {
|
|
1089
1087
|
const res = (deps.ssh ?? runSsh)(
|
|
1090
1088
|
['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, `sh -c ${shellQuote(LIVE_SESSIONS_SCRIPT)}`],
|
|
1091
1089
|
true,
|
|
@@ -1094,7 +1092,7 @@ export function askMachine(host: string, deps: AiDeps): MachineReport | null {
|
|
|
1094
1092
|
}
|
|
1095
1093
|
|
|
1096
1094
|
/** What a start continues: resume args here, or a conversation on another machine. */
|
|
1097
|
-
|
|
1095
|
+
interface ConversationChoice {
|
|
1098
1096
|
args: string[];
|
|
1099
1097
|
/** Continue it on that machine (`vdx ai@<host>`); `reachable` — it answered over ssh just now. */
|
|
1100
1098
|
remote?: { host: string; id: string; reachable: boolean };
|
|
@@ -1108,7 +1106,7 @@ export interface ConversationChoice {
|
|
|
1108
1106
|
lines: string[];
|
|
1109
1107
|
}
|
|
1110
1108
|
|
|
1111
|
-
|
|
1109
|
+
function isClaude(command: string): boolean {
|
|
1112
1110
|
return path.basename(command) === 'claude';
|
|
1113
1111
|
}
|
|
1114
1112
|
|
|
@@ -1138,7 +1136,7 @@ const resumeId = (id: string) => ['--resume', id];
|
|
|
1138
1136
|
* takes the newest overall; without a terminal it is this machine's newest
|
|
1139
1137
|
* idle one, never another's.
|
|
1140
1138
|
*/
|
|
1141
|
-
|
|
1139
|
+
function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
|
|
1142
1140
|
if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
1143
1141
|
const host = plan.host;
|
|
1144
1142
|
const same = sameMachine;
|
|
@@ -1351,8 +1349,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1351
1349
|
if (profilePath) {
|
|
1352
1350
|
try {
|
|
1353
1351
|
environment = loadEnvironment(profilePath);
|
|
1354
|
-
} catch (e
|
|
1355
|
-
log(`vdx ai: ${e
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
log(`vdx ai: ${errorMessage(e)}`);
|
|
1356
1354
|
return EXIT_USAGE;
|
|
1357
1355
|
}
|
|
1358
1356
|
}
|
|
@@ -1390,8 +1388,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1390
1388
|
let plan: LaunchPlan;
|
|
1391
1389
|
try {
|
|
1392
1390
|
plan = replan(focused);
|
|
1393
|
-
} catch (e
|
|
1394
|
-
log(`vdx ai: ${profilePath ?? 'profile'}: ${e
|
|
1391
|
+
} catch (e) {
|
|
1392
|
+
log(`vdx ai: ${profilePath ?? 'profile'}: ${errorMessage(e)}`);
|
|
1395
1393
|
return EXIT_USAGE;
|
|
1396
1394
|
}
|
|
1397
1395
|
return { projectRoot, environment, profilePath, profileDir, plan, replan };
|
|
@@ -1414,7 +1412,7 @@ export function processFocused(pid: number): boolean {
|
|
|
1414
1412
|
}
|
|
1415
1413
|
|
|
1416
1414
|
/** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
|
|
1417
|
-
|
|
1415
|
+
const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
|
|
1418
1416
|
|
|
1419
1417
|
/**
|
|
1420
1418
|
* The guarded folders an agent started here could not read. Through the tmux server
|
|
@@ -1445,8 +1443,9 @@ export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
|
|
|
1445
1443
|
try {
|
|
1446
1444
|
fs.readdirSync(d);
|
|
1447
1445
|
return false;
|
|
1448
|
-
} catch (e
|
|
1449
|
-
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
const code = at(e, 'code');
|
|
1448
|
+
return code === 'EPERM' || code === 'EACCES';
|
|
1450
1449
|
}
|
|
1451
1450
|
});
|
|
1452
1451
|
}
|
|
@@ -1672,7 +1671,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1672
1671
|
const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
|
|
1673
1672
|
const startedAt = Date.now();
|
|
1674
1673
|
let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
|
|
1675
|
-
if ((res.error
|
|
1674
|
+
if (at(res.error, 'code') === 'ENOENT') {
|
|
1676
1675
|
log(`vdx ai: \`${plan.command}\` not found on PATH`);
|
|
1677
1676
|
return 127;
|
|
1678
1677
|
}
|
package/src/author.ts
CHANGED
|
@@ -81,7 +81,7 @@ const GENERIC = new Set([
|
|
|
81
81
|
'dmitry',
|
|
82
82
|
]);
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
function nameTokens(dir: string, remotePath: string): Set<string> {
|
|
85
85
|
return new Set(
|
|
86
86
|
`${path.basename(dir)} ${remotePath}`
|
|
87
87
|
.toLowerCase()
|
package/src/clone-checks.ts
CHANGED
|
@@ -34,7 +34,7 @@ function isGitWorkTree(root: string): boolean {
|
|
|
34
34
|
|
|
35
35
|
function executableHooks(dir: string): Map<string, string> {
|
|
36
36
|
const out = new Map<string, string>();
|
|
37
|
-
let names: string[]
|
|
37
|
+
let names: string[];
|
|
38
38
|
try {
|
|
39
39
|
names = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
|
|
40
40
|
} catch {
|
package/src/conversations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { at, dict, str } from './narrow.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Claude Code conversations of a project, and the machine each one was last
|
|
@@ -28,7 +29,7 @@ import * as path from 'node:path';
|
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
/** The opening of `vdx ai --check`'s text; the label is an ssh destination from the other machines. */
|
|
31
|
-
|
|
32
|
+
const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
|
|
32
33
|
const MACHINE_LINE_RE = /^vdx ai: this machine is `([^`]+)`/m;
|
|
33
34
|
/** A label read from a synced file goes to ssh: it must not be able to read as an option. */
|
|
34
35
|
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -86,17 +87,20 @@ export function readConversation(file: string, updated: Date): Conversation | nu
|
|
|
86
87
|
const isTitle = line.includes('"ai-title"') || line.includes('"last-prompt"');
|
|
87
88
|
if (!turns && line.includes('"type":"user"')) turns = true;
|
|
88
89
|
if (!isHook && !isTitle) continue;
|
|
89
|
-
let entry:
|
|
90
|
+
let entry: unknown;
|
|
90
91
|
try {
|
|
91
92
|
entry = JSON.parse(line);
|
|
92
93
|
} catch {
|
|
93
94
|
continue;
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
const type = at(entry, 'type');
|
|
97
|
+
const title = str(at(entry, 'aiTitle'));
|
|
98
|
+
const prompt = str(at(entry, 'lastPrompt'));
|
|
99
|
+
if (type === 'ai-title' && title !== undefined) aiTitle = title;
|
|
100
|
+
else if (type === 'last-prompt' && prompt !== undefined) lastPrompt = prompt;
|
|
101
|
+
else if (type === 'attachment' && at(entry, 'attachment', 'type') === 'hook_additional_context') {
|
|
102
|
+
const content = at(entry, 'attachment', 'content');
|
|
103
|
+
for (const part of Array.isArray(content) ? (content as unknown[]) : [content]) {
|
|
100
104
|
const label = typeof part === 'string' ? part.match(MACHINE_LINE_RE)?.[1] : undefined;
|
|
101
105
|
if (label && LABEL_RE.test(label)) machine = label;
|
|
102
106
|
}
|
|
@@ -108,7 +112,7 @@ export function readConversation(file: string, updated: Date): Conversation | nu
|
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
/** Which of `ids` had a prompt typed in them on this machine. */
|
|
111
|
-
|
|
115
|
+
function typedHere(env: NodeJS.ProcessEnv, home: string, ids: Set<string>): Set<string> {
|
|
112
116
|
const found = new Set<string>();
|
|
113
117
|
let text: string;
|
|
114
118
|
try {
|
|
@@ -197,9 +201,11 @@ export function latestPerMachine(
|
|
|
197
201
|
*/
|
|
198
202
|
export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: number): string | null {
|
|
199
203
|
try {
|
|
200
|
-
const data = JSON.parse(
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
const data: unknown = JSON.parse(
|
|
205
|
+
fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'),
|
|
206
|
+
);
|
|
207
|
+
const id = str(at(data, 'sessionId'));
|
|
208
|
+
return id !== undefined && isConversationId(id) ? id : null;
|
|
203
209
|
} catch {
|
|
204
210
|
return null;
|
|
205
211
|
}
|
|
@@ -209,9 +215,8 @@ export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: nu
|
|
|
209
215
|
export function liveStartedAt(env: NodeJS.ProcessEnv, home: string, pid: number): number | null {
|
|
210
216
|
try {
|
|
211
217
|
const file = path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`);
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
return typeof at === 'number' ? at : null;
|
|
218
|
+
const started = at(JSON.parse(fs.readFileSync(file, 'utf8')), 'startedAt');
|
|
219
|
+
return typeof started === 'number' ? started : null;
|
|
215
220
|
} catch {
|
|
216
221
|
return null;
|
|
217
222
|
}
|
|
@@ -230,24 +235,26 @@ export interface LiveSession {
|
|
|
230
235
|
}
|
|
231
236
|
|
|
232
237
|
/** Session files, one JSON object per line — a file read here, or the output of LIVE_SESSIONS_SCRIPT. */
|
|
233
|
-
|
|
238
|
+
function parseLiveSessions(text: string): LiveSession[] {
|
|
234
239
|
const out: LiveSession[] = [];
|
|
235
240
|
for (const line of text.split('\n')) {
|
|
236
241
|
if (!line.trim().startsWith('{')) continue;
|
|
237
|
-
let data:
|
|
242
|
+
let data: Record<string, unknown> | undefined;
|
|
238
243
|
try {
|
|
239
|
-
data = JSON.parse(line);
|
|
244
|
+
data = dict(JSON.parse(line));
|
|
240
245
|
} catch {
|
|
241
246
|
continue;
|
|
242
247
|
}
|
|
243
|
-
|
|
244
|
-
|
|
248
|
+
const pid = data?.['pid'];
|
|
249
|
+
const conversation = str(data?.['sessionId']);
|
|
250
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || conversation === undefined) continue;
|
|
251
|
+
if (!isConversationId(conversation)) continue;
|
|
245
252
|
out.push({
|
|
246
|
-
pid
|
|
247
|
-
conversation
|
|
248
|
-
tmux:
|
|
249
|
-
status:
|
|
250
|
-
cwd:
|
|
253
|
+
pid,
|
|
254
|
+
conversation,
|
|
255
|
+
tmux: str(data?.['tmux']) ?? null,
|
|
256
|
+
status: str(data?.['status']) ?? null,
|
|
257
|
+
cwd: str(data?.['cwd']) ?? null,
|
|
251
258
|
});
|
|
252
259
|
}
|
|
253
260
|
return out;
|
|
@@ -277,8 +284,8 @@ function pidAlive(pid: number): boolean {
|
|
|
277
284
|
try {
|
|
278
285
|
process.kill(pid, 0);
|
|
279
286
|
return true;
|
|
280
|
-
} catch (e
|
|
281
|
-
return e
|
|
287
|
+
} catch (e) {
|
|
288
|
+
return at(e, 'code') === 'EPERM';
|
|
282
289
|
}
|
|
283
290
|
}
|
|
284
291
|
|