@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
package/CHANGELOG.md CHANGED
@@ -2,8 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.47
6
+
7
+ - **Quick-issue bar (`Cmd-Shift-O`): `Cmd-V` now pastes into the note field, and double-clicking a screenshot thumbnail opens it in Preview.** Two fixes from dogfooding the new bar. (1) The panel is a borderless `.accessory` window with **no main menu**, so the standard clipboard key-equivalents (`Cmd-V`/`C`/`X`/`A`) were never dispatched to the field editor — paste silently did nothing. `PromptPanel.performKeyEquivalent` now routes them through the responder chain so the text field handles them. (2) Thumbnails are small, so there was no way to confirm which screenshot you were attaching: **single click still toggles selection, double click opens the full image in the default viewer (Preview)**. The single-click toggle is deferred by the double-click interval so a double-click previews without also flipping the selection, and the bar suppresses its own click-outside dismissal while Preview takes focus (so summoning Preview never closes the bar or drops your typed note; it re-arms when the bar regains focus). Source: `apps/cli/menubar/Sources/MenubarHelper/PromptPanel.swift`.
8
+ - **Fix: the headless file-store fallback no longer silently shadows the OS keyring; NEW `agents secrets import-keyring` migrates stranded secrets into it.** On headless Linux/Windows the encrypted-file store is *sticky* — once any item is on disk, `preflight()` routed **every** op to the file store and never consulted GNOME Keyring / Windows Credential Manager again, so a secret written earlier into the native store (e.g. while a desktop keyring was unlocked) read back **empty** with no hint. This stranded real Linear CLI credentials in a locked keyring while other bundles lived in the file store, silently breaking the SessionStart hook. Two fixes: (1) `get`/`has` now **read through** to the native store on a file-store *miss* (the fast path and the non-fallback keychain-first path are untouched — the file store is still checked first), emitting a one-time stderr notice pointing at `import-keyring`; once a locked/`1312` error is seen the store is marked unreachable so it stops re-probing a known-dead store. (2) NEW **`agents secrets import-keyring`** — the Linux/Windows analogue of the macOS `migrate-acl`/orphan sweep — enumerates `agents-cli` items in the native store and copies them into the encrypted file store (the durable, passwordless headless backend). Dry-run by default; `--commit` writes; existing file-store items are never overwritten; Windows enumeration is floored to the `agents-cli.` namespace since Credential Manager targets have no service scoping. macOS is unaffected (it has no file fallback and keeps `migrate-acl`). Source: `apps/cli/src/lib/secrets/{fallback,linux,windows,index}.ts`, `apps/cli/src/commands/{secrets-import,secrets}.ts`, `apps/cli/docs/secrets.md`.
9
+ - **Launch-health self-heal now covers Windows, and the daemon repairs a gutted install proactively — before your next `agents run`.** #764 gave `agents run` an install/run-time self-heal (probe `<binary> --version`; clean-reinstall in place, else fall back to another installed version that launches), but it **skipped the probe on Windows** — `verifyInstalledBinaryLaunches` returned healthy on `win32` unconditionally, because probing the extensionless `.bin/<cli>` wrapper would ENOENT even on a *healthy* install. So the exact Windows failure the self-heal was built for went unhealed: a vendor auto-update renames the native `claude.exe` to `claude.exe.old.<epochMs>` and never lands the replacement, leaving the shim chain intact but pointing at a missing file, and every launch dies with `'…claude.exe' is not recognized`. The probe now runs on Windows against the **real launch target** — the npm `.cmd` wrapper `agents run` actually execs (`getBinaryPath + '.cmd'`, resolved via `cmd.exe`), which chains to the native `.exe` — so a gutted install trips the existing missing-binary signature (`is not recognized`) and is repaired by the same `ensureAgentRunnable` machinery; a missing `.cmd` (a non-npm/global agent like `droid.exe`) is still treated as healthy so a good install is never destroyed. Separately, the **daemon** now runs a proactive launch-health pass (`healBrokenDefaultLaunches`) ~90s after startup and every ~6h: it probes each agent's default version and, if it won't launch, repairs it in the background — so a gutted install is fixed *before* the next `agents run` hits the ENOENT, not at spawn time (the run-time `ensureAgentRunnable` only fires once a run is already starting). Verified end-to-end on a real Windows host: renaming `claude.exe` to `.old` makes the `.cmd` probe emit `is not recognized`; restoring it returns `2.1.191 (Claude Code)`. Source: `apps/cli/src/lib/versions.ts` (`verifyInstalledBinaryLaunches`, `healBrokenDefaultLaunches`), `apps/cli/src/lib/daemon.ts`.
10
+
5
11
  ## 1.20.45
