@phnx-labs/agents-cli 1.20.45 → 1.20.46

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 CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## 1.20.45
5
+ ## 1.20.46
6
6
 
7
- - **NEW: `agents run <agent> --host <name>` without a prompt forwards your TTY over SSH and runs the agent interactively on the remote host.** Previously `--host` runs required a prompt and were always headless (`agents run <agent> "<task>" --host <name>`). Now, omitting the prompt takes the interactive path: when local stdin is a TTY, the local CLI SSHes with `-tt`, runs `agents run <agent>` on the host, and lets the remote machine's `agents` start its normal tmux wrapper. The tmux session lives on the remote box, so detaching (`Ctrl-b d`) ends the SSH connection but keeps the agent running; you can reattach from the host or resume by session id. Session ids for Claude are still minted up front so `agents sessions` can surface and resolve the remote run. `--no-follow` is rejected for interactive host runs (it is meaningless for an attached TTY), and `--mode`, `--model`, `--name`, passthrough args after `--`, and `--raw`/`--no-tmux` are forwarded to the remote invocation. Source: `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/hosts/dispatch.ts`, `apps/cli/src/lib/hosts/session-index.ts`, `apps/cli/docs/hosts.md`.
7
+ - **NEW: `Cmd-Shift-O` opens a Spotlight-style quick-issue bar in the menu-bar helper type a sentence, attach recent screenshots, and an agent files the Linear ticket for you.** The menu-bar helper already turned a screenshot into a `<host>:<path>` token with `Cmd-Shift-V` (clip capture), but there was no path from "I see a bug" to "a triaged ticket exists." The new chord summons a borderless panel (a thin capture surface, not another form): you type a one-line note, optionally toggle one or more recent screenshots (from the system screencapture folder, CleanShot's export path, or the clip history) as a thumbnail strip (the newest is pre-selected when it's fresh), and hit Return. It then **dispatches a headless agent** (`agents run claude --mode auto`, isolated behind one `AgentsCLI.dispatchTicketAgent` call so a cloud pod is a later swap) that reads the screenshots, runs `agents sessions` to identify which repo/project this concerns, does a brief investigation for real context, and files the ticket via `~/.agents/skills/linear/scripts/linear create` with an honest priority + a `repo:<name>` label no preview step, the panel closes immediately and a notification reports the created `RUSH-####`. Focus is handled for a no-Dock `.accessory` app (`NSApp.activate` `makeKeyAndOrderFront` `makeFirstResponder`, with a borderless `NSPanel` overriding `canBecomeKey`; click-outside dismissal is armed only after the summon settles so the activation race can't self-dismiss the panel). The `Cmd-Shift-V` clip hotkey is unchanged the Carbon hotkey manager now demultiplexes both chords by `EventHotKeyID.id` through one installed handler. Self-test: `MENUBAR_ISSUE_TEST=1 MenubarHelper` exercises screenshot selection, ticket-id parsing, and the meta-prompt contract; `MENUBAR_PROMPT_PREVIEW=1` renders the panel without the global hotkey for QA. Source: `apps/cli/menubar/Sources/MenubarHelper/{PromptPanel,Hotkey,AgentsCLI,main,IssueSelfTest,Clip}.swift`.
8
8
 
9
+ - **NEW: a unified self-heal subsystem — the shim/PATH "repair" notice no longer nags on every terminal, and the daemon now heals shim drift in the background.** agents-cli had accumulated ~37 separate repair routines scattered across the daemon, every CLI startup, and a handful of commands, each hand-rolling its own detect+fix on its own trigger. The most visible symptom: the interactive shim bootstrap (`maybeBootstrapShimIntegration`) regenerated shims, adopted shadowing launchers, and offered to add the shims dir to PATH **in the foreground on every invocation**, suppressed only by a `process.ppid`-keyed temp sentinel — so a new terminal re-ran the whole detect-and-nag, and the underlying condition was never permanently fixed. This lands a single `HealCheck` registry (`lib/self-heal/`) with one runner (`runSelfHeal`) driven by two front doors — the daemon (on its existing ~30s-after-start + ~6h `safe`-mode cycle) and the interactive startup — sharing the same checks: `shims` (regenerate stale shims/aliases), `shadowing` (adopt symlink launchers; report real-binary shadows), `path` (add the shims dir to PATH once), and `resources` (the existing `heal()` engine, wrapped unchanged). The daemon's heal cycle now runs all four in `safe` mode (low-risk fixes silently; risky ones reported), replacing the resource-only `heal()` call — and drops the desktop toast for background heals (the log is the record). The interactive startup now heals **silently** and prints at most a **persistent, once-per-condition** notice (`lib/shim-heal.ts`, keyed to a signature of the actionable state under `~/.agents/.cache/state/shim-notice.json`) for what a machine genuinely can't fix for you — a real native binary shadowing the shim — instead of re-nagging every shell. What changes is *where* the repairs run (background/silent) and *how often* you hear about them (once, not every terminal). Source: `apps/cli/src/lib/self-heal/` (new), `apps/cli/src/lib/shim-heal.ts` (new), `apps/cli/src/lib/daemon.ts`, `apps/cli/src/index.ts`, `apps/cli/src/lib/shims.ts` (`isShimCurrent` exported).
10
+ ## 1.20.45
11
+ - **NEW: `agents run <agent> --host <name>` without a prompt forwards your TTY over SSH and runs the agent interactively on the remote host.** Previously `--host` runs required a prompt and were always headless (`agents run <agent> "<task>" --host <name>`). Now, omitting the prompt takes the interactive path: when local stdin is a TTY, the local CLI SSHes with `-tt`, runs `agents run <agent>` on the host, and lets the remote machine's `agents` start its normal tmux wrapper. The tmux session lives on the remote box, so detaching (`Ctrl-b d`) ends the SSH connection but keeps the agent running; you can reattach from the host or resume by session id. Session ids for Claude are still minted up front so `agents sessions` can surface and resolve the remote run. `--no-follow` is rejected for interactive host runs (it is meaningless for an attached TTY), and `--mode`, `--model`, `--name`, passthrough args after `--`, and `--raw`/`--no-tmux` are forwarded to the remote invocation. Source: `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/hosts/dispatch.ts`, `apps/cli/src/lib/hosts/session-index.ts`, `apps/cli/docs/hosts.md`.
9
12
  - **`agents secrets export --host` now works against Windows targets, and a new `agents secrets unlock --host` unlocks a bundle on a remote machine.** The export push was POSIX-only (`bash -lc`, `--from /dev/stdin`, `create … || true`, `IFS= read`), so a Windows remote died with `'true' is not recognized … cannot find the path specified`. Two changes fix it: `agents secrets import` now accepts **`--from -`** (read the `.env` from stdin, replacing the POSIX-only `/dev/stdin`), and the push is **platform-aware** — `bash -lc` on POSIX, `powershell -EncodedCommand` on Windows, with the target's OS taken from the device registry. Because the npm `agents.ps1` shim does **not** forward ssh-piped stdin to the underlying node process (a raw `--from -` read hangs), the Windows keychain push bridges the piped `.env` through PowerShell into a temp file and imports `--from <file>` (deleted afterwards). File-backend export to a Windows target is refused cleanly rather than emitting broken PowerShell. Verified end-to-end: `agents secrets export linear.app --host win-mini` imported all 13 keys. Separately, **`agents secrets unlock --host <machine> <bundle>`** runs the unlock ON the remote over `ssh -tt`, so a **file-backed** bundle's passphrase prompt surfaces on your terminal — the "unlock the Mac from the road with its password" path; keychain/biometry bundles are GUI-only (a local Touch-ID/passcode sheet can't cross SSH) and can't be remote-unlocked. `unlock`'s `--host` is single-valued so it never swallows the positional bundle name. Source: `apps/cli/src/commands/secrets.ts`, `apps/cli/src/lib/hosts/remote-cmd.ts`.
10
13
  - **A session now has ONE name, not two. `--name` seeds the session label instead of a parallel column.** Shipping `agents run --name` (1.20.43) as a separate immutable `name` column created two look-alike fields — an unshown, frozen `name` and the shown, searchable `label` — that both resolved `agents sessions <ref>` and forced tie-break bookkeeping nobody could keep straight. They unify into one field. `--name` is now the universal way to *seed* the `label` at launch — the same field an agent-generated title (Claude's `/rename`) later refines and `agents sessions` displays and searches — and it works consistently across interactive, headless, `--host`, and teams teammate runs (a teammate's friendly name now seeds its session label; before, teammate sessions had no name at all). Priority is a plain fallback chain resolved at scan time, no stored winner: an agent-generated title wins, else the `--name` seed, else the listing falls back to `topic`. So a Claude run's `--name` shows until Claude titles it (your seed, then refined); a non-Claude run keeps its `--name` as the label (it has no auto-title). The seeded name is now fuzzy-searchable in FTS (the old `name` column was not). `agents hosts logs <name>` is unchanged — it resolves against the host-task sidecar, not the session column. Schema v10 folds any existing `name` into `label` (where the label was empty), mirrors it into the FTS row, then drops the `name` column; the run-name sidecars re-seed every scan (`seedLabelsFromNames`), so no rescan is needed. Reworks the 1.20.43 `--name` design (partly reverts its separate-column approach). Source: `apps/cli/src/lib/session/{db,discover,run-names,types}.ts`, `apps/cli/src/lib/hosts/session-index.ts`, `apps/cli/src/lib/teams/agents.ts`, `apps/cli/src/commands/exec.ts`, `apps/cli/docs/{05-sessions,hosts}.md`.
11
14
  - **NEW: `agents teams add`/`start` warns when a *version-pinned* teammate is on a throttled or signed-out account.** The 1.20.43 `balanced`-default fix keeps *bare* teammates off rate-limited accounts (they route through bare `agents run`, which rotates), but a **version-pinned** (`claude@2.1.112`) or **profile** teammate spawns `agents run <agent>@<version>` / `agents run <profile>`, and a pin/profile deliberately *bypasses* rotation — so it would launch straight onto a maxed account and 429 on the first request, with no mid-run failover either (that only arms when a non-pinned strategy actually rotated). `agents teams add` (at add time) and `agents teams start` (per staged teammate, deduped by `agent@version`) now pre-check a **version-pinned** teammate's account and print an advisory when it's rate-limited, out of credits, or not signed in — reusing the router's *exact* eligibility gate (`checkRunAccountReadiness` → `hasUsageAvailable`, the same session-inclusive signal the `agents view` badge uses), so the warning can never disagree with what the spawn would actually do. It **warns, never blocks** (mirroring the existing "may not be signed in" advisory); `--force` silences it. Scoped to version-pinned teammates on purpose: bare teammates are already handled by rotation, and a profile injects its own auth (a different account than the version home carries) that isn't locally checkable — so no unreliable profile warning is emitted. Source: `apps/cli/src/lib/rotate.ts` (`readinessFromCandidate`, `checkRunAccountReadiness`, `rotate.test.ts`), `apps/cli/src/commands/teams.ts`.
package/dist/index.js CHANGED
@@ -8,7 +8,6 @@
8
8
  import { Command } from 'commander';
9
9
  import chalk from 'chalk';
10
10
  import * as fs from 'fs';
11
- import * as os from 'os';
12
11
  import * as path from 'path';
13
12
  import { fileURLToPath } from 'url';
14
13
  import { detectDevBuild } from './lib/startup/dev-build.js';
@@ -54,7 +53,6 @@ if (IS_DEV_BUILD) {
54
53
  import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
55
54
  import { applyGlobalHelpConventions } from './lib/help.js';
56
55
  import { renderWhatsNew } from './lib/whats-new.js';
57
- import { IS_WINDOWS } from './lib/platform/index.js';
58
56
  import { emit, redactArgs } from './lib/events.js';
59
57
  // Transparent shim delegate: the generated Windows `.cmd` shims invoke
60
58
  // `agents __shim <agent>[@version] <raw args>`. Intercept here, before commander
@@ -477,124 +475,21 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
477
475
  if (requestedCommand === 'sync' || requestedCommand === 'refresh-rules') {
478
476
  return;
479
477
  }
480
- // Past the documentation/non-TTY guards: only now load the shim + agent
481
- // tables this interactive repair flow needs, so fast commands never pay for
482
- // them at module-eval time.
483
- const { confirm } = await import('@inquirer/prompts');
484
- const { AGENTS } = await import('./lib/agents.js');
485
- const { getGlobalDefault, listInstalledVersions } = await import('./lib/versions.js');
486
- const { addShimsToPath, adoptShadowingLauncher, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
487
- const installedAgents = listAgentsWithInstalledVersions();
488
- if (installedAgents.length === 0) {
489
- return;
490
- }
491
- const createdOrUpdated = [];
492
- for (const agent of installedAgents) {
493
- const status = ensureShimCurrent(agent);
494
- if (status !== 'current') {
495
- createdOrUpdated.push(`${status === 'created' ? 'Created' : 'Updated'} ${AGENTS[agent].cliCommand} shim`);
496
- }
497
- for (const version of listInstalledVersions(agent)) {
498
- const aliasStatus = ensureVersionedAliasCurrent(agent, version);
499
- if (aliasStatus !== 'current') {
500
- createdOrUpdated.push(`${aliasStatus === 'created' ? 'Created' : 'Updated'} ${AGENTS[agent].cliCommand}@${version} alias`);
501
- }
502
- }
503
- }
504
- for (const notice of createdOrUpdated) {
505
- console.log(chalk.green(notice));
506
- }
507
- // Best-effort: remove leftover ~/.agents/shims/<cli> files from the pre-split
508
- // layout BEFORE running detection. These cause false-positive "shadowing"
509
- // results that make the repair prompt loop forever (the prompt user said
510
- // "yes" to never deletes the file; next invocation finds it again).
511
- for (const agent of installedAgents) {
512
- removeLegacyUserShim(agent);
513
- }
514
- // The remaining flow is rc-file PATH repair, which is POSIX-only. On Windows
515
- // the shims were just regenerated (incl. `.cmd` companions) above; PATH setup
516
- // is covered by the install-time guidance, so stop here rather than printing
517
- // shell-rc instructions that don't apply.
518
- if (IS_WINDOWS) {
519
- return;
520
- }
521
- const defaultAgents = installedAgents.filter((agent) => getGlobalDefault(agent));
522
- // Auto-adopt any harness launcher that shadows our shim. PATH-order repair
523
- // (below) cannot win against `~/.local/bin` — it's prepended in .zshenv for
524
- // every shell while our prepend only lands in .zshrc — so for symlink
525
- // launchers we *become* the launcher instead. Detection keys on the launcher
526
- // symlink EXISTING (via adoptShadowingLauncher's own fallback), not on this
527
- // shell's PATH order, so it also heals the GUI/non-interactive shadow an
528
- // interactive run can't see. Reversible; only ever rewrites a symlink.
529
- for (const agent of defaultAgents) {
530
- const result = adoptShadowingLauncher(agent);
531
- if (result.adopted) {
532
- console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher}) — version management now wins regardless of PATH order.`));
533
- }
534
- }
535
- // Recompute AFTER adoption so anything we just took over drops out. What
536
- // remains is a real binary we deliberately don't touch (adoption is
537
- // symlink-only) — those get an honest one-time note, never a looping prompt.
538
- const shadowed = defaultAgents
539
- .map((agent) => ({ agent, shadowedBy: getPathShadowingExecutable(agent) }))
540
- .filter((item) => Boolean(item.shadowedBy));
541
- // After adoption, the only things left are (a) real-binary shadows we won't
542
- // touch, and (b) a genuinely missing PATH entry. Nothing else needs the user.
543
- const pathMissing = !isShimsInPath();
544
- if (shadowed.length === 0 && !pathMissing) {
545
- return;
546
- }
547
- // Suppress repeated notices within the same shell. A successful rc-file edit
548
- // doesn't reload the parent shell, so the next invocation re-fires detection.
549
- // The sentinel survives only as long as the parent shell process — a new
550
- // terminal (new PPID) is allowed to surface it again.
551
- const sentinelPath = path.join(os.tmpdir(), `agents-shim-prompted-${process.ppid}`);
552
- if (fs.existsSync(sentinelPath)) {
553
- return;
554
- }
555
- // Real-binary shadows: adoption is symlink-only (we never rename a real native
556
- // binary), and `addShimsToPath` provably can't outrank an early-PATH dir like
557
- // ~/.local/bin across zsh's whole sourcing chain. So DON'T offer a "Repair?"
558
- // prompt here — that was the infinite-loop bug (Yes was always a no-op).
559
- // Inform once and point at the real levers.
560
- if (shadowed.length > 0) {
561
- const targets = shadowed
562
- .map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
563
- .join('\n');
564
- console.log(chalk.yellow('These agent commands run a native binary instead of the version-managed shim:'));
565
- console.log(chalk.gray(targets));
566
- console.log(chalk.gray(`It's a real binary (not a symlink), so agents-cli won't move it. To hand it to agents-cli, remove/reorder it, or put ${getShimsDir()} earlier in PATH.`));
567
- }
568
- // Genuinely-missing PATH entry is the one thing addShimsToPath actually fixes,
569
- // so it's the only case that still earns an interactive prompt.
570
- if (pathMissing) {
571
- const shouldRepair = await confirm({
572
- message: 'Add the agents-cli shims directory to your PATH now?',
573
- default: true,
574
- });
575
- if (!shouldRepair) {
576
- console.log(chalk.gray(getPathSetupInstructions()));
577
- }
578
- else {
579
- const pathResult = addShimsToPath();
580
- if (!pathResult.success) {
581
- console.log(chalk.yellow('Could not update PATH automatically.'));
582
- console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
583
- }
584
- else if (pathResult.alreadyPresent) {
585
- console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} — this shell just needs to reload it.`));
586
- console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
587
- }
588
- else {
589
- console.log(chalk.green(`Added shims to PATH in ~/${pathResult.rcFile}`));
590
- console.log(chalk.gray(getPathSetupInstructions()));
591
- }
592
- }
593
- }
594
- try {
595
- fs.writeFileSync(sentinelPath, '1');
478
+ // Past the documentation/non-TTY guards: heal the shim/shadow/PATH conditions
479
+ // through the unified self-heal registry the SAME checks the daemon runs, but
480
+ // driven silently on this interactive invocation so a user who never starts the
481
+ // daemon still gets healed. Regenerating stale shims, adopting symlink launchers,
482
+ // and adding the shims dir to PATH now happen without any output. The only thing
483
+ // that ever prints is a ONE-TIME notice for what a machine can't silently fix
484
+ // (a real native binary shadowing the shim) or is worth saying once (a PATH entry
485
+ // just added). Suppression is persistent and keyed to the condition — a new
486
+ // terminal no longer re-nags (the old per-PPID sentinel did, every shell).
487
+ const { healShimsInteractive } = await import('./lib/shim-heal.js');
488
+ const noticeLines = await healShimsInteractive();
489
+ if (noticeLines) {
490
+ for (const line of noticeLines)
491
+ console.log(chalk.gray(line));
596
492
  }
597
- catch { /* best-effort */ }
598
493
  }
