@phnx-labs/agents-cli 1.20.46 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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
+
11
+ ## 1.20.45
12
+
5
13
  ## 1.20.46
6
14
 
7
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`.
@@ -9,6 +17,7 @@
9
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).
10
18
  ## 1.20.45
11
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`.
20
+
12
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`.
13
22
  - **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`.
14
23
  - **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`.
@@ -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` /
@@ -454,6 +454,35 @@ export async function runDaemon() {
454
454
  };
455
455
  const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
456
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);
457
486
  const handleReload = () => {
458
487
  log('INFO', 'Reloading jobs (SIGHUP)');
459
488
  scheduler.reloadAll();
@@ -475,6 +504,8 @@ export async function runDaemon() {
475
504
  clearTimeout(deviceProbeKickoff);
476
505
  clearInterval(tmuxReconcileInterval);
477
506
  clearTimeout(tmuxReconcileKickoff);
507
+ clearInterval(launchHealthInterval);
508
+ clearTimeout(launchHealthKickoff);
478
509
  removeDaemonPid();
479
510
  process.exit(0);
480
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
@@ -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;
@@ -2456,6 +2456,8 @@ export function readKimiMeta(filePath) {
2456
2456
  project = parts.slice(0, -1).join('/');
2457
2457
  }
2458
2458
  }
2459
+ // Parse wire.jsonl to extract message count and token usage
2460
+ const { messageCount, tokenCount } = parseKimiWireMetrics(sessionDir);
2459
2461
  const meta = {
2460
2462
  id: sessionId,
2461
2463
  shortId,
@@ -2464,9 +2466,48 @@ export function readKimiMeta(filePath) {
2464
2466
  project,
2465
2467
  filePath,
2466
2468
  topic,
2469
+ messageCount,
2470
+ tokenCount: tokenCount > 0 ? tokenCount : undefined,
2467
2471
  };
2468
2472
  return { meta, content: lastPrompt || '' };
2469
2473
  }
2474
+ /** Parse Kimi's wire.jsonl to extract message count and token usage.
2475
+ * TODO: optimize to stream (like scanClaudeSession) to avoid loading large files into memory.
2476
+ * For now, synchronous readFileSync matches the pattern of reading state.json and is acceptable
2477
+ * since session dirs are usually fresh in FS cache during incremental scans. */
2478
+ function parseKimiWireMetrics(sessionDir) {
2479
+ const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
2480
+ let messageCount = 0;
2481
+ let tokenCount = 0;
2482
+ if (!fs.existsSync(wirePath)) {
2483
+ return { messageCount: 0, tokenCount: 0 };
2484
+ }
2485
+ try {
2486
+ const lines = fs.readFileSync(wirePath, 'utf-8').split('\n');
2487
+ for (const line of lines) {
2488
+ if (!line.trim())
2489
+ continue;
2490
+ try {
2491
+ const event = JSON.parse(line);
2492
+ if (event.type === 'context.append_message') {
2493
+ messageCount++;
2494
+ }
2495
+ else if (event.type === 'usage.record' && event.usage) {
2496
+ // Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
2497
+ const u = event.usage;
2498
+ tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
2499
+ }
2500
+ }
2501
+ catch {
2502
+ // Malformed line, skip
2503
+ }
2504
+ }
2505
+ }
2506
+ catch {
2507
+ // If wire.jsonl can't be read, return 0s (graceful degradation)
2508
+ }
2509
+ return { messageCount, tokenCount };
2510
+ }
2470
2511
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
2471
2512
  export function parseTimeFilter(input) {
2472
2513
  const relativeMatch = input.match(/^(\d+)([mhdw])$/i);
@@ -320,6 +320,21 @@ export declare function isMissingBinarySignature(output: string): boolean;
320
320
  * missing-binary signature (see isMissingBinarySignature) fails the check; a
321
321
  * plain nonzero exit or a timeout is treated as healthy so we never false-fail.
322
322
  */
323
+ /**
324
+ * Compose the spawn spec for a `<binary> --version` launch probe. On Windows the
325
+ * `.cmd` wrapper runs through cmd.exe, so the path is fully quoted into ONE
326
+ * command line and the args array is emptied (composeWin32CommandLine) — the
327
+ * DEP0190-safe pattern the real launch uses. Critically this keeps a spaced
328
+ * Windows profile path (`C:\Users\John Doe\…\claude.cmd`) intact; passing the raw
329
+ * path to a shell would split it at the space and false-fail a HEALTHY install.
330
+ * On POSIX no shell is involved and the binary is exec'd directly. Pure/exported
331
+ * so the quoting is unit-testable without spawning.
332
+ */
333
+ export declare function probeSpawnSpec(binary: string, isWin: boolean): {
334
+ command: string;
335
+ args: string[];
336
+ shell: boolean;
337
+ };
323
338
  export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: string): Promise<{
324
339
  ok: boolean;
325
340
  detail?: string;
@@ -344,6 +359,7 @@ export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: s
344
359
  * droid) have no such tarball and are returned unchanged.
345
360
  */
346
361
  export declare function ensureAgentRunnable(agent: AgentId, version: string, log?: (message: string) => void): Promise<string | null>;
362
+ export declare function healBrokenDefaultLaunches(log?: (m: string) => void): Promise<string[]>;
347
363
  /** Outcome of syncing resources to a version home, keyed by resource type. */
348
364
  export interface SyncResult {
349
365
  commands: boolean;
@@ -33,7 +33,7 @@ import { discoverPermissionGroups, getActivePermissionPresetName, readPermission
33
33
  import { parseMcpServerConfig } from './mcp.js';
34
34
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
35
35
  import { importInstallScriptBinary } from './import.js';
36
- import { IS_WINDOWS } from './platform/index.js';
36
+ import { IS_WINDOWS, composeWin32CommandLine } from './platform/index.js';
37
37
  import { pruneVersionHomeHookEntriesFromSettings } from './hooks.js';
38
38
  import { supports, explainSkip } from './capabilities.js';
39
39
  import { discoverPlugins } from './plugins.js';
@@ -1621,22 +1621,48 @@ export function isMissingBinarySignature(output) {
1621
1621
  * missing-binary signature (see isMissingBinarySignature) fails the check; a
1622
1622
  * plain nonzero exit or a timeout is treated as healthy so we never false-fail.
1623
1623
  */
1624
+ /**
1625
+ * Compose the spawn spec for a `<binary> --version` launch probe. On Windows the
1626
+ * `.cmd` wrapper runs through cmd.exe, so the path is fully quoted into ONE
1627
+ * command line and the args array is emptied (composeWin32CommandLine) — the
1628
+ * DEP0190-safe pattern the real launch uses. Critically this keeps a spaced
1629
+ * Windows profile path (`C:\Users\John Doe\…\claude.cmd`) intact; passing the raw
1630
+ * path to a shell would split it at the space and false-fail a HEALTHY install.
1631
+ * On POSIX no shell is involved and the binary is exec'd directly. Pure/exported
1632
+ * so the quoting is unit-testable without spawning.
1633
+ */
1634
+ export function probeSpawnSpec(binary, isWin) {
1635
+ if (isWin)
1636
+ return { command: composeWin32CommandLine(binary, ['--version']), args: [], shell: true };
1637
+ return { command: binary, args: ['--version'], shell: false };
1638
+ }
1624
1639
  export async function verifyInstalledBinaryLaunches(agent, version) {
1625
- // Windows: `getBinaryPath` returns the extensionless `.bin/<cli>` (a shell
1626
- // wrapper), NOT the `.cmd`/`.exe` that actually launches there — `execFile`ing
1627
- // it would ENOENT on a perfectly healthy install, and the integrity gate would
1628
- // then WIPE it. The gutted-native-binary failure this guards against is a POSIX
1629
- // concern in practice; treat win32 as healthy rather than risk destroying a
1630
- // good install. (isVersionInstalled already validates presence on Windows.)
1631
- if (process.platform === 'win32')
1632
- return { ok: true };
1633
- const binary = getBinaryPath(agent, version);
1640
+ // The real launch target differs by platform, so probe whatever `agents run`
1641
+ // actually execs. On Windows that's the npm `.cmd` wrapper (exec.ts uses
1642
+ // `absPath + '.cmd'`), which chains to the native `.exe`; a gutted install
1643
+ // (renamed/missing `.exe`) makes that wrapper emit "is not recognized" the
1644
+ // exact win-mini failure a vendor auto-update leaves behind. Probing the
1645
+ // extensionless `.bin/<cli>` instead would ENOENT even on a HEALTHY Windows
1646
+ // install, so we DON'T. On POSIX the `.bin/<cli>` binary is the launch target
1647
+ // and is probed directly.
1648
+ const isWin = process.platform === 'win32';
1649
+ const binary = isWin ? getBinaryPath(agent, version) + '.cmd' : getBinaryPath(agent, version);
1634
1650
  if (!fs.existsSync(binary)) {
1635
- return { ok: false, detail: `binary not found at ${binary}` };
1651
+ // Windows: a missing `.cmd` means a non-npm/global agent (droid.exe) we can't
1652
+ // safely probe — treat as healthy (isVersionInstalled validates presence).
1653
+ // POSIX: a missing launch binary is a genuine gutted install.
1654
+ return isWin ? { ok: true } : { ok: false, detail: `binary not found at ${binary}` };
1636
1655
  }
1637
1656
  try {
1638
- await execFileAsync(binary, ['--version'], {
1657
+ // On Windows the `.cmd` runs via cmd.exe (shell). Pass a single FULLY-QUOTED
1658
+ // command line + EMPTY args (composeWin32CommandLine) — the same DEP0190-safe
1659
+ // pattern the real launch uses (exec.ts) — so a space in the Windows profile
1660
+ // path (`C:\Users\John Doe\…`) can't split the path and false-fail a healthy
1661
+ // install into a destructive reinstall.
1662
+ const spec = probeSpawnSpec(binary, isWin);
1663
+ await execFileAsync(spec.command, spec.args, {
1639
1664
  timeout: 15000,
1665
+ shell: spec.shell,
1640
1666
  env: { ...process.env, HOME: getVersionHomePath(agent, version) },
1641
1667
  });
1642
1668
  return { ok: true };
@@ -1703,6 +1729,51 @@ export async function ensureAgentRunnable(agent, version, log) {
1703
1729
  }
1704
1730
  return null;
1705
1731
  }
1732
+ /**
1733
+ * Proactive launch-health pass for the daemon. Probe the DEFAULT version of
1734
+ * every npm-package agent and repair any that won't launch (via
1735
+ * ensureAgentRunnable), so a gutted install is healed BEFORE the user's next
1736
+ * `agents run` hits a raw ENOENT — the run-time heal (ensureAgentRunnable) only
1737
+ * fires once a run is already starting; this catches it in the background.
1738
+ *
1739
+ * Returns a label (`agent@broken→healed`) for each version actually repaired, so
1740
+ * the daemon can log/notify. A version that already launches costs one cheap
1741
+ * `--version` probe and is left untouched.
1742
+ */
1743
+ const failedRepairAt = new Map();
1744
+ const REPAIR_COOLDOWN_MS = 24 * 60 * 60_000;
1745
+ export async function healBrokenDefaultLaunches(log) {
1746
+ const repaired = [];
1747
+ for (const agent of Object.keys(AGENTS)) {
1748
+ if (!AGENTS[agent].npmPackage)
1749
+ continue; // native/global agents have no gutted-tarball failure mode
1750
+ const version = getGlobalDefault(agent);
1751
+ if (!version)
1752
+ continue;
1753
+ if ((await verifyInstalledBinaryLaunches(agent, version)).ok)
1754
+ continue;
1755
+ // Backoff: a version whose repair just failed (offline, npm 404, an arch the
1756
+ // registry can't serve) must NOT re-trigger a full clean-reinstall +
1757
+ // install-latest on every 6h pass. Skip it for a day; a daemon restart clears
1758
+ // the memo, giving a fresh attempt.
1759
+ const key = `${agent}@${version}`;
1760
+ const last = failedRepairAt.get(key);
1761
+ if (last !== undefined && Date.now() - last < REPAIR_COOLDOWN_MS) {
1762
+ log?.(`${AGENTS[agent].name}@${version} still won't launch — repair attempted recently, skipping until cooldown elapses.`);
1763
+ continue;
1764
+ }
1765
+ log?.(`${AGENTS[agent].name}@${version} won't launch — repairing…`);
1766
+ const healed = await ensureAgentRunnable(agent, version, log);
1767
+ if (healed) {
1768
+ failedRepairAt.delete(key);
1769
+ repaired.push(`${agent}@${version}${healed === version ? '' : `→${healed}`}`);
1770
+ }
1771
+ else {
1772
+ failedRepairAt.set(key, Date.now());
1773
+ }
1774
+ }
1775
+ return repaired;
1776
+ }
1706
1777
  async function getCliVersionFromPath(agent) {
1707
1778
  const agentConfig = AGENTS[agent];
1708
1779
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.46",
3
+ "version": "1.20.47",
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",