6
12
 
13
+ ## 1.20.46
14
+
15
+ - **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`.
16
+
17
+ - **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).
18
+ ## 1.20.45
7
19
  - **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`.
8
20
 
9
21
  - **`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`.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `agents secrets import-keyring` — migrate agents-cli secrets out of the native
3
+ * credential store (GNOME Keyring / Windows Credential Manager) and into the
4
+ * encrypted file store.
5
+ *
6
+ * Why: on headless Linux/Windows the file store is the durable, passwordless
7
+ * backend, but secrets written earlier (e.g. while a desktop keyring was
8
+ * unlocked) can linger in the native store where a headless session can't reach
9
+ * them. This is the Linux/Windows analogue of the macOS `migrate-acl` /
10
+ * orphan sweep. Dry-run by default; `--commit` performs the copy.
11
+ *
12
+ * Requires the native store to be reachable/unlocked — a locked keyring can't be
13
+ * read, so unlock it first (or the values are already only in the file store and
14
+ * there is nothing to do).
15
+ */
16
+ import type { Command } from 'commander';
17
+ /** Register `agents secrets import-keyring` on the parent secrets Command. */
18
+ export declare function registerSecretsImportKeyringCommand(secrets: Command): void;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `agents secrets import-keyring` — migrate agents-cli secrets out of the native
3
+ * credential store (GNOME Keyring / Windows Credential Manager) and into the
4
+ * encrypted file store.
5
+ *
6
+ * Why: on headless Linux/Windows the file store is the durable, passwordless
7
+ * backend, but secrets written earlier (e.g. while a desktop keyring was
8
+ * unlocked) can linger in the native store where a headless session can't reach
9
+ * them. This is the Linux/Windows analogue of the macOS `migrate-acl` /
10
+ * orphan sweep. Dry-run by default; `--commit` performs the copy.
11
+ *
12
+ * Requires the native store to be reachable/unlocked — a locked keyring can't be
13
+ * read, so unlock it first (or the values are already only in the file store and
14
+ * there is nothing to do).
15
+ */
16
+ import chalk from 'chalk';
17
+ import { importNativeItems } from '../lib/secrets/index.js';
18
+ /** Register `agents secrets import-keyring` on the parent secrets Command. */
19
+ export function registerSecretsImportKeyringCommand(secrets) {
20
+ secrets
21
+ .command('import-keyring')
22
+ .description('Migrate agents-cli secrets from the OS keyring / Credential Manager into the encrypted file store (headless-safe). Dry-run by default.')
23
+ .option('--commit', 'Perform the import (default is dry-run reporting only)')
24
+ .option('--prefix <p>', 'Only import items beginning with PREFIX (default: all agents-cli items)')
25
+ .action((opts) => {
26
+ try {
27
+ if (process.platform === 'darwin') {
28
+ throw new Error('import-keyring is for the Linux/Windows file-store fallback. On macOS use `agents secrets migrate-acl`.');
29
+ }
30
+ const commit = !!opts.commit;
31
+ const report = importNativeItems(opts.prefix ?? '', commit);
32
+ if (!report.available) {
33
+ console.log(chalk.gray('No native credential tooling found (secret-tool / PowerShell) — nothing to import.'));
34
+ return;
35
+ }
36
+ if (report.locked) {
37
+ console.error(chalk.yellow('The native credential store is locked/unreachable, so its secrets can\'t be read. ' +
38
+ 'Unlock it and retry (a locked store can\'t be migrated).'));
39
+ process.exit(1);
40
+ }
41
+ if (report.results.length === 0) {
42
+ console.log(chalk.green('Nothing to import — no native secrets outside the file store.'));
43
+ return;
44
+ }
45
+ const imported = report.results.filter((r) => r.status === 'imported' || r.status === 'would-import');
46
+ const existing = report.results.filter((r) => r.status === 'exists');
47
+ const failed = report.results.filter((r) => r.status === 'failed');
48
+ for (const r of report.results) {
49
+ if (r.status === 'imported')
50
+ console.log(` ${chalk.green('imported')} ${r.item}`);
51
+ else if (r.status === 'would-import')
52
+ console.log(` ${chalk.cyan('would import')} ${r.item}`);
53
+ else if (r.status === 'exists')
54
+ console.log(` ${chalk.gray('exists')} ${r.item} ${chalk.gray('(already in file store)')}`);
55
+ else
56
+ console.log(` ${chalk.red('failed')} ${r.item} ${chalk.gray(r.detail ?? '')}`);
57
+ }
58
+ console.log();
59
+ if (!commit) {
60
+ console.log(chalk.gray(`Dry-run: ${imported.length} would be imported, ${existing.length} already present, ${failed.length} unreadable. Pass --commit to write.`));
61
+ return;
62
+ }
63
+ if (failed.length > 0) {
64
+ console.error(chalk.yellow(`Imported ${imported.length}; ${existing.length} already present; ${failed.length} failed.`));
65
+ process.exit(1);
66
+ }
67
+ console.log(chalk.green(`Imported ${imported.length} secret(s) into the file store (${existing.length} already present).`));
68
+ }
69
+ catch (err) {
70
+ console.error(chalk.red(err.message));
71
+ process.exit(1);
72
+ }
73
+ });
74
+ }
@@ -25,6 +25,7 @@ import { registerCommandGroups, setHelpSections } from '../lib/help.js';
25
25
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
26
26
  import { registerSecretsSyncCommands } from './secrets-sync.js';
