@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +126 -27
  4. package/dist/commands/inspect.d.ts +2 -1
  5. package/dist/commands/inspect.js +1 -1
  6. package/dist/commands/menubar.js +6 -1
  7. package/dist/commands/repo.js +40 -0
  8. package/dist/commands/secrets.js +16 -12
  9. package/dist/commands/sessions.js +20 -1
  10. package/dist/index.js +2 -12
  11. package/dist/lib/agent-spec.d.ts +36 -0
  12. package/dist/lib/agent-spec.js +157 -0
  13. package/dist/lib/agents.js +1 -0
  14. package/dist/lib/daemon.js +32 -0
  15. package/dist/lib/doctor-diff.d.ts +7 -0
  16. package/dist/lib/doctor-diff.js +18 -13
  17. package/dist/lib/fs-atomic.d.ts +3 -2
  18. package/dist/lib/fs-atomic.js +22 -7
  19. package/dist/lib/heal.d.ts +107 -0
  20. package/dist/lib/heal.js +279 -0
  21. package/dist/lib/hooks.js +36 -1
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  23. package/dist/lib/menubar/install-menubar.d.ts +27 -3
  24. package/dist/lib/menubar/install-menubar.js +74 -9
  25. package/dist/lib/plugin-marketplace.d.ts +18 -0
  26. package/dist/lib/plugin-marketplace.js +67 -1
  27. package/dist/lib/plugins.d.ts +23 -1
  28. package/dist/lib/plugins.js +55 -10
  29. package/dist/lib/resources/rules.d.ts +5 -2
  30. package/dist/lib/resources/rules.js +39 -10
  31. package/dist/lib/rules/compose.d.ts +22 -0
  32. package/dist/lib/rules/compose.js +114 -9
  33. package/dist/lib/secrets/agent.d.ts +4 -2
  34. package/dist/lib/secrets/agent.js +6 -4
  35. package/dist/lib/secrets/bundles.d.ts +20 -14
  36. package/dist/lib/secrets/bundles.js +31 -10
  37. package/dist/lib/session/remote.d.ts +33 -0
  38. package/dist/lib/session/remote.js +114 -0
  39. package/dist/lib/staleness/checkers/rules.js +13 -1
  40. package/dist/lib/staleness/detectors/commands.js +7 -6
  41. package/dist/lib/staleness/writers/commands.js +7 -12
  42. package/dist/lib/startup/dev-build.d.ts +22 -0
  43. package/dist/lib/startup/dev-build.js +41 -0
  44. package/dist/lib/types.d.ts +13 -2
  45. package/dist/lib/versions.d.ts +3 -1
  46. package/dist/lib/versions.js +35 -3
  47. 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
+ }
@@ -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',
@@ -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[];
@@ -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 cwd = options.cwd ?? process.cwd();
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: getProjectAgentsDir(cwd),
493
+ project: projectDir,
489
494
  user: getUserAgentsDir(),
490
495
  system: getSystemAgentsDir(),
491
496
  extras: getEnabledExtraRepos().map((e) => ({ alias: e.alias, dir: e.dir })),
@@ -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 up to LOCK_RETRIES times with linear back-off.
16
- * Breaks stale locks older than LOCK_STALE_MS.
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;
@@ -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
- const LOCK_RETRIES = 5;
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 up to LOCK_RETRIES times with linear back-off.
50
- * Breaks stale locks older than LOCK_STALE_MS.
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
- for (let attempt = 0; attempt <= LOCK_RETRIES; attempt++) {
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 (attempt < LOCK_RETRIES)
63
- sleepSync(50 * (attempt + 1));
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>;