@phnx-labs/agents-cli 1.20.25 → 1.20.27
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/CHANGELOG.md +37 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +126 -27
- package/dist/commands/inspect.d.ts +2 -1
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/repo.js +40 -0
- package/dist/commands/secrets.js +16 -12
- package/dist/commands/sessions.js +20 -1
- package/dist/index.js +2 -12
- package/dist/lib/agent-spec.d.ts +36 -0
- package/dist/lib/agent-spec.js +157 -0
- package/dist/lib/agents.js +1 -0
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/doctor-diff.d.ts +7 -0
- package/dist/lib/doctor-diff.js +18 -13
- package/dist/lib/fs-atomic.d.ts +3 -2
- package/dist/lib/fs-atomic.js +22 -7
- package/dist/lib/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/hooks.js +36 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +27 -3
- package/dist/lib/menubar/install-menubar.js +74 -9
- package/dist/lib/plugin-marketplace.d.ts +18 -0
- package/dist/lib/plugin-marketplace.js +67 -1
- package/dist/lib/plugins.d.ts +23 -1
- package/dist/lib/plugins.js +55 -10
- package/dist/lib/resources/rules.d.ts +5 -2
- package/dist/lib/resources/rules.js +39 -10
- package/dist/lib/rules/compose.d.ts +22 -0
- package/dist/lib/rules/compose.js +114 -9
- package/dist/lib/secrets/agent.d.ts +4 -2
- package/dist/lib/secrets/agent.js +6 -4
- package/dist/lib/secrets/bundles.d.ts +20 -14
- package/dist/lib/secrets/bundles.js +31 -10
- package/dist/lib/session/remote.d.ts +33 -0
- package/dist/lib/session/remote.js +114 -0
- package/dist/lib/staleness/checkers/rules.js +13 -1
- package/dist/lib/staleness/detectors/commands.js +7 -6
- package/dist/lib/staleness/writers/commands.js +7 -12
- package/dist/lib/startup/dev-build.d.ts +22 -0
- package/dist/lib/startup/dev-build.js +41 -0
- package/dist/lib/types.d.ts +13 -2
- package/dist/lib/versions.d.ts +3 -1
- package/dist/lib/versions.js +35 -3
- package/package.json +3 -3
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Centralized agent-spec resolution — one vocabulary, one resolver, reused by
|
|
2
|
+
// every subcommand that accepts `<agent>[@<qualifier>]`.
|
|
3
|
+
//
|
|
4
|
+
// The qualifier vocabulary used to be split across three functions in
|
|
5
|
+
// versions.ts (parseAgentSpec, resolveVersionAlias, resolveInstalledAgentTargets)
|
|
6
|
+
// with diverging support — `@latest`/`@oldest` in one, `@all`/`@default` in
|
|
7
|
+
// another, `@pinned` nowhere. This module is the single source of truth.
|
|
8
|
+
//
|
|
9
|
+
// Built for the hot path (`--launch`, ~100ms budget): the common specs resolve
|
|
10
|
+
// with NO directory enumeration —
|
|
11
|
+
// exact `claude@2.1.181` → one isVersionInstalled() (existsSync)
|
|
12
|
+
// `claude@pinned|@default` → memoized getGlobalDefault() + existsSync
|
|
13
|
+
// bare `claude` → resolveVersion() (memoized meta), no readdir
|
|
14
|
+
// Only the relative qualifiers `@latest`/`@oldest`/`@all` enumerate, and even
|
|
15
|
+
// then via the mtime-cached listInstalledVersions().
|
|
16
|
+
import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError } from './agents.js';
|
|
17
|
+
import { listInstalledVersions, getGlobalDefault, isVersionInstalled, resolveVersion, } from './versions.js';
|
|
18
|
+
/** Canonical qualifier set, in help/display order. `pinned` ≡ `default`. */
|
|
19
|
+
export const AGENT_QUALIFIERS = ['latest', 'oldest', 'pinned', 'default', 'all'];
|
|
20
|
+
/** Shared `--help` epilog so every agent-spec command documents the same grammar. */
|
|
21
|
+
export const AGENT_SPEC_HELP = 'Agent spec: <agent>[@<qualifier>]. Qualifiers: ' +
|
|
22
|
+
'@latest (highest installed), @oldest (lowest installed), ' +
|
|
23
|
+
'@pinned / @default (your configured default — synonyms), ' +
|
|
24
|
+
'@all (every installed version), or an exact @x.y.z. ' +
|
|
25
|
+
'Bare <agent> uses the resolved default (project pin → global default). ' +
|
|
26
|
+
'Comma-separate to combine: claude@all,codex@latest.';
|
|
27
|
+
export class AgentSpecError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'AgentSpecError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve an agent spec (single or comma-list) into concrete installed targets.
|
|
35
|
+
* Domain = installed: `@latest`/`@oldest`/`@all` range over installed versions
|
|
36
|
+
* (`add`/`install` use a separate available-version path). Throws AgentSpecError
|
|
37
|
+
* on bad input — never calls process.exit, so it is safe on the hot path and in
|
|
38
|
+
* library contexts.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveAgentTargets(spec, opts = {}) {
|
|
41
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
42
|
+
const available = opts.availableAgents ?? ALL_AGENT_IDS;
|
|
43
|
+
const rawEntries = spec
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((s) => s.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
if (rawEntries.length === 0) {
|
|
48
|
+
throw new AgentSpecError('Empty agent spec.');
|
|
49
|
+
}
|
|
50
|
+
// Expand the bare literal `all` (or `all@all`) into every available agent that
|
|
51
|
+
// has at least one installed version. Lenient: agents with nothing installed
|
|
52
|
+
// are skipped rather than erroring.
|
|
53
|
+
const entries = [];
|
|
54
|
+
for (const e of rawEntries) {
|
|
55
|
+
if (e === 'all' || e === 'all@all') {
|
|
56
|
+
for (const a of available) {
|
|
57
|
+
if (listInstalledVersions(a).length > 0)
|
|
58
|
+
entries.push(`${a}@all`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
entries.push(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const out = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const push = (agent, version) => {
|
|
68
|
+
const key = `${agent}@${version ?? ''}`;
|
|
69
|
+
if (!seen.has(key)) {
|
|
70
|
+
seen.add(key);
|
|
71
|
+
out.push({ agent, version });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const at = entry.indexOf('@');
|
|
76
|
+
const agentToken = (at === -1 ? entry : entry.slice(0, at)).trim();
|
|
77
|
+
const qualifier = at === -1 ? null : entry.slice(at + 1).trim();
|
|
78
|
+
if (!agentToken)
|
|
79
|
+
continue;
|
|
80
|
+
if (at !== -1 && !qualifier) {
|
|
81
|
+
throw new AgentSpecError(`Missing version in '${entry}'. Use ${agentToken}@x.y.z, @latest, @oldest, @pinned, @default, or @all.`);
|
|
82
|
+
}
|
|
83
|
+
const agent = resolveAgentName(agentToken);
|
|
84
|
+
if (!agent || !available.includes(agent)) {
|
|
85
|
+
throw new AgentSpecError(formatAgentError(agentToken, [...available]));
|
|
86
|
+
}
|
|
87
|
+
const name = AGENTS[agent].name;
|
|
88
|
+
// ----- bare: resolved default, NO enumeration in the common case -----
|
|
89
|
+
if (qualifier === null) {
|
|
90
|
+
const resolved = resolveVersion(agent, cwd); // project pin → global default (meta-only)
|
|
91
|
+
if (resolved) {
|
|
92
|
+
push(agent, resolved);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const installed = listInstalledVersions(agent);
|
|
96
|
+
if (installed.length === 0)
|
|
97
|
+
push(agent, null);
|
|
98
|
+
else if (installed.length === 1)
|
|
99
|
+
push(agent, installed[0]);
|
|
100
|
+
else
|
|
101
|
+
throw new AgentSpecError(`No default version set for ${name}. Specify one (${agent}@<version>) or set it: agents use ${agent}@<version>.`);
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// ----- @pinned / @default: synonyms, meta-only fast path -----
|
|
106
|
+
if (qualifier === 'pinned' || qualifier === 'default') {
|
|
107
|
+
const def = getGlobalDefault(agent);
|
|
108
|
+
if (!def) {
|
|
109
|
+
throw new AgentSpecError(`No default version set for ${name}. Run: agents use ${agent}@<version>`);
|
|
110
|
+
}
|
|
111
|
+
push(agent, def);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// ----- @all: every installed version -----
|
|
115
|
+
if (qualifier === 'all') {
|
|
116
|
+
const installed = listInstalledVersions(agent);
|
|
117
|
+
if (installed.length === 0) {
|
|
118
|
+
throw new AgentSpecError(`No managed versions are installed for ${name}. Run: agents add ${agent}@latest`);
|
|
119
|
+
}
|
|
120
|
+
for (const v of installed)
|
|
121
|
+
push(agent, v);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// ----- @latest / @oldest: enumerate (mtime-cached), pick an end -----
|
|
125
|
+
if (qualifier === 'latest' || qualifier === 'oldest') {
|
|
126
|
+
const installed = listInstalledVersions(agent); // already sorted ascending
|
|
127
|
+
if (installed.length === 0) {
|
|
128
|
+
throw new AgentSpecError(`No managed versions are installed for ${name}. Run: agents add ${agent}@latest`);
|
|
129
|
+
}
|
|
130
|
+
push(agent, qualifier === 'oldest' ? installed[0] : installed[installed.length - 1]);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// ----- exact version: one existsSync, NO enumeration -----
|
|
134
|
+
if (!isVersionInstalled(agent, qualifier)) {
|
|
135
|
+
const installed = listInstalledVersions(agent);
|
|
136
|
+
const hint = installed.length ? ` Installed: ${installed.join(', ')}.` : '';
|
|
137
|
+
throw new AgentSpecError(`${name}@${qualifier} is not installed.${hint} Install it: agents add ${agent}@${qualifier}`);
|
|
138
|
+
}
|
|
139
|
+
push(agent, qualifier);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Convenience for single-target commands (`use`, `run`): resolve a spec that
|
|
145
|
+
* must name exactly one installed version. Rejects `@all` / multi-target specs.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveSingleAgentTarget(spec, opts = {}) {
|
|
148
|
+
const targets = resolveAgentTargets(spec, opts);
|
|
149
|
+
if (targets.length !== 1) {
|
|
150
|
+
throw new AgentSpecError(`'${spec}' resolves to ${targets.length} targets; this command needs exactly one.`);
|
|
151
|
+
}
|
|
152
|
+
const t = targets[0];
|
|
153
|
+
if (t.version === null) {
|
|
154
|
+
throw new AgentSpecError(`No installed version for ${AGENTS[t.agent].name}. Run: agents add ${t.agent}@latest`);
|
|
155
|
+
}
|
|
156
|
+
return { agent: t.agent, version: t.version };
|
|
157
|
+
}
|
package/dist/lib/agents.js
CHANGED
|
@@ -296,6 +296,7 @@ export const AGENTS = {
|
|
|
296
296
|
commandsDir: '', // OpenClaw uses Gateway-based slash commands, not file-based
|
|
297
297
|
commandsSubdir: '',
|
|
298
298
|
skillsDir: path.join(HOME, '.openclaw', 'skills'),
|
|
299
|
+
nativeCommandRuntime: true, // Gateway resolves slash commands — don't convert commands to skills
|
|
299
300
|
hooksDir: 'hooks',
|
|
300
301
|
instructionsFile: 'workspace/AGENTS.md', // Primary memory file (also has SOUL.md, IDENTITY.md, etc.)
|
|
301
302
|
format: 'markdown',
|
package/dist/lib/daemon.js
CHANGED
|
@@ -260,6 +260,36 @@ export async function runDaemon() {
|
|
|
260
260
|
};
|
|
261
261
|
const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
|
|
262
262
|
void runSessionSync(); // kick once at startup
|
|
263
|
+
// Resource safety check: heal gaps between what DotAgents repos define and
|
|
264
|
+
// what's actually installed in each agent home — the slow rot that nothing
|
|
265
|
+
// else catches (a non-default version left stale, a Claude-invalid plugin
|
|
266
|
+
// manifest silently rejecting a whole plugin). Conservative 'safe' mode: it
|
|
267
|
+
// fills missing resources, repairs invalid manifests, and fast-forwards
|
|
268
|
+
// provably-unmodified stale plugins, but never overwrites hand-edited content
|
|
269
|
+
// or a plugin it can't prove is pristine — those it reports for `doctor --fix`.
|
|
270
|
+
// Runs ~every 6h plus once ~30s after startup (staggered so launch isn't busy).
|
|
271
|
+
let healing = false;
|
|
272
|
+
const runHealCheck = async () => {
|
|
273
|
+
if (healing)
|
|
274
|
+
return;
|
|
275
|
+
healing = true;
|
|
276
|
+
try {
|
|
277
|
+
const { heal, summarizeHeal, notifyHeal, healChangedAnything } = await import('./heal.js');
|
|
278
|
+
const result = await heal({ mode: 'safe' });
|
|
279
|
+
if (healChangedAnything(result) || result.skippedPlugins.length > 0) {
|
|
280
|
+
log('INFO', `heal: ${summarizeHeal(result)}`);
|
|
281
|
+
notifyHeal(result);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
log('ERROR', `heal check failed: ${err.message}`);
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
healing = false;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
|
|
292
|
+
const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
|
|
263
293
|
const handleReload = () => {
|
|
264
294
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
265
295
|
scheduler.reloadAll();
|
|
@@ -275,6 +305,8 @@ export async function runDaemon() {
|
|
|
275
305
|
await browserIPC.stop();
|
|
276
306
|
clearInterval(monitorInterval);
|
|
277
307
|
clearInterval(syncInterval);
|
|
308
|
+
clearInterval(healInterval);
|
|
309
|
+
clearTimeout(healKickoff);
|
|
278
310
|
removeDaemonPid();
|
|
279
311
|
process.exit(0);
|
|
280
312
|
};
|
|
@@ -59,6 +59,13 @@ export interface DiffOptions {
|
|
|
59
59
|
cwd?: string;
|
|
60
60
|
/** Restrict to specific kinds; undefined = all. */
|
|
61
61
|
kinds?: DoctorKind[];
|
|
62
|
+
/**
|
|
63
|
+
* Drop the project (`<cwd>/.agents/`) layer from resolution. Used by the heal
|
|
64
|
+
* path: the GLOBAL version home is only ever reconciled against user/system/
|
|
65
|
+
* extra sources — project resources are layered at launch, never synced into
|
|
66
|
+
* the global home, so counting them as "missing" there is a false gap.
|
|
67
|
+
*/
|
|
68
|
+
excludeProject?: boolean;
|
|
62
69
|
}
|
|
63
70
|
export declare function diffVersionResources(agent: AgentId, version: string, options?: DiffOptions): VersionResourceReport;
|
|
64
71
|
export declare const DOCTOR_ALL_KINDS: DoctorKind[];
|
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -80,13 +80,13 @@ function buildLayerBases(cwd, kind, opts = {}) {
|
|
|
80
80
|
return out;
|
|
81
81
|
}
|
|
82
82
|
// ─── commands ─────────────────────────────────────────────────────────────────
|
|
83
|
-
function diffCommands(agent, version, cwd) {
|
|
83
|
+
function diffCommands(agent, version, cwd, excludeProject = false) {
|
|
84
84
|
const agentConfig = AGENTS[agent];
|
|
85
85
|
const isToml = agentConfig.format === 'toml';
|
|
86
86
|
const ext = isToml ? '.toml' : '.md';
|
|
87
87
|
const homeDir = getVersionCommandsDir(agent, version);
|
|
88
88
|
const installed = new Set(listCommandsInVersionHome(agent, version));
|
|
89
|
-
const layerBases = buildLayerBases(cwd, 'commands');
|
|
89
|
+
const layerBases = buildLayerBases(cwd, 'commands', { excludeProject });
|
|
90
90
|
const sourceByName = new Map();
|
|
91
91
|
for (const base of layerBases) {
|
|
92
92
|
if (!fs.existsSync(base.path))
|
|
@@ -190,10 +190,10 @@ function dirsContentMatch(src, dst) {
|
|
|
190
190
|
}
|
|
191
191
|
return true;
|
|
192
192
|
}
|
|
193
|
-
function diffSkills(agent, version, cwd) {
|
|
193
|
+
function diffSkills(agent, version, cwd, excludeProject = false) {
|
|
194
194
|
const homeDir = getVersionSkillsDir(agent, version);
|
|
195
195
|
const installed = new Set(listSkillsInVersionHome(agent, version));
|
|
196
|
-
const layerBases = buildLayerBases(cwd, 'skills');
|
|
196
|
+
const layerBases = buildLayerBases(cwd, 'skills', { excludeProject });
|
|
197
197
|
const sourceByName = new Map();
|
|
198
198
|
for (const base of layerBases) {
|
|
199
199
|
if (!fs.existsSync(base.path))
|
|
@@ -298,8 +298,8 @@ function diffHooks(agent, version, cwd) {
|
|
|
298
298
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
299
299
|
}
|
|
300
300
|
// ─── rules / memory ───────────────────────────────────────────────────────────
|
|
301
|
-
function listRulesNames(cwd) {
|
|
302
|
-
const projectDir = getProjectAgentsDir(cwd);
|
|
301
|
+
function listRulesNames(cwd, excludeProject = false) {
|
|
302
|
+
const projectDir = excludeProject ? null : getProjectAgentsDir(cwd);
|
|
303
303
|
const userRules = getUserRulesDir();
|
|
304
304
|
const systemRules = getResolvedRulesDir();
|
|
305
305
|
const extras = getEnabledExtraRepos();
|
|
@@ -349,11 +349,11 @@ function expectedRuleContent(agent, name, sourcePath) {
|
|
|
349
349
|
}
|
|
350
350
|
return readSafe(sourcePath);
|
|
351
351
|
}
|
|
352
|
-
function diffRules(agent, version, cwd) {
|
|
352
|
+
function diffRules(agent, version, cwd, excludeProject = false) {
|
|
353
353
|
const agentConfig = AGENTS[agent];
|
|
354
354
|
const versionHome = getVersionHomePath(agent, version);
|
|
355
355
|
const configDir = path.join(versionHome, agentConfigDirName(agent));
|
|
356
|
-
const sourcesByName = listRulesNames(cwd);
|
|
356
|
+
const sourcesByName = listRulesNames(cwd, excludeProject);
|
|
357
357
|
// Files actually present in the version home.
|
|
358
358
|
const homeFiles = new Set();
|
|
359
359
|
if (fs.existsSync(configDir)) {
|
|
@@ -432,9 +432,14 @@ function diffPromptcuts() {
|
|
|
432
432
|
return [{ kind: 'promptcuts', name: 'promptcuts.yaml', status: 'ok', sourcePath }];
|
|
433
433
|
}
|
|
434
434
|
export function diffVersionResources(agent, version, options = {}) {
|
|
435
|
-
const
|
|
435
|
+
const rawCwd = options.cwd ?? process.cwd();
|
|
436
|
+
const excludeProject = options.excludeProject ?? false;
|
|
436
437
|
const home = getVersionHomePath(agent, version);
|
|
437
438
|
const requested = new Set(options.kinds ?? ALL_KINDS);
|
|
439
|
+
// When excluding the project layer, resolve every per-cwd lookup against a
|
|
440
|
+
// neutral cwd so no `<cwd>/.agents/` is ever discovered.
|
|
441
|
+
const cwd = rawCwd;
|
|
442
|
+
const projectDir = excludeProject ? null : getProjectAgentsDir(cwd);
|
|
438
443
|
const available = getAvailableResources(cwd);
|
|
439
444
|
const synced = getActuallySyncedResources(agent, version, { cwd });
|
|
440
445
|
const empty = {
|
|
@@ -449,13 +454,13 @@ export function diffVersionResources(agent, version, options = {}) {
|
|
|
449
454
|
promptcuts: [],
|
|
450
455
|
};
|
|
451
456
|
if (requested.has('commands'))
|
|
452
|
-
empty.commands = diffCommands(agent, version, cwd);
|
|
457
|
+
empty.commands = diffCommands(agent, version, cwd, excludeProject);
|
|
453
458
|
if (requested.has('skills'))
|
|
454
|
-
empty.skills = diffSkills(agent, version, cwd);
|
|
459
|
+
empty.skills = diffSkills(agent, version, cwd, excludeProject);
|
|
455
460
|
if (requested.has('hooks'))
|
|
456
461
|
empty.hooks = diffHooks(agent, version, cwd);
|
|
457
462
|
if (requested.has('rules'))
|
|
458
|
-
empty.rules = diffRules(agent, version, cwd);
|
|
463
|
+
empty.rules = diffRules(agent, version, cwd, excludeProject);
|
|
459
464
|
if (requested.has('mcp'))
|
|
460
465
|
empty.mcp = diffPresenceOnly('mcp', available.mcp, synced.mcp);
|
|
461
466
|
if (requested.has('permissions'))
|
|
@@ -485,7 +490,7 @@ export function diffVersionResources(agent, version, options = {}) {
|
|
|
485
490
|
home,
|
|
486
491
|
cwd,
|
|
487
492
|
layers: {
|
|
488
|
-
project:
|
|
493
|
+
project: projectDir,
|
|
489
494
|
user: getUserAgentsDir(),
|
|
490
495
|
system: getSystemAgentsDir(),
|
|
491
496
|
extras: getEnabledExtraRepos().map((e) => ({ alias: e.alias, dir: e.dir })),
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export declare function ensureLockTarget(filePath: string, initialContent?: stri
|
|
|
12
12
|
export declare function atomicWriteFileSync(filePath: string, content: string): void;
|
|
13
13
|
/**
|
|
14
14
|
* Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
|
|
15
|
-
* releases the lock. Retries
|
|
16
|
-
* Breaks stale locks older than
|
|
15
|
+
* releases the lock. Retries with capped linear back-off until either the lock
|
|
16
|
+
* is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
|
|
17
|
+
* LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
|
|
17
18
|
*/
|
|
18
19
|
export declare function withFileLock<T>(filePath: string, fn: () => T): T;
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -3,7 +3,18 @@ import * as path from 'path';
|
|
|
3
3
|
import { randomBytes } from 'crypto';
|
|
4
4
|
import lockfile from 'proper-lockfile';
|
|
5
5
|
const LOCK_STALE_MS = 5_000;
|
|
6
|
-
|
|
6
|
+
// Wall-clock budget to acquire the lock before giving up. A count-bounded retry
|
|
7
|
+
// (the old 5 attempts / ~750ms ceiling) could expire while a peer legitimately
|
|
8
|
+
// held the lock — under CI/parallel load two `agents` invocations mutating
|
|
9
|
+
// agents.yaml would have one throw and silently drop its write. The budget must
|
|
10
|
+
// comfortably exceed both a normal critical-section hold and the stale-break
|
|
11
|
+
// window (LOCK_STALE_MS): a dead holder's lock turns stale at 5s and is then
|
|
12
|
+
// broken on the next attempt, so this only ever waits out a live, in-progress
|
|
13
|
+
// holder. Bounded (not unbounded) so a truly wedged holder still surfaces an
|
|
14
|
+
// error instead of hanging the CLI forever.
|
|
15
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
|
|
16
|
+
const LOCK_RETRY_MIN_MS = 50;
|
|
17
|
+
const LOCK_RETRY_MAX_MS = 250;
|
|
7
18
|
// Reused across all sleepSync calls — avoids allocating a new SAB each time.
|
|
8
19
|
const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
9
20
|
export function sleepSync(ms) {
|
|
@@ -46,26 +57,30 @@ export function atomicWriteFileSync(filePath, content) {
|
|
|
46
57
|
}
|
|
47
58
|
/**
|
|
48
59
|
* Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
|
|
49
|
-
* releases the lock. Retries
|
|
50
|
-
* Breaks stale locks older than
|
|
60
|
+
* releases the lock. Retries with capped linear back-off until either the lock
|
|
61
|
+
* is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
|
|
62
|
+
* LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
|
|
51
63
|
*/
|
|
52
64
|
export function withFileLock(filePath, fn) {
|
|
53
65
|
let release = null;
|
|
54
66
|
let lastError;
|
|
55
|
-
|
|
67
|
+
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
|
|
68
|
+
for (let attempt = 0;; attempt++) {
|
|
56
69
|
try {
|
|
57
70
|
release = lockfile.lockSync(filePath, { stale: LOCK_STALE_MS });
|
|
58
71
|
break;
|
|
59
72
|
}
|
|
60
73
|
catch (err) {
|
|
61
74
|
lastError = err;
|
|
62
|
-
if (
|
|
63
|
-
|
|
75
|
+
if (Date.now() >= deadline)
|
|
76
|
+
break;
|
|
77
|
+
const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
|
|
78
|
+
sleepSync(Math.min(backoff, Math.max(0, deadline - Date.now())));
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
if (!release) {
|
|
67
82
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
68
|
-
throw new Error(`Could not acquire lock for ${filePath}: ${message}`);
|
|
83
|
+
throw new Error(`Could not acquire lock for ${filePath} after ${LOCK_ACQUIRE_TIMEOUT_MS}ms: ${message}`);
|
|
69
84
|
}
|
|
70
85
|
try {
|
|
71
86
|
return fn();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource heal engine — close the gap between what DotAgents repos DEFINE and
|
|
3
|
+
* what is actually present/valid in each installed agent home.
|
|
4
|
+
*
|
|
5
|
+
* Powers two callers:
|
|
6
|
+
* - `agents doctor --fix` — explicit, operator-driven. Mode 'full': fills
|
|
7
|
+
* missing, overwrites drifted content, and refreshes stale plugins even when
|
|
8
|
+
* the baseline is unknown (the operator asked for it).
|
|
9
|
+
* - the routines daemon's periodic safety check — Mode 'safe': fixes only the
|
|
10
|
+
* unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
|
|
11
|
+
* provably-unmodified stale plugins). Drift and risky refreshes are reported,
|
|
12
|
+
* never clobbered.
|
|
13
|
+
*
|
|
14
|
+
* Built on the LIVE-home diff (`diffVersionResources`) — NOT the staleness
|
|
15
|
+
* manifest. `isStale()` only compares the last-synced manifest against the
|
|
16
|
+
* sources, so home-side rot (a deleted, corrupted, or Claude-rejected file in a
|
|
17
|
+
* version home whose source never changed) is invisible to it and to the sync
|
|
18
|
+
* fast-guard. The diff reads the actual home, so heal catches exactly that class
|
|
19
|
+
* of drift — the kind that silently broke the `code` plugin on a non-default
|
|
20
|
+
* Claude version.
|
|
21
|
+
*
|
|
22
|
+
* Heal FILLS and FIXES; it never deletes. Orphan/extra removal stays the job of
|
|
23
|
+
* `agents prune cleanup`, so a heal pass can never lose work.
|
|
24
|
+
*/
|
|
25
|
+
import type { AgentId } from './types.js';
|
|
26
|
+
import { type DoctorKind, type DiffStatus } from './doctor-diff.js';
|
|
27
|
+
export interface HealedResource {
|
|
28
|
+
kind: DoctorKind;
|
|
29
|
+
name: string;
|
|
30
|
+
/** Why it was healed: 'missing' (filled) or 'diff' (overwritten / re-pushed). */
|
|
31
|
+
was: DiffStatus;
|
|
32
|
+
}
|
|
33
|
+
export interface SkippedResource {
|
|
34
|
+
kind: DoctorKind;
|
|
35
|
+
name: string;
|
|
36
|
+
/** 'drift': hand-edited content left untouched in 'safe' mode.
|
|
37
|
+
* 'unreconcilable': heal wrote it but the diff still flags it — a source/home
|
|
38
|
+
* asymmetry the writer can't satisfy (e.g. a hook sidecar the installer omits),
|
|
39
|
+
* surfaced honestly instead of "fixed" on every pass. */
|
|
40
|
+
reason: 'drift' | 'unreconcilable';
|
|
41
|
+
}
|
|
42
|
+
export interface VersionHealResult {
|
|
43
|
+
agent: AgentId;
|
|
44
|
+
version: string;
|
|
45
|
+
healed: HealedResource[];
|
|
46
|
+
skipped: SkippedResource[];
|
|
47
|
+
}
|
|
48
|
+
export interface ManifestRepairResult {
|
|
49
|
+
plugin: string;
|
|
50
|
+
/** Bare-name fields stripped from the source plugin.json (e.g. ["skills"]). */
|
|
51
|
+
droppedFields: string[];
|
|
52
|
+
}
|
|
53
|
+
export interface PluginRefreshResult {
|
|
54
|
+
plugin: string;
|
|
55
|
+
from: string;
|
|
56
|
+
to: string;
|
|
57
|
+
}
|
|
58
|
+
export interface PluginRefreshSkip {
|
|
59
|
+
plugin: string;
|
|
60
|
+
from: string;
|
|
61
|
+
upstream: string;
|
|
62
|
+
/** 'modified': central diverged from baseline. 'no-baseline': pre-tracking install. */
|
|
63
|
+
reason: 'modified' | 'no-baseline';
|
|
64
|
+
}
|
|
65
|
+
export interface HealResult {
|
|
66
|
+
versions: VersionHealResult[];
|
|
67
|
+
repairedManifests: ManifestRepairResult[];
|
|
68
|
+
refreshedPlugins: PluginRefreshResult[];
|
|
69
|
+
skippedPlugins: PluginRefreshSkip[];
|
|
70
|
+
}
|
|
71
|
+
export interface HealOptions {
|
|
72
|
+
/** 'full' (doctor --fix): fix drift + refresh unknown-baseline plugins.
|
|
73
|
+
* 'safe' (daemon): missing + invalid-manifest + unmodified refresh only. */
|
|
74
|
+
mode: 'full' | 'safe';
|
|
75
|
+
/** Resolution cwd. Defaults to the home dir so no project layer is ever
|
|
76
|
+
* resolved — heal targets the GLOBAL install, never a project. Tests override. */
|
|
77
|
+
cwd?: string;
|
|
78
|
+
/** Scope to one agent; omit to heal every installed agent. */
|
|
79
|
+
agent?: AgentId;
|
|
80
|
+
/** Scope to specific versions of `agent`; omit for all installed versions. */
|
|
81
|
+
versions?: string[];
|
|
82
|
+
/** Compute the plan without writing anything. */
|
|
83
|
+
dryRun?: boolean;
|
|
84
|
+
}
|
|
85
|
+
/** True when a heal pass made (or would make) any change at all. */
|
|
86
|
+
export declare function healChangedAnything(r: HealResult): boolean;
|
|
87
|
+
/** One-line summary of a heal pass for daemon logs. */
|
|
88
|
+
export declare function summarizeHeal(r: HealResult): string;
|
|
89
|
+
/**
|
|
90
|
+
* Fire a native desktop notification when a background heal did something
|
|
91
|
+
* noteworthy. Best-effort — missing `osascript`/`notify-send` or no display is
|
|
92
|
+
* swallowed. Silent when the pass auto-fixed everything and nothing needs the
|
|
93
|
+
* operator (no point pinging them for routine self-healing).
|
|
94
|
+
*/
|
|
95
|
+
export declare function notifyHeal(r: HealResult): void;
|
|
96
|
+
/**
|
|
97
|
+
* Strip Claude-invalid bare-name `skills`/`commands` fields from every central
|
|
98
|
+
* plugin's SOURCE plugin.json. Unambiguously safe (Claude auto-discovers both
|
|
99
|
+
* from their directories) and the precondition for those plugins loading at all.
|
|
100
|
+
*/
|
|
101
|
+
export declare function repairCentralPluginManifests(dryRun?: boolean): ManifestRepairResult[];
|
|
102
|
+
/**
|
|
103
|
+
* Run a heal pass. Repairs the central plugin layer once (manifest + stale
|
|
104
|
+
* refresh), then reconciles every targeted (agent, version) home against its
|
|
105
|
+
* live diff. Returns a full account of what changed (or would, under dryRun).
|
|
106
|
+
*/
|
|
107
|
+
export declare function heal(opts: HealOptions): Promise<HealResult>;
|