27
27
  import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
28
+ import { registerSecretsImportKeyringCommand } from './secrets-import.js';
28
29
  /** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
29
30
  async function promptForSecret(message) {
30
31
  if (!isInteractiveTerminal()) {
@@ -1817,6 +1818,7 @@ Examples:
1817
1818
  });
1818
1819
  registerSecretsSyncCommands(cmd);
1819
1820
  registerSecretsMigrateAclCommand(cmd);
1821
+ registerSecretsImportKeyringCommand(cmd);
1820
1822
  }
1821
1823
  /** Validate a prompt-policy value, throwing a clear message on a bad one (the
1822
1824
  * caller's try/catch renders it and exits). Accepts the legacy `biometry` /
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;
@@ -451,6 +454,35 @@ export async function runDaemon() {
451
454
  };
452
455
  const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
453
456
  const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
457
+ // Launch-health self-heal: probe that each agent's DEFAULT version actually
458
+ // LAUNCHES (not just that its files exist), and repair a gutted install — the
459
+ // JS wrapper present but its native binary renamed/missing (a vendor
460
+ // auto-update that never landed its replacement, or a partially-extracted
461
+ // tarball) — BEFORE the user's next `agents run` dies with a raw ENOENT. This
462
+ // is the proactive companion to the run-time heal (ensureAgentRunnable), which
463
+ // only fires once a run is already starting. Cheap steady-state: one
464
+ // `--version` probe per default version; a clean reinstall runs only on a real
465
+ // launch failure. ~every 6h, plus once ~90s after startup (staggered off launch).
466
+ let checkingLaunchHealth = false;
467
+ const runLaunchHealthCheck = async () => {
468
+ if (checkingLaunchHealth)
469
+ return;
470
+ checkingLaunchHealth = true;
471
+ try {
472
+ const { healBrokenDefaultLaunches } = await import('./versions.js');
473
+ const repaired = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`));
474
+ if (repaired.length)
475
+ log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
476
+ }
477
+ catch (err) {
478
+ log('ERROR', `launch-health check failed: ${err.message}`);
479
+ }
480
+ finally {
481
+ checkingLaunchHealth = false;
482
+ }
483
+ };
484
+ const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
485
+ const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
454
486
  const handleReload = () => {
455
487
  log('INFO', 'Reloading jobs (SIGHUP)');
456
488
  scheduler.reloadAll();
@@ -472,6 +504,8 @@ export async function runDaemon() {
472
504
  clearTimeout(deviceProbeKickoff);
473
505
  clearInterval(tmuxReconcileInterval);
474
506
  clearTimeout(tmuxReconcileKickoff);
507
+ clearInterval(launchHealthInterval);
508
+ clearTimeout(launchHealthKickoff);
475
509
  removeDaemonPid();
476
510
  process.exit(0);
477
511
  };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared helpers for the Linux/Windows encrypted-file fallback.
3
+ *
4
+ * When the native credential store (GNOME Keyring / Windows Credential Manager)
5
+ * is unreachable, both backends route to the AES-256-GCM file store. The routing
6
+ * is "sticky": once any item is on disk, every op stays on the file store. That
7
+ * is correct for perf, but on its own it would silently *shadow* secrets that
8
+ * still live in the native store — reads for them would return empty with no
9
+ * hint. This module centralizes the two pieces that keep that from being silent:
10
+ *
11
+ * 1. `noteNativeShadow()` — a one-time stderr notice, emitted when a read
12
+ * falls through to the native store (found something shadowed, or hit a
13
+ * locked/unreachable store), pointing at `agents secrets import-keyring`.
14
+ * 2. The result types for that import command.
15
+ *
16
+ * macOS has no file fallback (see ./index.ts) and uses `migrate-acl` /
17
+ * `migrate-orphans` for its own invisible-item classes, so none of this runs
18
+ * there.
19
+ */
20
+ export type NativeImportStatus = 'imported' | 'would-import' | 'exists' | 'failed';
21
+ export interface NativeImportResult {
22
+ item: string;
23
+ status: NativeImportStatus;
24
+ detail?: string;
25
+ }
26
+ /**
27
+ * Outcome of an `import-keyring` run. `available` is false when no native
28
+ * tooling exists (no `secret-tool` / no `powershell.exe`); `locked` is true when
29
+ * the native store exists but is locked/unreachable, so nothing could be read.
30
+ */
31
+ export interface NativeImportReport {
32
+ available: boolean;
33
+ locked: boolean;
34
+ results: NativeImportResult[];
35
+ }
36
+ /**
37
+ * Emit a one-time stderr notice that the file fallback is masking the native
38
+ * credential store. Both backends share the copy so the guidance is identical.
39
+ *
40
+ * 'shadowed' — a secret was just read from the native store that isn't in the
41
+ * file store. It works, but each read pays a native lookup and it
42
+ * won't survive the store locking; suggest migrating it.
43
+ * 'locked' — the native store is locked/unreachable, so its secrets can't be
44
+ * read in this session at all; the user must unlock, then migrate.
45
+ */
46
+ export declare function noteNativeShadow(kind: 'shadowed' | 'locked', fileDir: string): void;
47
+ /** Test-only: clear the one-time notice guard between cases. */
48
+ export declare function _resetFallbackNoticeForTest(): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared helpers for the Linux/Windows encrypted-file fallback.
3
+ *
4
+ * When the native credential store (GNOME Keyring / Windows Credential Manager)
5
+ * is unreachable, both backends route to the AES-256-GCM file store. The routing
6
+ * is "sticky": once any item is on disk, every op stays on the file store. That
7
+ * is correct for perf, but on its own it would silently *shadow* secrets that
8
+ * still live in the native store — reads for them would return empty with no
9
+ * hint. This module centralizes the two pieces that keep that from being silent:
10
+ *
11
+ * 1. `noteNativeShadow()` — a one-time stderr notice, emitted when a read
12
+ * falls through to the native store (found something shadowed, or hit a
13
+ * locked/unreachable store), pointing at `agents secrets import-keyring`.
14
+ * 2. The result types for that import command.
15
+ *
16
+ * macOS has no file fallback (see ./index.ts) and uses `migrate-acl` /
17
+ * `migrate-orphans` for its own invisible-item classes, so none of this runs
18
+ * there.
19
+ */
20
+ let noticeEmitted = false;
21
+ /**
22
+ * Emit a one-time stderr notice that the file fallback is masking the native
23
+ * credential store. Both backends share the copy so the guidance is identical.
24
+ *
25
+ * 'shadowed' — a secret was just read from the native store that isn't in the
26
+ * file store. It works, but each read pays a native lookup and it
27
+ * won't survive the store locking; suggest migrating it.
28
+ * 'locked' — the native store is locked/unreachable, so its secrets can't be
29
+ * read in this session at all; the user must unlock, then migrate.
30
+ */
31
+ export function noteNativeShadow(kind, fileDir) {
32
+ if (noticeEmitted)
33
+ return;
34
+ noticeEmitted = true;
35
+ if (kind === 'locked') {
36
+ process.stderr.write(`[agents] the native credential store is locked/unreachable — secrets stored there are not ` +
37
+ `readable in this session. Unlock it, then run \`agents secrets import-keyring\` to migrate ` +
38
+ `them into the encrypted file store at ${fileDir}.\n`);
39
+ }
40
+ else {
41
+ process.stderr.write(`[agents] read a secret from the native credential store that is not in the file store at ` +
42
+ `${fileDir}. Run \`agents secrets import-keyring\` to migrate it so it stays readable headless.\n`);
43
+ }
44
+ }
45
+ /** Test-only: clear the one-time notice guard between cases. */
46
+ export function _resetFallbackNoticeForTest() {
47
+ noticeEmitted = false;
48
+ }
@@ -22,6 +22,8 @@
22
22
  * through the explicit export/import flow in src/lib/secrets/sync.ts