599
494
  // --- Inline command registrars ----------------------------------------------
600
495
  // These commands are defined here rather than in a command module because they
@@ -374,15 +374,18 @@ export async function runDaemon() {
374
374
  return;
375
375
  healing = true;
376
376
  try {
377
- const { heal, summarizeHeal, notifyHeal, healChangedAnything } = await import('./heal.js');
378
- const result = await heal({ mode: 'safe' });
379
- if (healChangedAnything(result) || result.skippedPlugins.length > 0) {
380
- log('INFO', `heal: ${summarizeHeal(result)}`);
381
- notifyHeal(result);
377
+ const { runSelfHeal, selfHealChangedAnything, selfHealNeedsAttention, summarizeSelfHeal } = await import('./self-heal/registry.js');
378
+ // Background heal is conservative (mode: 'safe'): fixes low-risk drift (shims,
379
+ // symlink adoption, PATH, missing resources) and only reports risky ones. The
380
+ // 30s kickoff means shims/PATH settle shortly after the daemon starts. No
381
+ // desktop toast here — background heal is silent by design; the log is the record.
382
+ const report = await runSelfHeal({ mode: 'safe' });
383
+ if (selfHealChangedAnything(report) || selfHealNeedsAttention(report)) {
384
+ log('INFO', `self-heal: ${summarizeSelfHeal(report)}`);
382
385
  }
383
386
  }
384
387
  catch (err) {
385
- log('ERROR', `heal check failed: ${err.message}`);
388
+ log('ERROR', `self-heal check failed: ${err.message}`);
386
389
  }
387
390
  finally {
388
391
  healing = 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
+ }
@@ -0,0 +1,41 @@
1
+ export type HealCheckId = 'resources' | 'shims' | 'shadowing' | 'path';
2
+ /** When the daemon schedules a check. */
3
+ export type HealCadence = 'startup' | 'frequent' | 'periodic';
4
+ export interface HealCtx {
5
+ /** 'safe' = daemon (low-risk only); 'full' = doctor --fix (everything). */
6
+ mode: 'safe' | 'full';
7
+ /** Detect only — never write. Powers `agents doctor` (read-only) and previews. */
8
+ dryRun: boolean;
9
+ }
10
+ /** Outcome of one check. `ok` means nothing was wrong. */
11
+ export interface CheckResult {
12
+ /** Things repaired (or, under dryRun, that WOULD be repaired). Human-readable. */
13
+ fixed: string[];
14
+ /** Detected but not auto-fixed: unfixable, or risky-in-safe-mode. Human-readable. */
15
+ needsAttention: string[];
16
+ /** True iff detect found nothing wrong (fixed and needsAttention both empty). */
17
+ ok: boolean;
18
+ }
19
+ export interface HealCheck {
20
+ id: HealCheckId;
21
+ title: string;
22
+ /** Restrict to these platforms; omit to run on all. */
23
+ platforms?: NodeJS.Platform[];
24
+ cadence: HealCadence;
25
+ /** Detect + (repair unless dryRun). Must be headless (no TTY/prompt) and idempotent. */
26
+ run(ctx: HealCtx): Promise<CheckResult>;
27
+ }
28
+ export interface CheckReport {
29
+ id: HealCheckId;
30
+ title: string;
31
+ result: CheckResult | null;
32
+ /** Set when the check itself threw (isolated — one check failing never aborts the run). */
33
+ error?: string;
34
+ }
35
+ export interface SelfHealReport {
36
+ checks: CheckReport[];
37
+ }
38
+ /** Convenience: an all-clear result. */
39
+ export declare function okResult(): CheckResult;
40
+ /** Build a CheckResult from collected fixes/attention items (ok iff both empty). */
41
+ export declare function resultOf(fixed: string[], needsAttention: string[]): CheckResult;
@@ -0,0 +1,21 @@
1
+ // Unified self-heal subsystem — shared shapes.
2
+ //
3
+ // agents-cli had ~37 separate repair routines scattered across the daemon, every
4
+ // CLI startup, and a handful of commands, each hand-rolling detect+fix on its own
5
+ // trigger. This subsystem gives every repairable class of problem ONE shape — a
6
+ // HealCheck — driven by ONE runner, hosted behind TWO front doors (the daemon,
7
+ // on tiered schedules, and `agents doctor`, on demand).
8
+ //
9
+ // A check's `run()` both detects and repairs in a single pass (repair is skipped
10
+ // when `ctx.dryRun`), mirroring the existing resource heal (heal.ts) which computes
11
+ // and applies together. `mode` gates how aggressive a repair may be: 'safe' (the
12
+ // daemon default) fixes only low-risk drift and merely reports risky conditions;
13
+ // 'full' (`agents doctor --fix`) applies everything.
14
+ /** Convenience: an all-clear result. */
15
+ export function okResult() {
16
+ return { fixed: [], needsAttention: [], ok: true };
17
+ }
18
+ /** Build a CheckResult from collected fixes/attention items (ok iff both empty). */
19
+ export function resultOf(fixed, needsAttention) {
20
+ return { fixed, needsAttention, ok: fixed.length === 0 && needsAttention.length === 0 };
21
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Heal the shim/shadow/PATH conditions silently, then return the notice lines to
3
+ * print ONCE for whatever is left (a real-binary shadow we won't move; a PATH entry
4
+ * that was just added / needs a reload), or null when there's nothing new to say.
5
+ */
6
+ export declare function healShimsInteractive(): Promise<string[] | null>;
7
+ type PathNoticeState = 'ok' | 'added' | 'reload';
8
+ /**
9
+ * A stable signature of the conditions worth surfacing: the sorted set of
10
+ * real-binary shadows plus the PATH notice state. Empty string = nothing to say.
11
+ */
12
+ export declare function computeShimNoticeSignature(input: {
13
+ shadowNotes: string[];
14
+ pathState: PathNoticeState;
15
+ }): string;
16
+ /**
17
+ * Whether to surface the notice for the current condition. Returns false (stay
18
+ * quiet) when the exact same signature was already surfaced, or when there's
19
+ * nothing to say. On true it records the signature so the next shell with the same
20
+ * state is suppressed. An empty signature clears the marker.
21
+ */
22
+ export declare function shouldSurfaceShimNotice(signature: string): boolean;
23
+ export {};
@@ -0,0 +1,109 @@
1
+ // Interactive shim-heal for the CLI startup path + the persistent notice-state that
2
+ // replaces the old per-PPID sentinel.
3
+ //
4
+ // The actual repair (regenerating shims, adopting symlink launchers, adding the
5
+ // shims dir to PATH) lives in the unified self-heal registry — this module just
6
+ // drives the shim-relevant checks SILENTLY on a normal `agents` invocation and then
7
+ // decides whether to print a one-time notice about anything left. The old flow
8
+ // re-ran its whole detect-and-nag on every new terminal (its sentinel was keyed to
9
+ // process.ppid); the persistent signature here means an unresolved condition is
10
+ // surfaced once, not on every shell.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { getRuntimeStateDir } from './state.js';
14
+ /**
15
+ * Heal the shim/shadow/PATH conditions silently, then return the notice lines to
16
+ * print ONCE for whatever is left (a real-binary shadow we won't move; a PATH entry
17
+ * that was just added / needs a reload), or null when there's nothing new to say.
18
+ */
19
+ export async function healShimsInteractive() {
20
+ const { runSelfHeal } = await import('./self-heal/registry.js');
21
+ const report = await runSelfHeal({ checks: ['shims', 'shadowing', 'path'], mode: 'safe' });
22
+ const shadowNotes = [];
23
+ let pathAdded = null;
24
+ let pathReload = null;
25
+ for (const c of report.checks) {
26
+ if (!c.result)
27
+ continue;
28
+ if (c.id === 'shadowing')
29
+ shadowNotes.push(...c.result.needsAttention);
30
+ if (c.id === 'path') {
31
+ for (const f of c.result.fixed)
32
+ pathAdded = f; // "added shims to PATH (~/.zshrc)"
33
+ for (const a of c.result.needsAttention)
34
+ pathReload = a; // "...not loaded — open a new terminal"
35
+ }
36
+ }
37
+ const pathState = pathAdded ? 'added' : pathReload ? 'reload' : 'ok';
38
+ const signature = computeShimNoticeSignature({ shadowNotes, pathState });
39
+ if (!shouldSurfaceShimNotice(signature))
40
+ return null;
41
+ const lines = [];
42
+ if (pathAdded) {
43
+ lines.push(pathAdded);
44
+ lines.push('Open a new terminal (or source your shell rc) to pick it up.');
45
+ }
46
+ else if (pathReload) {
47
+ lines.push(pathReload);
48
+ }
49
+ if (shadowNotes.length > 0) {
50
+ lines.push('These agent commands run a native binary instead of the version-managed shim:');
51
+ for (const note of shadowNotes)
52
+ lines.push(` ${note}`);
53
+ lines.push("It's a real binary (not a symlink), so agents-cli won't move it — reorder PATH or remove it to hand it over.");
54
+ }
55
+ return lines.length > 0 ? lines : null;
56
+ }
57
+ function noticeStatePath() {
58
+ return path.join(getRuntimeStateDir(), 'shim-notice.json');
59
+ }
60
+ /**
61
+ * A stable signature of the conditions worth surfacing: the sorted set of
62
+ * real-binary shadows plus the PATH notice state. Empty string = nothing to say.
63
+ */
64
+ export function computeShimNoticeSignature(input) {
65
+ const shadows = [...input.shadowNotes].sort().join(',');
66
+ const parts = [];
67
+ if (shadows)
68
+ parts.push(`shadow:${shadows}`);
69
+ if (input.pathState !== 'ok')
70
+ parts.push(`path:${input.pathState}`);
71
+ return parts.join('|');
72
+ }
73
+ function readLastNoticeSignature() {
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(noticeStatePath(), 'utf-8'));
76
+ return typeof parsed.signature === 'string' ? parsed.signature : null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ function writeLastNoticeSignature(signature) {
83
+ try {
84
+ fs.mkdirSync(getRuntimeStateDir(), { recursive: true });
85
+ fs.writeFileSync(noticeStatePath(), JSON.stringify({ signature }));
86
+ }
87
+ catch {
88
+ /* best-effort: never block a command on the marker */
89
+ }
90
+ }
91
+ /**
92
+ * Whether to surface the notice for the current condition. Returns false (stay
93
+ * quiet) when the exact same signature was already surfaced, or when there's
94
+ * nothing to say. On true it records the signature so the next shell with the same
95
+ * state is suppressed. An empty signature clears the marker.
96
+ */
97
+ export function shouldSurfaceShimNotice(signature) {
98
+ if (!signature) {
99
+ try {
100
+ fs.rmSync(noticeStatePath(), { force: true });
101
+ }
102
+ catch { /* best-effort */ }
103
+ return false;
104
+ }
105
+ if (readLastNoticeSignature() === signature)
106
+ return false;
107
+ writeLastNoticeSignature(signature);
108
+ return true;
109
+ }
@@ -278,6 +278,12 @@ export declare function getConfigSymlinkVersion(agent: AgentId): string | null;
278
278
  */
279
279
  export declare function onDiskShimFile(cliCommand: string, platform: NodeJS.Platform): string;
280
280
  export declare function shimExists(agent: AgentId): boolean;
281
+ /**
282
+ * True if the on-disk shim's schema version matches `SHIM_SCHEMA_VERSION`.
283
+ * False means either the shim is missing, is pre-v2 (no marker), or is an
284
+ * older version that needs regeneration.
285
+ */
286
+ export declare function isShimCurrent(agent: AgentId): boolean;
281
287
  /**
282
288
  * Regenerate the shim if it's missing or outdated. Returns a status describing
283
289
  * what happened — callers can surface a one-line notice to the user ("Updated
package/dist/lib/shims.js CHANGED
@@ -1606,7 +1606,7 @@ function readShimSchemaVersion(agent) {
1606
1606
  * False means either the shim is missing, is pre-v2 (no marker), or is an
1607
1607
  * older version that needs regeneration.
1608
1608
  */
1609
- function isShimCurrent(agent) {
1609
+ export function isShimCurrent(agent) {
1610
1610
  const version = readShimSchemaVersion(agent);
1611
1611
  return version === SHIM_SCHEMA_VERSION;
1612
1612
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.45",
3
+ "version": "1.20.46",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",