@phnx-labs/agents-cli 1.20.45 → 1.20.47

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 (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/secrets-import.d.ts +18 -0
  3. package/dist/commands/secrets-import.js +74 -0
  4. package/dist/commands/secrets.js +2 -0
  5. package/dist/index.js +14 -119
  6. package/dist/lib/daemon.js +40 -6
  7. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  8. package/dist/lib/secrets/fallback.d.ts +48 -0
  9. package/dist/lib/secrets/fallback.js +48 -0
  10. package/dist/lib/secrets/index.d.ts +11 -0
  11. package/dist/lib/secrets/index.js +20 -2
  12. package/dist/lib/secrets/linux.d.ts +7 -0
  13. package/dist/lib/secrets/linux.js +113 -5
  14. package/dist/lib/secrets/windows.d.ts +7 -0
  15. package/dist/lib/secrets/windows.js +110 -5
  16. package/dist/lib/self-heal/checks/path.d.ts +2 -0
  17. package/dist/lib/self-heal/checks/path.js +30 -0
  18. package/dist/lib/self-heal/checks/resources.d.ts +2 -0
  19. package/dist/lib/self-heal/checks/resources.js +36 -0
  20. package/dist/lib/self-heal/checks/shadowing.d.ts +2 -0
  21. package/dist/lib/self-heal/checks/shadowing.js +48 -0
  22. package/dist/lib/self-heal/checks/shims.d.ts +2 -0
  23. package/dist/lib/self-heal/checks/shims.js +35 -0
  24. package/dist/lib/self-heal/registry.d.ts +22 -0
  25. package/dist/lib/self-heal/registry.js +66 -0
  26. package/dist/lib/self-heal/types.d.ts +41 -0
  27. package/dist/lib/self-heal/types.js +21 -0
  28. package/dist/lib/session/discover.js +41 -0
  29. package/dist/lib/shim-heal.d.ts +23 -0
  30. package/dist/lib/shim-heal.js +109 -0
  31. package/dist/lib/shims.d.ts +6 -0
  32. package/dist/lib/shims.js +1 -1
  33. package/dist/lib/versions.d.ts +16 -0
  34. package/dist/lib/versions.js +83 -12
  35. package/package.json +1 -1
@@ -19,6 +19,7 @@
19
19
  import { spawnSync } from 'child_process';
20
20
  import * as os from 'os';
21
21
  import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
22
+ import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
22
23
  // Re-exported so existing importers (and tests) can keep reaching these via
23
24
  // './linux.js'. The implementations live in ./filestore.ts.
24
25
  export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
@@ -35,13 +36,16 @@ let isAvailable = false;
35
36
  // ---------- file fallback state ----------
36
37
  let useFileFallback = false;
37
38
  let warnedFallback = false;
39
+ // Set once the keyring is observed locked/unreachable in this process, so the
40
+ // read-through in get/has stops re-probing it (and stops re-emitting notices).
41
+ let nativeUnreachable = false;
38
42
  function activateFileFallback() {
39
43
  if (useFileFallback)
40
44
  return;
41
45
  useFileFallback = true;
42
46
  if (!warnedFallback) {
43
47
  warnedFallback = true;
44
- process.stderr.write(`[agents] secret-service collection locked, using file-based store at ${fileDir()}\n`);
48
+ process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
45
49
  }
46
50
  }
47
51
  function isLockedCollectionError(stderr) {
@@ -111,8 +115,21 @@ export function usesFileFallback() {
111
115
  /** secret-tool lookup attributes:
112
116
  * service=agents-cli account=<user> item=<itemName> */
113
117
  export function hasSecretToolToken(item) {
114
- if (preflight() === 'file')
115
- return fileStore.has(item);
118
+ if (preflight() === 'file') {
119
+ if (fileStore.has(item))
120
+ return true;
121
+ // The file store is primary under the fallback, but an item can still live
122
+ // only in an (unlocked) keyring that predates it — read through so it isn't
123
+ // silently shadowed.
124
+ const probe = readNativeItemRaw(item);
125
+ if (probe.value !== undefined) {
126
+ noteNativeShadow('shadowed', fileDir());
127
+ return true;
128
+ }
129
+ if (probe.locked)
130
+ noteNativeShadow('locked', fileDir());
131
+ return false;
132
+ }
116
133
  const user = os.userInfo().username;
117
134
  const result = spawnSync('secret-tool', [
118
135
  'lookup',
@@ -125,14 +142,25 @@ export function hasSecretToolToken(item) {
125
142
  }
126
143
  const stderr = result.stderr?.toString() ?? '';
127
144
  if (isLockedCollectionError(stderr)) {
145
+ nativeUnreachable = true;
128
146
  activateFileFallback();
129
147
  return fileStore.has(item);
130
148
  }
131
149
  return false;
132
150
  }
133
151
  export function getSecretToolToken(item) {
134
- if (preflight() === 'file')
135
- return fileStore.get(item);
152
+ if (preflight() === 'file') {
153
+ if (fileStore.has(item))
154
+ return fileStore.get(item);
155
+ const probe = readNativeItemRaw(item);
156
+ if (probe.value !== undefined) {
157
+ noteNativeShadow('shadowed', fileDir());
158
+ return probe.value;
159
+ }
160
+ if (probe.locked)
161
+ noteNativeShadow('locked', fileDir());
162
+ throw new Error(`Secret '${item}' not found in the file store or keyring.`);
163
+ }
136
164
  const user = os.userInfo().username;
137
165
  const result = spawnSync('secret-tool', [
138
166
  'lookup',
@@ -148,6 +176,7 @@ export function getSecretToolToken(item) {
148
176
  }
149
177
  const stderr = result.stderr?.toString() ?? '';
150
178
  if (isLockedCollectionError(stderr)) {
179
+ nativeUnreachable = true;
151
180
  activateFileFallback();
152
181
  return fileStore.get(item);
153
182
  }
@@ -171,6 +200,7 @@ export function setSecretToolToken(item, value) {
171
200
  return;
172
201
  const stderr = result.stderr?.toString().trim() ?? '';
173
202
  if (isLockedCollectionError(stderr)) {
203
+ nativeUnreachable = true;
174
204
  activateFileFallback();
175
205
  fileStore.set(item, value);
176
206
  return;
@@ -193,6 +223,7 @@ export function deleteSecretToolToken(item) {
193
223
  return true;
194
224
  const stderr = result.stderr?.toString() ?? '';
195
225
  if (isLockedCollectionError(stderr)) {
226
+ nativeUnreachable = true;
196
227
  activateFileFallback();
197
228
  return fileStore.delete(item);
198
229
  }
@@ -252,6 +283,81 @@ export function listSecretToolItems(prefix) {
252
283
  const output = `${result.stdout?.toString() || ''}\n${result.stderr?.toString() || ''}`;
253
284
  return parseSecretToolItems(output, prefix);
254
285
  }
286
+ // ---------- native-direct helpers (bypass preflight routing) ----------
287
+ //
288
+ // These always talk to secret-tool regardless of whether the process has fallen
289
+ // back to the file store. They power (a) the read-through that keeps the file
290
+ // store from silently shadowing keyring items, and (b) `import-keyring`.
291
+ /**
292
+ * Read one item straight from the keyring. Returns `{value}` on a hit,
293
+ * `{locked:true}` when the collection is locked/unreachable, and `{}` on a plain
294
+ * miss. Never throws and never emits — the caller decides whether to notice.
295
+ */
296
+ function readNativeItemRaw(item) {
297
+ if (nativeUnreachable)
298
+ return { locked: true };
299
+ if (!secretToolAvailable())
300
+ return {};
301
+ const user = os.userInfo().username;
302
+ const r = spawnSync('secret-tool', [
303
+ 'lookup', 'service', SERVICE, 'account', user, 'item', item,
304
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
305
+ if (r.status === 0) {
306
+ const v = r.stdout?.toString().trim();
307
+ return v && v.length ? { value: v } : {};
308
+ }
309
+ if (isLockedCollectionError(r.stderr?.toString() ?? '')) {
310
+ nativeUnreachable = true;
311
+ return { locked: true };
312
+ }
313
+ return {};
314
+ }
315
+ /**
316
+ * Enumerate agents-cli items in the keyring whose name starts with `prefix`.
317
+ * `available` is false when secret-tool isn't installed; `locked` is true when
318
+ * the collection is locked.
319
+ */
320
+ function listNativeItemsRaw(prefix) {
321
+ if (!secretToolAvailable())
322
+ return { items: [], locked: false, available: false };
323
+ const r = spawnSync('secret-tool', [
324
+ 'search', '--all', 'service', SERVICE,
325
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
326
+ if (r.status !== 0) {
327
+ const locked = isLockedCollectionError(r.stderr?.toString() ?? '');
328
+ if (locked)
329
+ nativeUnreachable = true;
330
+ return { items: [], locked, available: true };
331
+ }
332
+ const output = `${r.stdout?.toString() || ''}\n${r.stderr?.toString() || ''}`;
333
+ return { items: parseSecretToolItems(output, prefix), locked: false, available: true };
334
+ }
335
+ /**
336
+ * Copy agents-cli items from the keyring into the file store (the `import-keyring`
337
+ * backend for Linux). Requires an unlocked keyring; items already in the file
338
+ * store are left untouched. With `commit=false` it reports what it *would* do.
339
+ */
340
+ export function importNativeSecretToolItems(prefix, commit) {
341
+ const { items, locked, available } = listNativeItemsRaw(prefix);
342
+ if (!available || locked)
343
+ return { available, locked, results: [] };
344
+ const results = [];
345
+ for (const item of items) {
346
+ if (fileStore.has(item)) {
347
+ results.push({ item, status: 'exists' });
348
+ continue;
349
+ }
350
+ const probe = readNativeItemRaw(item);
351
+ if (probe.value === undefined) {
352
+ results.push({ item, status: 'failed', detail: probe.locked ? 'keyring locked' : 'unreadable' });
353
+ continue;
354
+ }
355
+ if (commit)
356
+ fileStore.set(item, probe.value);
357
+ results.push({ item, status: commit ? 'imported' : 'would-import' });
358
+ }
359
+ return { available, locked, results };
360
+ }
255
361
  /** KeychainBackend implementation for Linux. Routes through secret-tool
256
362
  * with a transparent encrypted-file fallback when the default Secret
257
363
  * Service collection is locked (or libsecret-tools is not installed but
@@ -280,6 +386,8 @@ export function _resetForTest(opts = {}) {
280
386
  _resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
281
387
  useFileFallback = opts.forceFileFallback ?? false;
282
388
  warnedFallback = false;
389
+ nativeUnreachable = false;
283
390
  checkedAvailability = false;
284
391
  isAvailable = false;
392
+ _resetFallbackNoticeForTest();
285
393
  }
@@ -27,6 +27,7 @@
27
27
  * names directly.
28
28
  */
29
29
  import type { KeychainBackend } from './index.js';
30
+ import { type NativeImportReport } from './fallback.js';
30
31
  export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
31
32
  /**
32
33
  * CRED_MAX_CREDENTIAL_BLOB_SIZE — Credential Manager rejects a generic
@@ -53,6 +54,12 @@ export declare function listCredManItems(prefix: string): string[];
53
54
  * parseSecretToolItems (linux.ts). Exported for tests.
54
55
  */
55
56
  export declare function parseWindowsCredList(output: string, prefix: string): string[];
57
+ /**
58
+ * Copy agents-cli credentials from Credential Manager into the file store (the
59
+ * `import-keyring` backend for Windows). Requires a reachable store; items
60
+ * already in the file store are left untouched.
61
+ */
62
+ export declare function importNativeCredManItems(prefix: string, commit: boolean): NativeImportReport;
56
63
  /**
57
64
  * KeychainBackend implementation for Windows. Routes through Windows Credential
58
65
  * Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
@@ -29,6 +29,7 @@
29
29
  import { spawnSync } from 'child_process';
30
30
  import { encodePwshBase64 } from '../pwsh.js';
31
31
  import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
32
+ import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
32
33
  // Re-exported so importers (and tests) can keep reaching these via './windows.js'.
33
34
  export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
34
35
  const POWERSHELL = 'powershell.exe';
@@ -250,13 +251,16 @@ let isAvailable = false;
250
251
  // ---------- file fallback state ----------
251
252
  let useFileFallback = false;
252
253
  let warnedFallback = false;
254
+ // Set once Credential Manager is observed unreachable in this process, so the
255
+ // read-through in get/has stops re-probing it (and re-emitting notices).
256
+ let nativeUnreachable = false;
253
257
  function activateFileFallback() {
254
258
  if (useFileFallback)
255
259
  return;
256
260
  useFileFallback = true;
257
261
  if (!warnedFallback) {
258
262
  warnedFallback = true;
259
- process.stderr.write(`[agents] Windows Credential Manager unavailable, using file-based store at ${fileDir()}\n`);
263
+ process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
260
264
  }
261
265
  }
262
266
  /**
@@ -316,22 +320,45 @@ export function usesFileFallback() {
316
320
  }
317
321
  // ---------- Credential Manager ops with fallback ----------
318
322
  export function hasCredManToken(item) {
319
- if (preflight() === 'file')
320
- return fileStore.has(item);
323
+ if (preflight() === 'file') {
324
+ if (fileStore.has(item))
325
+ return true;
326
+ // Read through to Credential Manager so an item that predates the fallback
327
+ // isn't silently shadowed by the file store.
328
+ const probe = readNativeCredItemRaw(item);
329
+ if (probe.value !== undefined) {
330
+ noteNativeShadow('shadowed', fileDir());
331
+ return true;
332
+ }
333
+ if (probe.unavailable)
334
+ noteNativeShadow('locked', fileDir());
335
+ return false;
336
+ }
321
337
  const r = runCred('has', { target: item });
322
338
  if (r.status === 0)
323
339
  return true;
324
340
  if (r.status === 3)
325
341
  return false;
326
342
  if (isCredManUnavailableError(r)) {
343
+ nativeUnreachable = true;
327
344
  activateFileFallback();
328
345
  return fileStore.has(item);
329
346
  }
330
347
  return false;
331
348
  }
332
349
  export function getCredManToken(item) {
333
- if (preflight() === 'file')
334
- return fileStore.get(item);
350
+ if (preflight() === 'file') {
351
+ if (fileStore.has(item))
352
+ return fileStore.get(item);
353
+ const probe = readNativeCredItemRaw(item);
354
+ if (probe.value !== undefined) {
355
+ noteNativeShadow('shadowed', fileDir());
356
+ return probe.value;
357
+ }
358
+ if (probe.unavailable)
359
+ noteNativeShadow('locked', fileDir());
360
+ throw new Error(`Secret '${item}' not found in the file store or Credential Manager.`);
361
+ }
335
362
  const r = runCred('get', { target: item });
336
363
  if (r.status === 0) {
337
364
  // stdout is base64 of the raw UTF-8 blob (dodges PowerShell encoding corruption).
@@ -340,6 +367,7 @@ export function getCredManToken(item) {
340
367
  if (r.status === 3)
341
368
  throw new Error(`Secret '${item}' not found in Credential Manager.`);
342
369
  if (isCredManUnavailableError(r)) {
370
+ nativeUnreachable = true;
343
371
  activateFileFallback();
344
372
  return fileStore.get(item);
345
373
  }
@@ -362,6 +390,7 @@ export function setCredManToken(item, value) {
362
390
  if (r.status === 0)
363
391
  return;
364
392
  if (isCredManUnavailableError(r)) {
393
+ nativeUnreachable = true;
365
394
  activateFileFallback();
366
395
  fileStore.set(item, value);
367
396
  return;
@@ -377,6 +406,7 @@ export function deleteCredManToken(item) {
377
406
  if (r.status === 3)
378
407
  return false;
379
408
  if (isCredManUnavailableError(r)) {
409
+ nativeUnreachable = true;
380
410
  activateFileFallback();
381
411
  return fileStore.delete(item);
382
412
  }
@@ -389,6 +419,7 @@ export function listCredManItems(prefix) {
389
419
  if (r.status === 0)
390
420
  return parseWindowsCredList(r.stdout, prefix);
391
421
  if (isCredManUnavailableError(r)) {
422
+ nativeUnreachable = true;
392
423
  activateFileFallback();
393
424
  return fileStore.list(prefix);
394
425
  }
@@ -407,6 +438,78 @@ export function parseWindowsCredList(output, prefix) {
407
438
  .filter((s) => s.startsWith(prefix));
408
439
  return [...new Set(items)]; // dedupe
409
440
  }
441
+ // ---------- native-direct helpers (bypass preflight routing) ----------
442
+ //
443
+ // Always talk to Credential Manager regardless of the file fallback. They power
444
+ // (a) the read-through that keeps the file store from shadowing credman items,
445
+ // and (b) `import-keyring`.
446
+ /**
447
+ * Read one item straight from Credential Manager. `{value}` on hit,
448
+ * `{unavailable:true}` when the store is unreachable, `{}` on a plain miss.
449
+ * Never throws, never emits.
450
+ */
451
+ function readNativeCredItemRaw(item) {
452
+ if (nativeUnreachable)
453
+ return { unavailable: true };
454
+ if (!powershellAvailable())
455
+ return {};
456
+ const r = runCred('get', { target: item });
457
+ if (r.status === 0)
458
+ return { value: Buffer.from(r.stdout.trim(), 'base64').toString('utf8') };
459
+ if (r.status === 3)
460
+ return {};
461
+ if (isCredManUnavailableError(r)) {
462
+ nativeUnreachable = true;
463
+ return { unavailable: true };
464
+ }
465
+ return {};
466
+ }
467
+ /**
468
+ * Enumerate agents-cli credentials under `prefix`. Windows credentials have no
469
+ * service scoping — the target IS the identifier — so we NEVER enumerate with an
470
+ * empty filter (that returns unrelated machine credentials). The filter is
471
+ * floored to the `agents-cli.` namespace; bare items (unprefixed targets) are
472
+ * therefore out of scope for auto-discovery on Windows.
473
+ */
474
+ function listNativeCredItemsRaw(prefix) {
475
+ if (!powershellAvailable())
476
+ return { items: [], locked: false, available: false };
477
+ const floor = prefix && prefix.startsWith('agents-cli.') ? prefix : 'agents-cli.';
478
+ const r = runCred('list', { prefix: floor });
479
+ if (r.status === 0)
480
+ return { items: parseWindowsCredList(r.stdout, floor), locked: false, available: true };
481
+ if (isCredManUnavailableError(r)) {
482
+ nativeUnreachable = true;
483
+ return { items: [], locked: true, available: true };
484
+ }
485
+ return { items: [], locked: false, available: true };
486
+ }
487
+ /**
488
+ * Copy agents-cli credentials from Credential Manager into the file store (the
489
+ * `import-keyring` backend for Windows). Requires a reachable store; items
490
+ * already in the file store are left untouched.
491
+ */
492
+ export function importNativeCredManItems(prefix, commit) {
493
+ const { items, locked, available } = listNativeCredItemsRaw(prefix);
494
+ if (!available || locked)
495
+ return { available, locked, results: [] };
496
+ const results = [];
497
+ for (const item of items) {
498
+ if (fileStore.has(item)) {
499
+ results.push({ item, status: 'exists' });
500
+ continue;
501
+ }
502
+ const probe = readNativeCredItemRaw(item);
503
+ if (probe.value === undefined) {
504
+ results.push({ item, status: 'failed', detail: probe.unavailable ? 'credential manager unavailable' : 'unreadable' });
505
+ continue;
506
+ }
507
+ if (commit)
508
+ fileStore.set(item, probe.value);
509
+ results.push({ item, status: commit ? 'imported' : 'would-import' });
510
+ }
511
+ return { available, locked, results };
512
+ }
410
513
  /**
411
514
  * KeychainBackend implementation for Windows. Routes through Windows Credential
412
515
  * Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
@@ -440,6 +543,8 @@ export function _resetForTest(opts = {}) {
440
543
  _resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
441
544
  useFileFallback = opts.forceFileFallback ?? false;
442
545
  warnedFallback = false;
546
+ nativeUnreachable = false;
547
+ _resetFallbackNoticeForTest();
443
548
  if (opts.forceAvailable === undefined || opts.forceAvailable === null) {
444
549
  checkedAvailability = false;
445
550
  isAvailable = false;
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const pathCheck: HealCheck;
@@ -0,0 +1,30 @@
1
+ // path check — ensures the shims dir is on PATH. On POSIX it appends to the shell
2
+ // rc file; on Windows it registers on the user PATH (registry). addShimsToPath is a
3
+ // no-op when already present, so this is idempotent. Formerly an interactive prompt
4
+ // in index.ts fired on every new shell; here the daemon does it once, silently.
5
+ //
6
+ // Caveat: an already-open shell won't pick up the new rc/PATH entry until it reloads
7
+ // — but new shells will, so the recurring prompt stops.
8
+ import { resultOf } from '../types.js';
9
+ import { isShimsInPath, addShimsToPath } from '../../shims.js';
10
+ export const pathCheck = {
11
+ id: 'path',
12
+ title: 'Shims directory on PATH',
13
+ cadence: 'startup',
14
+ async run(ctx) {
15
+ if (isShimsInPath())
16
+ return resultOf([], []);
17
+ if (ctx.dryRun)
18
+ return resultOf(['add shims dir to PATH'], []);
19
+ const r = addShimsToPath();
20
+ if (r.success && !r.alreadyPresent) {
21
+ return resultOf([`added shims to PATH (${r.location ?? r.rcFile ?? 'PATH'})`], []);
22
+ }
23
+ if (r.success && r.alreadyPresent) {
24
+ // Present in the rc file but not in THIS process's PATH — a reload issue,
25
+ // not something to fix again. Report quietly.
26
+ return resultOf([], [`shims dir in ${r.rcFile ?? 'rc file'} but not loaded — open a new terminal`]);
27
+ }
28
+ return resultOf([], [`could not add shims to PATH: ${r.error ?? 'unknown'}`]);
29
+ },
30
+ };
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const resourcesCheck: HealCheck;
@@ -0,0 +1,36 @@
1
+ // resources check — reconciles each installed version's home against the DotAgents
2
+ // definitions (commands, skills, hooks, rules, mcp, plugins). This is a thin adapter
3
+ // over the existing, battle-tested heal() engine (lib/heal.ts) — no behavior change;
4
+ // it just re-expresses heal()'s result in the unified CheckResult shape.
5
+ import { resultOf } from '../types.js';
6
+ export const resourcesCheck = {
7
+ id: 'resources',
8
+ title: 'Resource sync (commands, skills, hooks, rules, plugins)',
9
+ cadence: 'periodic',
10
+ async run(ctx) {
11
+ // Lazy import so the (heavy) heal graph only loads when this check actually runs.
12
+ const { heal } = await import('../../heal.js');
13
+ const result = await heal({ mode: ctx.mode, dryRun: ctx.dryRun });
14
+ const fixed = [];
15
+ const needsAttention = [];
16
+ let healed = 0;
17
+ for (const v of result.versions) {
18
+ healed += v.healed.length;
19
+ for (const s of v.skipped) {
20
+ needsAttention.push(`${v.agent}@${v.version}: ${s.kind}/${s.name} (${s.reason})`);
21
+ }
22
+ }
23
+ if (healed > 0)
24
+ fixed.push(`${healed} resource(s) reconciled`);
25
+ for (const m of result.repairedManifests) {
26
+ fixed.push(`plugin ${m.plugin}: dropped ${m.droppedFields.join(', ')}`);
27
+ }
28
+ for (const p of result.refreshedPlugins) {
29
+ fixed.push(`plugin ${p.plugin}: ${p.from} -> ${p.to}`);
30
+ }
31
+ for (const s of result.skippedPlugins) {
32
+ needsAttention.push(`plugin ${s.plugin}: ${s.reason} (${s.from} vs ${s.upstream})`);
33
+ }
34
+ return resultOf(fixed, needsAttention);
35
+ },
36
+ };
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const shadowingCheck: HealCheck;
@@ -0,0 +1,48 @@
1
+ // shadowing check — when a harness's own launcher shadows our shim on PATH, adopt
2
+ // it (symlink-only, reversible) so version management wins regardless of PATH order.
3
+ // A REAL native binary is never moved — it's surfaced as needsAttention so the
4
+ // interactive layer can inform the user once. POSIX-only (the launcher convention
5
+ // and PATH-order problem are POSIX; Windows resolves via the registry PATH).
6
+ import { resultOf } from '../types.js';
7
+ import { AGENTS } from '../../agents.js';
8
+ import { getPathShadowingExecutable, adoptShadowingLauncher, listAgentsWithInstalledVersions, } from '../../shims.js';
9
+ import { getGlobalDefault } from '../../versions.js';
10
+ export const shadowingCheck = {
11
+ id: 'shadowing',
12
+ title: 'Launcher shadowing the version-managed shim',
13
+ platforms: ['darwin', 'linux'],
14
+ cadence: 'frequent',
15
+ async run(ctx) {
16
+ const fixed = [];
17
+ const needsAttention = [];
18
+ for (const agent of listAgentsWithInstalledVersions()) {
19
+ if (!getGlobalDefault(agent))
20
+ continue; // only default agents, like the interactive flow
21
+ const cmd = AGENTS[agent].cliCommand;
22
+ const shadowedBy = getPathShadowingExecutable(agent);
23
+ if (!shadowedBy)
24
+ continue;
25
+ if (ctx.dryRun) {
26
+ // Classify without mutating: adoption only ever touches a symlink.
27
+ let isSymlink = false;
28
+ try {
29
+ const fs = await import('node:fs');
30
+ isSymlink = fs.lstatSync(shadowedBy).isSymbolicLink();
31
+ }
32
+ catch { /* treat as real binary */ }
33
+ if (isSymlink)
34
+ fixed.push(`${cmd} launcher (${shadowedBy})`);
35
+ else
36
+ needsAttention.push(`${cmd}: real binary shadows the shim (${shadowedBy})`);
37
+ continue;
38
+ }
39
+ const res = adoptShadowingLauncher(agent);
40
+ if (res.adopted)
41
+ fixed.push(`adopted ${cmd} launcher (${res.launcher})`);
42
+ else if (res.reason === 'not-a-symlink') {
43
+ needsAttention.push(`${cmd}: real binary shadows the shim (${shadowedBy})`);
44
+ }
45
+ }
46
+ return resultOf(fixed, needsAttention);
47
+ },
48
+ };
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const shimsCheck: HealCheck;
@@ -0,0 +1,35 @@
1
+ // shims check — keeps the dispatch shims and versioned aliases current, and clears
2
+ // pre-split legacy shim files. Formerly done in the interactive index.ts startup
3
+ // (which PRINTED "Updated <cli> shim" on every run); here it runs silently in the
4
+ // background so the shim schema settles without user-facing churn.
5
+ import { resultOf } from '../types.js';
6
+ import { AGENTS } from '../../agents.js';
7
+ import { ensureShimCurrent, ensureVersionedAliasCurrent, isShimCurrent, isVersionedAliasCurrent, removeLegacyUserShim, listAgentsWithInstalledVersions, } from '../../shims.js';
8
+ import { listInstalledVersions } from '../../versions.js';
9
+ export const shimsCheck = {
10
+ id: 'shims',
11
+ title: 'Dispatch shims + versioned aliases',
12
+ cadence: 'frequent',
13
+ async run(ctx) {
14
+ const fixed = [];
15
+ for (const agent of listAgentsWithInstalledVersions()) {
16
+ const cmd = AGENTS[agent].cliCommand;
17
+ if (!isShimCurrent(agent)) {
18
+ if (!ctx.dryRun)
19
+ ensureShimCurrent(agent);
20
+ fixed.push(`${cmd} shim`);
21
+ }
22
+ for (const version of listInstalledVersions(agent)) {
23
+ if (!isVersionedAliasCurrent(agent, version)) {
24
+ if (!ctx.dryRun)
25
+ ensureVersionedAliasCurrent(agent, version);
26
+ fixed.push(`${cmd}@${version} alias`);
27
+ }
28
+ }
29
+ // Pre-split ~/.agents/shims/<cli> files cause false-positive shadow hits.
30
+ if (!ctx.dryRun && removeLegacyUserShim(agent))
31
+ fixed.push(`removed legacy ${cmd} shim`);
32
+ }
33
+ return resultOf(fixed, []);
34
+ },
35
+ };
@@ -0,0 +1,22 @@
1
+ import type { HealCheck, HealCheckId, HealCadence, SelfHealReport } from './types.js';
2
+ export declare const HEAL_CHECKS: HealCheck[];
3
+ export interface SelfHealOptions {
4
+ /** Restrict to these check ids; omit to run every registered check. */
5
+ checks?: HealCheckId[];
6
+ /** Only run checks whose cadence is in this set (daemon scheduling). */
7
+ cadences?: HealCadence[];
8
+ /** 'safe' (daemon default) or 'full' (doctor --fix). Default 'safe'. */
9
+ mode?: 'safe' | 'full';
10
+ /** Detect only — never write. Default false. */
11
+ dryRun?: boolean;
12
+ /** Override the platform gate (tests). Default process.platform. */
13
+ platform?: NodeJS.Platform;
14
+ }
15
+ /** Run the selected checks, isolating per-check failures. */
16
+ export declare function runSelfHeal(opts?: SelfHealOptions): Promise<SelfHealReport>;
17
+ /** True if any check repaired something (for daemon logging / notification). */
18
+ export declare function selfHealChangedAnything(report: SelfHealReport): boolean;
19
+ /** True if any check surfaced something a human should look at. */
20
+ export declare function selfHealNeedsAttention(report: SelfHealReport): boolean;
21
+ /** One-line human summary, e.g. "shims: 2 fixed; path: 1 fixed". */
22
+ export declare function summarizeSelfHeal(report: SelfHealReport): string;
@@ -0,0 +1,66 @@
1
+ // The self-heal registry + runner.
2
+ //
3
+ // One ordered list of HealChecks; one runner that executes the requested subset,
4
+ // isolating failures (one check throwing never aborts the rest) and aggregating a
5
+ // SelfHealReport. Both front doors — the daemon (by cadence) and `agents doctor`
6
+ // (all, or by id) — call runSelfHeal.
7
+ import { resourcesCheck } from './checks/resources.js';
8
+ import { shimsCheck } from './checks/shims.js';
9
+ import { shadowingCheck } from './checks/shadowing.js';
10
+ import { pathCheck } from './checks/path.js';
11
+ // Order matters: cheap structural fixes (shims, shadow adoption, PATH) before the
12
+ // heavier resource reconciliation, so a freshly-repaired shim is in place first.
13
+ export const HEAL_CHECKS = [
14
+ shimsCheck,
15
+ shadowingCheck,
16
+ pathCheck,
17
+ resourcesCheck,
18
+ ];
19
+ /** Run the selected checks, isolating per-check failures. */
20
+ export async function runSelfHeal(opts = {}) {
21
+ const platform = opts.platform ?? process.platform;
22
+ const ctx = { mode: opts.mode ?? 'safe', dryRun: opts.dryRun ?? false };
23
+ const selected = HEAL_CHECKS.filter((c) => {
24
+ if (opts.checks && !opts.checks.includes(c.id))
25
+ return false;
26
+ if (opts.cadences && !opts.cadences.includes(c.cadence))
27
+ return false;
28
+ if (c.platforms && !c.platforms.includes(platform))
29
+ return false;
30
+ return true;
31
+ });
32
+ const reports = [];
33
+ for (const check of selected) {
34
+ try {
35
+ const result = await check.run(ctx);
36
+ reports.push({ id: check.id, title: check.title, result });
37
+ }
38
+ catch (err) {
39
+ reports.push({ id: check.id, title: check.title, result: null, error: err.message });
40
+ }
41
+ }
42
+ return { checks: reports };
43
+ }
44
+ /** True if any check repaired something (for daemon logging / notification). */
45
+ export function selfHealChangedAnything(report) {
46
+ return report.checks.some((c) => (c.result?.fixed.length ?? 0) > 0);
47
+ }
48
+ /** True if any check surfaced something a human should look at. */
49
+ export function selfHealNeedsAttention(report) {
50
+ return report.checks.some((c) => (c.result?.needsAttention.length ?? 0) > 0 || Boolean(c.error));
51
+ }
52
+ /** One-line human summary, e.g. "shims: 2 fixed; path: 1 fixed". */
53
+ export function summarizeSelfHeal(report) {
54
+ const parts = [];
55
+ for (const c of report.checks) {
56
+ if (c.error) {
57
+ parts.push(`${c.id}: error (${c.error})`);
58
+ continue;
59
+ }
60
+ const n = c.result?.fixed.length ?? 0;
61
+ const a = c.result?.needsAttention.length ?? 0;
62
+ if (n > 0 || a > 0)
63
+ parts.push(`${c.id}: ${n} fixed${a > 0 ? `, ${a} to review` : ''}`);
64
+ }
65
+ return parts.join('; ') || 'nothing to heal';
66
+ }