23
23
  * rather than the system's cloud-keychain path.
24
24
  */
25
+ import type { NativeImportReport } from './fallback.js';
26
+ export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
25
27
  /** Supported secret resolution backends. */
26
28
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
27
29
  /** A typed reference to a secret, consisting of a provider and a provider-specific value. */
@@ -181,6 +183,15 @@ export declare function parseOrphanMigrationOutput(stdout: string): OrphanMigrat
181
183
  * aborted" from "nothing to do" (empty array).
182
184
  */
183
185
  export declare function migrateOrphanedKeychainItems(prefix: string): OrphanMigrationResult[];
186
+ /**
187
+ * Import agents-cli secrets from the native store (GNOME Keyring / Windows
188
+ * Credential Manager) into the encrypted file store — the Linux/Windows
189
+ * analogue of the macOS orphan/legacy migration, exposed as
190
+ * `agents secrets import-keyring`. Requires the native store to be
191
+ * reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
192
+ * report (it has no file fallback and uses `migrate-acl` instead).
193
+ */
194
+ export declare function importNativeItems(prefix: string, commit: boolean): NativeImportReport;
184
195
  /** Options controlling how secret refs are resolved. */
185
196
  export interface ResolveOptions {
186
197
  /** Translate a short keychain ID to a fully namespaced item name. */
@@ -26,8 +26,8 @@ import { execFileSync, spawnSync } from 'child_process';
26
26
  import * as fs from 'fs';
27
27
  import * as os from 'os';
28
28
  import * as path from 'path';
29
- import { linuxBackend, usesFileFallback as linuxUsesFileFallback } from './linux.js';
30
- import { windowsBackend, usesFileFallback as windowsUsesFileFallback } from './windows.js';
29
+ import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
30
+ import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
31
31
  import { getKeychainHelperPath } from './install-helper.js';
32
32
  const SERVICE_PREFIX = 'agents-cli';
33
33
  const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
@@ -518,6 +518,24 @@ export function migrateOrphanedKeychainItems(prefix) {
518
518
  }
519
519
  return parseOrphanMigrationOutput(result.stdout?.toString() || '');
520
520
  }
