@phnx-labs/agents-cli 1.20.33 → 1.20.35

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 (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
@@ -18,7 +18,7 @@ import { listInstalledVersions, getVersionHomePath } from './versions.js';
18
18
  import { AGENTS, agentConfigDirName } from './agents.js';
19
19
  import { capableAgents, isCapable } from './capabilities.js';
20
20
  import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './command-skills.js';
21
- import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
21
+ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, registerDroidInstalledPlugin, unregisterDroidInstalledPlugin, isDroidPluginInstalled, registerCopilotInstalledPlugin, unregisterCopilotInstalledPlugin, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
22
22
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
23
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
24
  const USER_CONFIG_FILE = '.user-config.json';
@@ -416,7 +416,7 @@ export function marketplaceSpecForName(name, cwd = process.cwd()) {
416
416
  * carries, not just the user one.
417
417
  */
418
418
  function listVersionMarketplaceNames(agent, versionHome) {
419
- const dir = path.join(versionHome, `.${agent}`, 'plugins', 'marketplaces');
419
+ const dir = path.join(versionHome, agentConfigDirName(agent), 'plugins', 'marketplaces');
420
420
  if (!fs.existsSync(dir))
421
421
  return [];
422
422
  try {
@@ -444,6 +444,11 @@ function listVersionMarketplaceNames(agent, versionHome) {
444
444
  * MCP servers, bin/, settings.json, and permissions once the plugin lives at the
445
445
  * native install path and is marked enabled — see
446
446
  * https://code.claude.com/docs/en/plugins.
447
+ *
448
+ * Droid (Factory CLI) reuses this same marketplace layout but additionally
449
+ * requires an installed_plugins.json registry entry and a "local"-source
450
+ * known_marketplaces.json entry before `droid plugin list` will see the plugin
451
+ * (steps handled by registerMarketplace's droid branch + step 5c below).
447
452
  */
448
453
  export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
449
454
  const result = {
@@ -491,9 +496,25 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
491
496
  // .mcp.json, settings.json, permissions/) are only auto-enabled when the
492
497
  // caller explicitly opts in. addPluginToSettings does no gating — that moved
493
498
  // here, where plugin capabilities are inspected.
494
- if (options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root))) {
499
+ const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
500
+ if (enablePlugin) {
495
501
  addPluginToSettings(plugin.name, marketplaceName, agent, versionHome);
496
502
  }
503
+ // 5c. Droid diverges from Claude's marketplace+enabledPlugins model: it only
504
+ // "sees" a plugin that also has an entry in installed_plugins.json (with
505
+ // the marketplace registered as source "local"). Register the install so
506
+ // `droid plugin list` shows it — Active when enabled, Inactive otherwise.
507
+ if (agent === 'droid') {
508
+ registerDroidInstalledPlugin(plugin.name, marketplaceName, installDir, plugin.manifest.version, agent, versionHome);
509
+ }
510
+ // 5d. Copilot, like Droid, only "sees" a plugin that also has an entry in its
511
+ // auto-managed config.json#installedPlugins (registerMarketplace already
512
+ // wrote settings.json#extraKnownMarketplaces, and addPluginToSettings the
513
+ // enabledPlugins flag). cache_path points at the marketplace copy; the
514
+ // `enabled` flag mirrors the exec-surface trust gate above.
515
+ if (agent === 'copilot') {
516
+ registerCopilotInstalledPlugin(plugin.name, marketplaceName, installDir, plugin.manifest.version, enablePlugin, agent, versionHome);
517
+ }
497
518
  // 5b. Convert plugin commands/ to skills for agents that dropped command support
498
519
  // (Codex >= 0.117.0). Skill name is prefixed with plugin name to avoid
499
520
  // collision with standalone command skills.
@@ -718,7 +739,15 @@ function migrateLegacyFlatLayout(plugin, agent, versionHome) {
718
739
  export function isPluginSynced(plugin, agent, versionHome) {
719
740
  if (!isCapable(agent, 'plugins'))
720
741
  return false;
721
- return isInstalledInMarketplace(plugin.name, marketplaceSpecForName(plugin.marketplace), agent, versionHome);
742
+ const spec = marketplaceSpecForName(plugin.marketplace);
743
+ if (!isInstalledInMarketplace(plugin.name, spec, agent, versionHome))
744
+ return false;
745
+ // Droid additionally requires its installed_plugins.json registry entry —
746
+ // without it the marketplace copy is invisible to `droid plugin list`.
747
+ if (agent === 'droid') {
748
+ return isDroidPluginInstalled(plugin.name, marketplaceNameFor(spec), agent, versionHome);
749
+ }
750
+ return true;
722
751
  }
723
752
  // ─── Removal ─────────────────────────────────────────────────────────────────
724
753
  /**
@@ -746,6 +775,12 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
746
775
  removedAny = true;
747
776
  }
748
777
  removePluginFromSettings(pluginName, name, agent, versionHome);
778
+ if (agent === 'droid') {
779
+ unregisterDroidInstalledPlugin(pluginName, name, agent, versionHome);
780
+ }
781
+ if (agent === 'copilot') {
782
+ unregisterCopilotInstalledPlugin(pluginName, name, agent, versionHome);
783
+ }
749
784
  // Refresh marketplace.json so it reflects what's left under plugins/.
750
785
  syncMarketplaceManifest(spec, agent, versionHome);
751
786
  // If we just removed the last plugin, drop the marketplace dir and the
@@ -930,6 +965,12 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
930
965
  fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
931
966
  fs.renameSync(path.join(mktPluginsDir, entry.name), trashDest);
932
967
  removePluginFromSettings(entry.name, name, agent, versionHome);
968
+ if (agent === 'droid') {
969
+ unregisterDroidInstalledPlugin(entry.name, name, agent, versionHome);
970
+ }
971
+ if (agent === 'copilot') {
972
+ unregisterCopilotInstalledPlugin(entry.name, name, agent, versionHome);
973
+ }
933
974
  removed.push(entry.name);
934
975
  trashedHere = true;
935
976
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * PowerShell `-EncodedCommand` helper.
3
+ *
4
+ * Base64 of a script's UTF-16LE bytes is a single quote-free token, so it rides
5
+ * through Node spawn → Windows sshd → cmd.exe with zero escaping hazards
6
+ * (hand-quoted `powershell -Command "…"` is fragile the moment a path, URL, or
7
+ * newline is involved). Shared by the browser SSH driver (which builds a
8
+ * `powershell -EncodedCommand …` string) and the Windows secrets backend (which
9
+ * spawns powershell.exe with an argv array).
10
+ */
11
+ export declare function encodePwshBase64(script: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * PowerShell `-EncodedCommand` helper.
3
+ *
4
+ * Base64 of a script's UTF-16LE bytes is a single quote-free token, so it rides
5
+ * through Node spawn → Windows sshd → cmd.exe with zero escaping hazards
6
+ * (hand-quoted `powershell -Command "…"` is fragile the moment a path, URL, or
7
+ * newline is involved). Shared by the browser SSH driver (which builds a
8
+ * `powershell -EncodedCommand …` string) and the Windows secrets backend (which
9
+ * spawns powershell.exe with an argv array).
10
+ */
11
+ export function encodePwshBase64(script) {
12
+ return Buffer.from(script, 'utf16le').toString('base64');
13
+ }
@@ -25,6 +25,19 @@
25
25
  import type { SecretsBundle } from './bundles.js';
26
26
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
27
27
  export declare const DEFAULT_TTL_MS: number;
28
+ /**
29
+ * Reserved store-key prefix for the `secrets list` metadata snapshot cache.
30
+ * The broker holds the resolved bundle-metadata array (names/policy/timestamps,
31
+ * NO resolved secret values beyond the literals already in metadata) keyed by a
32
+ * hash of the current keychain bundle name-set, so the second and later
33
+ * `secrets list` within the daily window read metadata without a Touch ID
34
+ * prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
35
+ * changes the key and misses the cache automatically — no active invalidation.
36
+ * The '!' sentinel can never collide with a real bundle name
37
+ * (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
38
+ * spawnSync argv (unlike a NUL byte); `status` hides these entries.
39
+ */
40
+ export declare const META_CACHE_PREFIX = "!meta:";
28
41
  /**
29
42
  * Decide whether a persistent broker should self-heal onto freshly-installed
30
43
  * code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
@@ -119,6 +132,14 @@ export type Response = {
119
132
  * unit-testable with a controlled `now`, without a socket or a spawned process.
120
133
  * Mutates `store` in place; returns the wire response.
121
134
  */
135
+ /**
136
+ * Count of real unlocked bundles in the store, excluding the internal
137
+ * `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
138
+ * and idle-exit: a metadata-only store must read as empty so a disposable list
139
+ * cache never blocks an upgrade restart (#435) or an idle one-off broker from
140
+ * exiting. Pure + exported for unit testing.
141
+ */
142
+ export declare function realBundleCount(store: Map<string, StoredBundle>): number;
122
143
  export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
123
144
  /**
124
145
  * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
@@ -140,6 +161,22 @@ export declare function agentGetSync(name: string): {
140
161
  bundle: SecretsBundle;
141
162
  env: Record<string, string>;
142
163
  } | null;
164
+ /**
165
+ * Read the cached `secrets list` metadata snapshot for the given keychain
166
+ * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
167
+ * fast-path socket read (agentGetSync) — no prompt, no wire change. The hash is
168
+ * the cache key: a changed name-set (bundle added/removed/renamed) yields a
169
+ * different key and therefore a clean miss, so the stale set is never served.
170
+ */
171
+ export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] | null;
172
+ /**
173
+ * Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
174
+ * the next `secrets list` within the daily window renders without a prompt.
175
+ * Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
176
+ * reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
177
+ * detached worker (never argv/disk), same as value caching. macOS only.
178
+ */
179
+ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: SecretsBundle[], ttlMs: number): void;
143
180
  /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
144
181
  * broker is the mechanism that delivers the `daily` default policy (one Touch ID
145
182
  * per ~24h), so auto-caching is ON by default; opt out with
@@ -171,7 +208,11 @@ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Reco
171
208
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
172
209
  * wiped, or 0 when no broker is running. */
173
210
  export declare function agentLock(name?: string): Promise<number>;
174
- /** List currently-unlocked bundles, or [] when no broker is running. */
211
+ /** List currently-unlocked bundles, or [] when no broker is running. The
212
+ * internal `secrets list` metadata-cache entry is filtered out here as well as
213
+ * server-side: during a rollout a NEW client can talk to an OLD broker that
214
+ * predates the server-side exclusion, so this keeps the internal entry from
215
+ * surfacing in `agents secrets status` in that skew window. */
175
216
  export declare function agentStatus(): Promise<AgentStatusEntry[]>;
176
217
  /**
177
218
  * Ensure a broker is running and reachable. Returns true once the socket answers
@@ -36,6 +36,19 @@ import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  const PROTOCOL_VERSION = 1;
37
37
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
38
38
  export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
39
+ /**
40
+ * Reserved store-key prefix for the `secrets list` metadata snapshot cache.
41
+ * The broker holds the resolved bundle-metadata array (names/policy/timestamps,
42
+ * NO resolved secret values beyond the literals already in metadata) keyed by a
43
+ * hash of the current keychain bundle name-set, so the second and later
44
+ * `secrets list` within the daily window read metadata without a Touch ID
45
+ * prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
46
+ * changes the key and misses the cache automatically — no active invalidation.
47
+ * The '!' sentinel can never collide with a real bundle name
48
+ * (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
49
+ * spawnSync argv (unlike a NUL byte); `status` hides these entries.
50
+ */
51
+ export const META_CACHE_PREFIX = '!meta:';
39
52
  /** After the store goes empty (all bundles locked or expired) for this long,
40
53
  * the broker exits so no idle process lingers holding a socket. */
41
54
  const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
@@ -233,6 +246,20 @@ export async function uninstallSecretsAgentService() {
233
246
  * unit-testable with a controlled `now`, without a socket or a spawned process.
234
247
  * Mutates `store` in place; returns the wire response.
235
248
  */
249
+ /**
250
+ * Count of real unlocked bundles in the store, excluding the internal
251
+ * `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
252
+ * and idle-exit: a metadata-only store must read as empty so a disposable list
253
+ * cache never blocks an upgrade restart (#435) or an idle one-off broker from
254
+ * exiting. Pure + exported for unit testing.
255
+ */
256
+ export function realBundleCount(store) {
257
+ let n = 0;
258
+ for (const name of store.keys())
259
+ if (!name.startsWith(META_CACHE_PREFIX))
260
+ n++;
261
+ return n;
262
+ }
236
263
  export function handleAgentRequest(store, req, now = Date.now()) {
237
264
  switch (req.cmd) {
238
265
  case 'ping':
@@ -266,6 +293,8 @@ export function handleAgentRequest(store, req, now = Date.now()) {
266
293
  for (const [name, e] of store) {
267
294
  if (now >= e.expiresAt)
268
295
  continue;
296
+ if (name.startsWith(META_CACHE_PREFIX))
297
+ continue; // internal list cache, not a user bundle
269
298
  entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
270
299
  }
271
300
  return { ok: true, cmd: 'status', entries };
@@ -321,22 +350,28 @@ export async function runSecretsAgent(opts = {}) {
321
350
  // an in-place upgrade has landed and self-heal onto it. getCliVersion caches
322
351
  // this value for the process lifetime; getCliVersionFresh re-reads on disk.
323
352
  const runningVersion = getCliVersion();
353
+ // "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
354
+ // the internal `secrets list` metadata cache (#524). Otherwise a 24h-TTL list
355
+ // cache would keep the store non-empty and (a) block the persistent broker
356
+ // from self-healing onto a freshly-installed version for up to a day (#435's
357
+ // gate is size===0), and (b) stop a one-off broker from ever idle-exiting. The
358
+ // metadata cache is a disposable list snapshot — wiping it on upgrade/idle
359
+ // costs at most one extra prompt on the next `secrets list`.
324
360
  const sweep = () => {
325
361
  const now = Date.now();
326
362
  for (const [name, e] of store)
327
363
  if (now >= e.expiresAt)
328
364
  store.delete(name);
329
- // Self-heal onto a newer in-place install — but ONLY while the store is
330
- // empty, so we never wipe live unlocks and force a re-prompt (#435). The
331
- // `store.size === 0` short-circuit also keeps getCliVersionFresh (a disk
332
- // read) off the hot path. A pending upgrade is adopted at the next idle
333
- // sweep instead of immediately.
334
- if (store.size === 0 &&
335
- shouldSelfHealForUpgrade(persistent, store.size, runningVersion, getCliVersionFresh())) {
365
+ const live = realBundleCount(store);
366
+ // Self-heal onto a newer in-place install but ONLY while no real unlocks
367
+ // are held, so we never wipe live unlocks and force a re-prompt (#435). A
368
+ // metadata-only store still self-heals (the list cache is disposable).
369
+ if (live === 0 &&
370
+ shouldSelfHealForUpgrade(persistent, live, runningVersion, getCliVersionFresh())) {
336
371
  shutdown(0); // KeepAlive relaunches on the new code
337
372
  return;
338
373
  }
339
- if (store.size === 0) {
374
+ if (live === 0) {
340
375
  if (!persistent && now - emptySince >= IDLE_EXIT_MS)
341
376
  shutdown(0);
342
377
  }
@@ -346,7 +381,7 @@ export async function runSecretsAgent(opts = {}) {
346
381
  };
347
382
  const handle = (req) => {
348
383
  const resp = handleAgentRequest(store, req);
349
- if (store.size > 0)
384
+ if (realBundleCount(store) > 0)
350
385
  emptySince = Date.now();
351
386
  return resp;
352
387
  };
@@ -529,6 +564,44 @@ export function agentGetSync(name) {
529
564
  return null;
530
565
  }
531
566
  }
567
+ // Key inside the cached entry's env that holds the JSON metadata snapshot.
568
+ const META_SNAPSHOT_KEY = '__snapshot__';
569
+ /**
570
+ * Read the cached `secrets list` metadata snapshot for the given keychain
571
+ * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
572
+ * fast-path socket read (agentGetSync) — no prompt, no wire change. The hash is
573
+ * the cache key: a changed name-set (bundle added/removed/renamed) yields a
574
+ * different key and therefore a clean miss, so the stale set is never served.
575
+ */
576
+ export function agentGetMetaSync(nameSetHash) {
577
+ if (!onDarwin())
578
+ return null;
579
+ const hit = agentGetSync(META_CACHE_PREFIX + nameSetHash);
580
+ const raw = hit?.env?.[META_SNAPSHOT_KEY];
581
+ if (!raw)
582
+ return null;
583
+ try {
584
+ const parsed = JSON.parse(raw);
585
+ return Array.isArray(parsed) ? parsed : null;
586
+ }
587
+ catch {
588
+ return null;
589
+ }
590
+ }
591
+ /**
592
+ * Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
593
+ * the next `secrets list` within the daily window renders without a prompt.
594
+ * Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
595
+ * reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
596
+ * detached worker (never argv/disk), same as value caching. macOS only.
597
+ */
598
+ export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
599
+ if (!onDarwin())
600
+ return;
601
+ const key = META_CACHE_PREFIX + nameSetHash;
602
+ const placeholder = { name: key, vars: {} };
603
+ agentAutoLoadSync(key, placeholder, { [META_SNAPSHOT_KEY]: JSON.stringify(bundles) }, ttlMs);
604
+ }
532
605
  /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
533
606
  * broker is the mechanism that delivers the `daily` default policy (one Touch ID
534
607
  * per ~24h), so auto-caching is ON by default; opt out with
@@ -606,10 +679,15 @@ export async function agentLock(name) {
606
679
  const r = await request({ cmd: 'lock', name });
607
680
  return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
608
681
  }
609
- /** List currently-unlocked bundles, or [] when no broker is running. */
682
+ /** List currently-unlocked bundles, or [] when no broker is running. The
683
+ * internal `secrets list` metadata-cache entry is filtered out here as well as
684
+ * server-side: during a rollout a NEW client can talk to an OLD broker that
685
+ * predates the server-side exclusion, so this keeps the internal entry from
686
+ * surfacing in `agents secrets status` in that skew window. */
610
687
  export async function agentStatus() {
611
688
  const r = await request({ cmd: 'status' });
612
- return r?.ok === true && r.cmd === 'status' ? r.entries : [];
689
+ const entries = r?.ok === true && r.cmd === 'status' ? r.entries : [];
690
+ return entries.filter((e) => !e.name.startsWith(META_CACHE_PREFIX));
613
691
  }
614
692
  /** Ping result: whether a broker is reachable + speaking our protocol, and the
615
693
  * version of the code it's running (for staleness detection). */
@@ -21,11 +21,12 @@ import * as fs from 'fs';
21
21
  import * as os from 'os';
22
22
  import * as path from 'path';
23
23
  import * as yaml from 'yaml';
24
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
24
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
28
- import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
+ import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
29
+ import { createHash } from 'node:crypto';
29
30
  const keychainStore = {
30
31
  has: hasKeychainToken,
31
32
  get: getKeychainToken,
@@ -398,15 +399,45 @@ export function listBundles() {
398
399
  .map((s) => s.slice(BUNDLE_META_PREFIX.length))
399
400
  .filter((n) => BUNDLE_NAME_PATTERN.test(n));
400
401
  if (keychainNames.length > 0) {
401
- const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
402
- for (const name of keychainNames) {
403
- const json = fetched.get(bundleMetaItem(name));
404
- if (json === undefined)
405
- continue;
406
- const bundle = parseBundleMeta(name, json, 'keychain');
407
- if (bundle)
402
+ // Daily-policy fast-path (macOS). Bundle metadata items are biometry-gated,
403
+ // so the getKeychainTokens batch below pops Touch ID on every `secrets
404
+ // list` the broker/`daily` mechanism only ever covered value reads, not
405
+ // this listing. Serve a broker-cached metadata snapshot when one is held,
406
+ // so only the first list per ~24h prompts. The cache key is a hash of the
407
+ // current keychain name-set (enumerated silently above): add / remove /
408
+ // rename a bundle and the key changes, so the stale snapshot is never
409
+ // served — no active invalidation needed. Values are never cached here;
410
+ // this is metadata only.
411
+ const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
412
+ !isKeychainBackendOverridden() &&
413
+ secretsAgentAutoEnabled();
414
+ const nameSetHash = createHash('sha256')
415
+ .update([...keychainNames].sort().join('\n'))
416
+ .digest('hex')
417
+ .slice(0, 32);
418
+ const cached = useAgent ? agentGetMetaSync(nameSetHash) : null;
419
+ if (cached) {
420
+ for (const bundle of cached)
408
421
  out.push(bundle);
409
422
  }
423
+ else {
424
+ const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
425
+ const keychainBundles = [];
426
+ for (const name of keychainNames) {
427
+ const json = fetched.get(bundleMetaItem(name));
428
+ if (json === undefined)
429
+ continue;
430
+ const bundle = parseBundleMeta(name, json, 'keychain');
431
+ if (bundle)
432
+ keychainBundles.push(bundle);
433
+ }
434
+ for (const bundle of keychainBundles)
435
+ out.push(bundle);
436
+ // Populate the broker for the rest of the daily window (fire-and-forget).
437
+ if (useAgent && keychainBundles.length > 0) {
438
+ agentAutoLoadMetaSync(nameSetHash, keychainBundles, DEFAULT_TTL_MS);
439
+ }
440
+ }
410
441
  }
411
442
  }
412
443
  // File-backed bundles live in the encrypted-file store. Enumeration is a
@@ -20,11 +20,12 @@
20
20
  * item and its keychain twin carry identical names:
21
21
  * `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
22
22
  */
23
- import { execSync } from 'child_process';
23
+ import { execSync, spawnSync } from 'child_process';
24
24
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
25
25
  import * as fs from 'fs';
26
26
  import * as os from 'os';
27
27
  import * as path from 'path';
28
+ import { encodePwshBase64 } from '../pwsh.js';
28
29
  // ---------- file store location ----------
29
30
  let fileDirOverride = null;
30
31
  let cachedPassphrase = null;
@@ -36,7 +37,36 @@ function ensureFileDir() {
36
37
  fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
37
38
  }
38
39
  // ---------- passphrase ----------
40
+ /**
41
+ * Windows has no `/dev/tty` and no POSIX `stty`, so the interactive prompt runs
42
+ * through PowerShell's `Read-Host -AsSecureString` (which never echoes). The
43
+ * secure string is marshaled back out and written to stdout, which we capture.
44
+ * If PowerShell cannot run at all, fail with an actionable error rather than
45
+ * letting `fs.openSync('/dev/tty')` throw a raw ENOENT. Reached only on the rare
46
+ * interactive-Windows file-fallback path — the headless service-account case
47
+ * (no TTY) auto-provisions a machine-local key and never gets here.
48
+ */
49
+ function readPassphraseFromTtyWindows() {
50
+ const script = `
51
+ $ErrorActionPreference = 'Stop'
52
+ $sec = Read-Host -AsSecureString -Prompt 'Enter AGENTS_SECRETS_PASSPHRASE'
53
+ $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
54
+ try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }
55
+ finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
56
+ `;
57
+ // Not -NonInteractive: that flag would suppress the Read-Host prompt itself.
58
+ const res = spawnSync('powershell.exe', ['-NoProfile', '-EncodedCommand', encodePwshBase64(script)], {
59
+ stdio: ['inherit', 'pipe', 'inherit'],
60
+ });
61
+ if (res.error || res.status !== 0) {
62
+ throw new Error('Could not prompt for a passphrase on Windows. Set AGENTS_SECRETS_PASSPHRASE ' +
63
+ 'to decrypt the file-backed secret store.');
64
+ }
65
+ return (res.stdout?.toString() ?? '').replace(/\r?\n$/, '');
66
+ }
39
67
  function readPassphraseFromTty() {
68
+ if (process.platform === 'win32')
69
+ return readPassphraseFromTtyWindows();
40
70
  const fd = fs.openSync('/dev/tty', 'r+');
41
71
  let echoDisabled = false;
42
72
  try {
@@ -12,7 +12,10 @@
12
12
  * Linux: libsecret (GNOME Keyring) via the `secret-tool` CLI. No biometry —
13
13
  * items are unlocked when the keyring is open.
14
14
  *
15
- * Windows: not supported.
15
+ * Windows: Windows Credential Manager (CRED_TYPE_GENERIC,
16
+ * CRED_PERSIST_LOCAL_MACHINE) via a PowerShell P/Invoke shim, with the same
17
+ * AES-256-GCM encrypted-file fallback used on Linux when the credential store
18
+ * is unreachable (no logon session / no powershell.exe). No biometry.
16
19
  *
17
20
  * Items are device-local: the biometry access control requires the OS to
18
21
  * treat them as bound to this device, so cross-machine propagation goes
@@ -42,6 +45,19 @@ export declare function parseBundleValue(raw: BundleValue): {
42
45
  };
43
46
  /** Serialize a secret ref back to its `provider:value` string form. */
44
47
  export declare function serializeRef(ref: SecretRef): string;
48
+ /**
49
+ * Guard a secret value before it is written to the current platform's primary
50
+ * backend.
51
+ *
52
+ * A value is empty on every platform → always rejected. Embedded newlines are
53
+ * rejected ONLY on darwin: the macOS batch read path (`get-batch`, see
54
+ * getKeychainTokens) is newline-delimited, so a value with a newline would
55
+ * corrupt record framing on read. Linux (secret-tool), Windows (Credential
56
+ * Manager stores the raw UTF-8 blob and emits base64), and the encrypted-file
57
+ * fallback all store raw bytes and round-trip multiline values (PEM / SSH keys)
58
+ * faithfully, so they accept newlines. `platform` is injectable for tests.
59
+ */
60
+ export declare function assertValueStorable(value: string, platform?: NodeJS.Platform): void;
45
61
  /** Build the keychain item name for a profile provider token. */
46
62
  export declare function profileKeychainItem(provider: string): string;
47
63
  /** Build the keychain item name for a secrets-bundle key. */
@@ -61,6 +77,11 @@ export interface KeychainBackend {
61
77
  }
62
78
  /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
63
79
  export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
80
+ /** True when a test backend is installed (real keychain / biometry bypassed).
81
+ * Callers that gate on the live secrets-agent broker use this to stay hermetic —
82
+ * with an in-memory backend there is no real keychain to dedup, so the broker
83
+ * fast-path must not engage. Always false in production (`backend` is null). */
84
+ export declare function isKeychainBackendOverridden(): boolean;
64
85
  /** Check if a keychain/keyring item exists. Never prompts for biometry. */
65
86
  export declare function hasKeychainToken(item: string): boolean;
66
87
  /**
@@ -99,6 +120,17 @@ export declare function deleteKeychainToken(item: string): boolean;
99
120
  export declare function keychainUsesFileFallback(): boolean;
100
121
  /** Enumerate keychain/keyring item names starting with the given prefix. */
101
122
  export declare function listKeychainItems(prefix: string): string[];
123
+ /**
124
+ * Enumerate ONLY legacy file-based-keychain item names with the given prefix —
125
+ * the items that still carry a pre-migration (trusted-app) ACL and pop a
126
+ * separate auth sheet on read. Items already in the data-protection keychain are
127
+ * excluded (they need no migration). Silent (attributes only, never decrypts).
128
+ *
129
+ * macOS only: on Linux / the test backend there is no separate legacy keychain,
130
+ * so this returns []. Used by `agents secrets migrate-acl` to rewrite only the
131
+ * stragglers instead of every item (which would be a Touch ID storm).
132
+ */
133
+ export declare function listLegacyKeychainItems(prefix: string): string[];
102
134
  /**
103
135
  * One-time upgrade for a keychain item that was written by a previous helper
104
136
  * generation with a trusted-app ACL. The helper reads the legacy item