521
+ /**
522
+ * Import agents-cli secrets from the native store (GNOME Keyring / Windows
523
+ * Credential Manager) into the encrypted file store — the Linux/Windows
524
+ * analogue of the macOS orphan/legacy migration, exposed as
525
+ * `agents secrets import-keyring`. Requires the native store to be
526
+ * reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
527
+ * report (it has no file fallback and uses `migrate-acl` instead).
528
+ */
529
+ export function importNativeItems(prefix, commit) {
530
+ if (backend)
531
+ return { available: false, locked: false, results: [] };
532
+ assertSupportedPlatform();
533
+ if (isLinux())
534
+ return importNativeSecretToolItems(prefix, commit);
535
+ if (isWindows())
536
+ return importNativeCredManItems(prefix, commit);
537
+ return { available: false, locked: false, results: [] };
538
+ }
521
539
  function expandHome(p) {
522
540
  if (p.startsWith('~/') || p === '~') {
523
541
  return path.join(os.homedir(), p.slice(1));
@@ -17,6 +17,7 @@
17
17
  * item = the secret identifier
18
18
  */
19
19
  import type { KeychainBackend } from './index.js';
20
+ import { type NativeImportReport } from './fallback.js';
20
21
  export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
21
22
  /**
22
23
  * True when secret operations currently route to the encrypted-file store
@@ -55,6 +56,12 @@ export declare function parseSecretToolItems(output: string, prefix: string): st
55
56
  * so we use secret-tool search which outputs in a specific format.
56
57
  */
57
58
  export declare function listSecretToolItems(prefix: string): string[];
59
+ /**
60
+ * Copy agents-cli items from the keyring into the file store (the `import-keyring`
61
+ * backend for Linux). Requires an unlocked keyring; items already in the file
62
+ * store are left untouched. With `commit=false` it reports what it *would* do.
63
+ */
64
+ export declare function importNativeSecretToolItems(prefix: string, commit: boolean): NativeImportReport;
58
65
  /** KeychainBackend implementation for Linux. Routes through secret-tool
59
66
  * with a transparent encrypted-file fallback when the default Secret
60
67
  * Service collection is locked (or libsecret-tools is not installed but