@phnx-labs/agents-cli 1.22.117 → 1.22.119

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 (271) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/accounts.js +7 -4
  4. package/dist/commands/browser.d.ts +52 -70
  5. package/dist/commands/browser.js +192 -3562
  6. package/dist/commands/daemon.d.ts +39 -2
  7. package/dist/commands/daemon.js +72 -73
  8. package/dist/commands/events.js +1 -1
  9. package/dist/commands/exec.js +53 -1
  10. package/dist/commands/projects.js +92 -7
  11. package/dist/commands/repo.js +13 -49
  12. package/dist/commands/route.js +5 -5
  13. package/dist/commands/routines.js +39 -12
  14. package/dist/commands/sessions-render.d.ts +1 -1
  15. package/dist/commands/sessions-render.js +0 -1
  16. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  17. package/dist/commands/sessions.test-fixture.js +0 -37
  18. package/dist/commands/setup-browser.d.ts +17 -12
  19. package/dist/commands/setup-browser.js +52 -87
  20. package/dist/commands/setup-preferences.d.ts +0 -17
  21. package/dist/commands/setup-preferences.js +18 -49
  22. package/dist/commands/setup.js +9 -9
  23. package/dist/commands/ssh.js +10 -1
  24. package/dist/commands/sync.js +6 -44
  25. package/dist/commands/teams.js +0 -1
  26. package/dist/commands/update.d.ts +19 -0
  27. package/dist/commands/update.js +34 -6
  28. package/dist/commands/view.d.ts +12 -1
  29. package/dist/commands/view.js +47 -2
  30. package/dist/index.js +107 -26
  31. package/dist/lib/account-capabilities.js +0 -2
  32. package/dist/lib/account-catalog.d.ts +38 -0
  33. package/dist/lib/account-catalog.js +110 -26
  34. package/dist/lib/account-provider-registry.js +1 -1
  35. package/dist/lib/account-registry.d.ts +1 -1
  36. package/dist/lib/account-registry.js +36 -2
  37. package/dist/lib/accounting/rotate.js +22 -8
  38. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  39. package/dist/lib/accounting/usage-ingest.js +115 -45
  40. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  41. package/dist/lib/accounting/usage-sync.js +227 -66
  42. package/dist/lib/accounting/usage.d.ts +16 -3
  43. package/dist/lib/accounting/usage.js +26 -8
  44. package/dist/lib/accounts/add.d.ts +3 -1
  45. package/dist/lib/accounts/add.js +24 -13
  46. package/dist/lib/accounts/slots.d.ts +8 -0
  47. package/dist/lib/accounts/slots.js +17 -0
  48. package/dist/lib/add-dir.js +0 -1
  49. package/dist/lib/agent-cli-commands.js +0 -1
  50. package/dist/lib/agent-spec/agents.d.ts +6 -7
  51. package/dist/lib/agent-spec/agents.js +7 -60
  52. package/dist/lib/auth-health.d.ts +69 -1
  53. package/dist/lib/auth-health.js +133 -4
  54. package/dist/lib/browser/context.d.ts +82 -0
  55. package/dist/lib/browser/context.js +88 -0
  56. package/dist/lib/browser/paths.d.ts +25 -0
  57. package/dist/lib/browser/paths.js +51 -0
  58. package/dist/lib/browser/record.d.ts +38 -0
  59. package/dist/lib/browser/record.js +57 -0
  60. package/dist/lib/browser/sessions-list.js +1 -2
  61. package/dist/lib/browser-client.d.ts +158 -0
  62. package/dist/lib/browser-client.js +234 -0
  63. package/dist/lib/channels/owner-sink.d.ts +1 -18
  64. package/dist/lib/channels/owner-sink.js +29 -82
  65. package/dist/lib/channels/providers/rush.d.ts +7 -7
  66. package/dist/lib/channels/providers/rush.js +127 -57
  67. package/dist/lib/claude-account-token.d.ts +7 -1
  68. package/dist/lib/claude-account-token.js +26 -4
  69. package/dist/lib/claude-statusline.d.ts +1 -1
  70. package/dist/lib/claude-statusline.js +15 -3
  71. package/dist/lib/commands.js +1 -1
  72. package/dist/lib/crabbox/lease.js +1 -1
  73. package/dist/lib/crabbox/runtimes.js +1 -3
  74. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  75. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  76. package/dist/lib/daemon/auth-sync-service.js +37 -43
  77. package/dist/lib/daemon/daemon.d.ts +25 -14
  78. package/dist/lib/daemon/daemon.js +92 -154
  79. package/dist/lib/daemon/runner.d.ts +2 -2
  80. package/dist/lib/daemon/runner.js +32 -9
  81. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  82. package/dist/lib/daemon/self-update-service.js +40 -36
  83. package/dist/lib/daemon/service.d.ts +12 -8
  84. package/dist/lib/daemon/supervisor.d.ts +65 -54
  85. package/dist/lib/daemon/supervisor.js +136 -171
  86. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  87. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  88. package/dist/lib/daemon/usage-sync-service.js +46 -78
  89. package/dist/lib/daemon-health.d.ts +22 -5
  90. package/dist/lib/daemon-health.js +47 -3
  91. package/dist/lib/daemon-services.d.ts +1 -1
  92. package/dist/lib/daemon-services.js +0 -10
  93. package/dist/lib/daemon-ticks.js +4 -2
  94. package/dist/lib/devices/doctor-findings.js +8 -11
  95. package/dist/lib/devices/harness-inventory.js +14 -5
  96. package/dist/lib/exec-account-home.js +5 -0
  97. package/dist/lib/exec.js +10 -13
  98. package/dist/lib/feed/events.js +0 -1
  99. package/dist/lib/feed/tool-activity.js +5 -2
  100. package/dist/lib/fleet-shared-state.d.ts +45 -0
  101. package/dist/lib/fleet-shared-state.js +78 -9
  102. package/dist/lib/fs-atomic.js +25 -0
  103. package/dist/lib/github/pr-mergeable.d.ts +4 -2
  104. package/dist/lib/github/pr-mergeable.js +5 -3
  105. package/dist/lib/github/project-prs.d.ts +58 -3
  106. package/dist/lib/github/project-prs.js +205 -7
  107. package/dist/lib/harness/adapters/cursor.js +14 -4
  108. package/dist/lib/harness-auth-capabilities.d.ts +4 -3
  109. package/dist/lib/harness-auth-capabilities.js +1 -2
  110. package/dist/lib/helper-versions.js +1 -1
  111. package/dist/lib/hooks/cache.d.ts +7 -0
  112. package/dist/lib/hooks/cache.js +27 -2
  113. package/dist/lib/hooks/install.js +7 -1
  114. package/dist/lib/hosts/ready.d.ts +38 -8
  115. package/dist/lib/hosts/ready.js +62 -14
  116. package/dist/lib/installations/active-check.d.ts +27 -0
  117. package/dist/lib/installations/active-check.js +61 -0
  118. package/dist/lib/installations/migrate.js +6 -3
  119. package/dist/lib/installations/shims.d.ts +1 -1
  120. package/dist/lib/installations/shims.js +32 -12
  121. package/dist/lib/installations/store.d.ts +9 -0
  122. package/dist/lib/installations/store.js +24 -0
  123. package/dist/lib/installations/update.js +21 -15
  124. package/dist/lib/installations/versions.js +15 -3
  125. package/dist/lib/manifest.js +1 -1
  126. package/dist/lib/models.js +0 -2
  127. package/dist/lib/open-url.js +28 -54
  128. package/dist/lib/profiles.js +0 -2
  129. package/dist/lib/projects.d.ts +16 -0
  130. package/dist/lib/projects.js +31 -0
  131. package/dist/lib/refresh.js +1 -1
  132. package/dist/lib/routine-readiness.d.ts +35 -0
  133. package/dist/lib/routine-readiness.js +74 -16
  134. package/dist/lib/rules/compile.js +6 -0
  135. package/dist/lib/sandbox.js +0 -2
  136. package/dist/lib/secrets-policy.d.ts +89 -11
  137. package/dist/lib/secrets-policy.js +175 -28
  138. package/dist/lib/session/active.d.ts +17 -6
  139. package/dist/lib/session/active.js +105 -49
  140. package/dist/lib/session/db.d.ts +1 -1
  141. package/dist/lib/session/db.js +65 -18
  142. package/dist/lib/session/discover.js +1 -283
  143. package/dist/lib/session/mirror.d.ts +4 -3
  144. package/dist/lib/session/mirror.js +4 -3
  145. package/dist/lib/session/parse.d.ts +0 -2
  146. package/dist/lib/session/parse.js +1 -138
  147. package/dist/lib/session/pid-registry.d.ts +6 -3
  148. package/dist/lib/session/pid-registry.js +18 -8
  149. package/dist/lib/session/remote/watch.d.ts +1 -1
  150. package/dist/lib/session/remote/watch.js +7 -3
  151. package/dist/lib/session/throughput.d.ts +1 -3
  152. package/dist/lib/session/throughput.js +0 -20
  153. package/dist/lib/session/types.d.ts +2 -2
  154. package/dist/lib/session/types.js +2 -2
  155. package/dist/lib/sessions-client.d.ts +60 -1
  156. package/dist/lib/sessions-client.js +227 -4
  157. package/dist/lib/setup-tool-status.js +5 -10
  158. package/dist/lib/signin-badge.d.ts +3 -2
  159. package/dist/lib/signin-badge.js +6 -3
  160. package/dist/lib/smart-launch.js +4 -1
  161. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  162. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  163. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  164. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  165. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  166. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  167. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  168. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  169. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  170. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  171. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  172. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  173. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  174. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  175. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  176. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  177. package/dist/lib/staleness/writers/sources.js +5 -0
  178. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  179. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  180. package/dist/lib/state.d.ts +6 -4
  181. package/dist/lib/state.js +7 -17
  182. package/dist/lib/teams/parsers.d.ts +1 -1
  183. package/dist/lib/teams/parsers.js +1 -136
  184. package/dist/lib/teams/placement-probe.js +50 -15
  185. package/dist/lib/teams/scheduler.d.ts +7 -0
  186. package/dist/lib/types.d.ts +16 -1
  187. package/dist/lib/types.js +1 -1
  188. package/dist/lib/view-types.d.ts +32 -0
  189. package/dist/lib/watchdog/read.js +0 -1
  190. package/dist/lib/watchdog/rotate.js +1 -2
  191. package/dist/lib/watchdog/runner.js +2 -2
  192. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  193. package/dist/lib/watchdog/watchdogTail.js +0 -31
  194. package/package.json +1 -1
  195. package/scripts/postinstall.js +8 -8
  196. package/dist/browser.d.ts +0 -2
  197. package/dist/browser.js +0 -17
  198. package/dist/commands/browser-picker.d.ts +0 -18
  199. package/dist/commands/browser-picker.js +0 -97
  200. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  201. package/dist/lib/browser/arc-discovery.js +0 -197
  202. package/dist/lib/browser/arc-dom.d.ts +0 -14
  203. package/dist/lib/browser/arc-dom.js +0 -121
  204. package/dist/lib/browser/caller-identity.d.ts +0 -40
  205. package/dist/lib/browser/caller-identity.js +0 -175
  206. package/dist/lib/browser/cdp.d.ts +0 -51
  207. package/dist/lib/browser/cdp.js +0 -254
  208. package/dist/lib/browser/chrome.d.ts +0 -141
  209. package/dist/lib/browser/chrome.js +0 -704
  210. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  211. package/dist/lib/browser/chromium-discovery.js +0 -96
  212. package/dist/lib/browser/devices.d.ts +0 -34
  213. package/dist/lib/browser/devices.js +0 -61
  214. package/dist/lib/browser/domain-skills.d.ts +0 -71
  215. package/dist/lib/browser/domain-skills.js +0 -195
  216. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  217. package/dist/lib/browser/drivers/arc.js +0 -276
  218. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  219. package/dist/lib/browser/drivers/firefox.js +0 -377
  220. package/dist/lib/browser/drivers/local.d.ts +0 -55
  221. package/dist/lib/browser/drivers/local.js +0 -266
  222. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  223. package/dist/lib/browser/drivers/ssh.js +0 -467
  224. package/dist/lib/browser/editor.d.ts +0 -3
  225. package/dist/lib/browser/editor.js +0 -50
  226. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  227. package/dist/lib/browser/ffmpeg.js +0 -184
  228. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  229. package/dist/lib/browser/firefox-discovery.js +0 -162
  230. package/dist/lib/browser/har.d.ts +0 -85
  231. package/dist/lib/browser/har.js +0 -77
  232. package/dist/lib/browser/hygiene.d.ts +0 -97
  233. package/dist/lib/browser/hygiene.js +0 -153
  234. package/dist/lib/browser/index.d.ts +0 -5
  235. package/dist/lib/browser/index.js +0 -5
  236. package/dist/lib/browser/input.d.ts +0 -7
  237. package/dist/lib/browser/input.js +0 -92
  238. package/dist/lib/browser/ipc.d.ts +0 -204
  239. package/dist/lib/browser/ipc.js +0 -1453
  240. package/dist/lib/browser/login-detection.d.ts +0 -87
  241. package/dist/lib/browser/login-detection.js +0 -274
  242. package/dist/lib/browser/profiles.d.ts +0 -422
  243. package/dist/lib/browser/profiles.js +0 -1157
  244. package/dist/lib/browser/refs.d.ts +0 -89
  245. package/dist/lib/browser/refs.js +0 -191
  246. package/dist/lib/browser/registry.d.ts +0 -70
  247. package/dist/lib/browser/registry.js +0 -252
  248. package/dist/lib/browser/remote-control.d.ts +0 -59
  249. package/dist/lib/browser/remote-control.js +0 -85
  250. package/dist/lib/browser/resolve-target.d.ts +0 -66
  251. package/dist/lib/browser/resolve-target.js +0 -264
  252. package/dist/lib/browser/runtime-state.d.ts +0 -291
  253. package/dist/lib/browser/runtime-state.js +0 -584
  254. package/dist/lib/browser/secret-ref.d.ts +0 -10
  255. package/dist/lib/browser/secret-ref.js +0 -14
  256. package/dist/lib/browser/service.d.ts +0 -812
  257. package/dist/lib/browser/service.js +0 -4693
  258. package/dist/lib/browser/stream.d.ts +0 -17
  259. package/dist/lib/browser/stream.js +0 -72
  260. package/dist/lib/browser/task-index.d.ts +0 -78
  261. package/dist/lib/browser/task-index.js +0 -199
  262. package/dist/lib/browser/types.d.ts +0 -647
  263. package/dist/lib/browser/types.js +0 -61
  264. package/dist/lib/browser/upload.d.ts +0 -29
  265. package/dist/lib/browser/upload.js +0 -298
  266. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  267. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  268. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  269. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  270. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  271. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -28,7 +28,7 @@ import { INSTALLATION_RECORD_FILE } from './types.js';
28
28
  import { composeWin32CommandLine } from '../platform/index.js';
29
29
  import { listInstalledSubagents } from '../subagents.js';
30
30
  import { listInstalledWorkflows } from '../workflows.js';
31
- import { parseHookManifest, registerHooksToSettings, selectHookManifest, pruneVersionHomeHookEntriesFromSettings, installSessionTrackerHookSync, installSessionTrackerHook } from '../hooks/install.js';
31
+ import { parseHookManifest, registerHooksToSettings, selectHookManifest, pruneVersionHomeHookEntriesFromSettings, installSessionTrackerHookSync, installSessionTrackerHook, repairManagedHookRuntimeArtifacts } from '../hooks/install.js';
32
32
  import { supports, explainSkip } from '../capabilities.js';
33
33
  import { discoverPlugins, syncPluginToVersion, pluginSupportsAgent, cleanOrphanedPluginSkills, marketplaceSpecForName } from '../plugins/plugins.js';
34
34
  import { loadManifest, saveManifest, buildManifest as buildSyncManifest, isStale } from '../staleness/index.js';
@@ -1630,6 +1630,17 @@ export function removeVersion(agent, version) {
1630
1630
  pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, version);
1631
1631
  }
1632
1632
  }
1633
+ // The global hook shims embed one SOURCE path. When it pointed into this
1634
+ // version home every shimmed guard would exit 127 (allow) until a self-heal
1635
+ // pass ran, and that pass needs a live daemon. Re-point them now. Best
1636
+ // effort: the removal itself already succeeded, and a repair failure on an
1637
+ // unrelated harness's shim must not abort a bulk removal.
1638
+ try {
1639
+ repairManagedHookRuntimeArtifacts();
1640
+ }
1641
+ catch (err) {
1642
+ console.warn(`hook shim repair after removing ${agent}@${version} failed: ${err instanceof Error ? err.message : String(err)}`);
1643
+ }
1633
1644
  emit('version.remove', { agent, version });
1634
1645
  return true;
1635
1646
  }
@@ -2539,7 +2550,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2539
2550
  // Orphan-sweep stale skill directories from previous syncs under a
2540
2551
  // different cwd. Only runs in "full sync" mode (no explicit selection) —
2541
2552
  // see the matching guard on the commands sweep above for why. Skip
2542
- // dot-dirs to keep plugin-managed subtrees (.plugins/, .promptcuts) intact.
2553
+ // dot-dirs to keep plugin-managed subtrees (.plugins/, .promptcuts) intact,
2554
+ // and the harness's own directories (ownedSkillDirs), which it writes itself.
2543
2555
  const skillsTargetSweep = path.join(agentDir, 'skills');
2544
2556
  if (!userPassedSelection && fs.existsSync(skillsTargetSweep) && !fs.lstatSync(skillsTargetSweep).isSymbolicLink()) {
2545
2557
  // Trust real skills AND command-skills: when commandsInstallAsSkills, the
@@ -2547,7 +2559,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2547
2559
  // skills/. Those names are not in skillsToSync, so without this they'd be
2548
2560
  // swept as orphans — silently deleting every converted command (e.g.
2549
2561
  // /recap on Kimi or newer Codex releases).
2550
- const trustedSkills = new Set(skillsToSync);
2562
+ const trustedSkills = new Set([...skillsToSync, ...(agentConfig.ownedSkillDirs ?? [])]);
2551
2563
  if (commandsInstallAsSkills)
2552
2564
  for (const cmd of commandsToSync)
2553
2565
  trustedSkills.add(cmd);
@@ -125,7 +125,7 @@ export function createDefaultManifest() {
125
125
  defaults: {
126
126
  method: 'symlink',
127
127
  scope: 'global',
128
- agents: ['claude', 'codex', 'gemini', 'cursor', 'opencode'],
128
+ agents: ['claude', 'codex', 'cursor', 'opencode'],
129
129
  },
130
130
  };
131
131
  }
@@ -113,8 +113,6 @@ export function locateModelSource(agent, version) {
113
113
  }
114
114
  return null;
115
115
  }
116
- // gemini is hard-deprecated (no launch path left to validate a model
117
- // against), so its bundle is deliberately not parsed for a catalog here.
118
116
  if (agent === 'opencode') {
119
117
  // The `opencode` shim under node_modules/.bin dispatches to a platform-
120
118
  // specific native binary. We don't parse the 100MB binary; we let the CLI
@@ -101,68 +101,42 @@ export async function resolveViewer(opts = {}) {
101
101
  if (opts.osBrowser)
102
102
  return 'os';
103
103
  const { getConfigValue } = await import('./device-config.js');
104
- const configured = opts.profile ?? (getConfigValue('browser.viewer').value || undefined);
105
- // Unset means "follow the profile agents drive" — the whole point is that a
106
- // machine with a configured browser stops leaking pages to the OS handler.
107
- const { getConfiguredDefaultProfileName, resolveProfileRef, getProfile, isProfileLaunchableHere } = await import('./browser/profiles.js');
108
- const name = configured ?? getConfiguredDefaultProfileName();
109
- if (!name)
104
+ // A configured viewer, else the profile agents drive (`browser.profile`) — the
105
+ // whole point is that a machine with a configured browser stops leaking pages
106
+ // to the OS handler. Both keys are shared with the standalone `browser` CLI,
107
+ // whose `show` verb applies exactly this precedence itself; agents-cli resolves
108
+ // the NAME only to decide viewer-vs-OS, then delegates suitability (Arc/Firefox/
109
+ // launchable-here) to the engine (PHNX-4101).
110
+ const name = opts.profile
111
+ ?? (getConfigValue('browser.viewer').value || undefined)
112
+ ?? (getConfigValue('browser.profile').value || undefined);
113
+ if (!name || name === 'os')
110
114
  return 'os';
111
- if (name === 'os')
112
- return 'os';
113
- let resolved;
114
- try {
115
- resolved = await resolveProfileRef(name);
116
- }
117
- catch (err) {
118
- console.error(`[viewer] ${name}: ${err instanceof Error ? err.message : String(err)} — using the OS browser.`);
119
- return 'os';
120
- }
121
- if (!resolved) {
122
- console.error(`[viewer] "${name}" does not resolve to a profile — using the OS browser.`);
123
- return 'os';
124
- }
125
- const profile = await getProfile(resolved);
126
- if (!profile) {
127
- console.error(`[viewer] profile "${resolved}" is not configured — using the OS browser.`);
128
- return 'os';
129
- }
130
- if (profile.browser === 'arc') {
131
- // agents browser can drive an attached Arc's existing tabs, but showing the
132
- // user a page needs its OWN fresh tab, and Arc crashes on tab creation over
133
- // CDP (Target.createTarget). So Arc is a fine automation profile but never a
134
- // viewer — fall back to the OS browser for anything a human is meant to read.
135
- console.error(`[viewer] "${resolved}" is Arc, which cannot open a new viewer tab — using the OS browser.`);
136
- return 'os';
137
- }
138
- if (profile.browser === 'firefox') {
139
- // agents drives Firefox headless over BiDi for its own read-back; a page a
140
- // human is meant to read goes to the visible OS browser, not the headless one.
141
- console.error(`[viewer] "${resolved}" is a headless-automation Firefox profile — using the OS browser.`);
142
- return 'os';
143
- }
144
- if (!isProfileLaunchableHere(profile)) {
145
- console.error(`[viewer] "${resolved}" cannot launch on this machine — using the OS browser.`);
146
- return 'os';
147
- }
148
- return { profile: resolved };
115
+ return { profile: name };
149
116
  }
150
117
  /** Show a URL to the human at this machine. Never throws. */
151
118
  export async function showUrl(url, opts = {}) {
152
119
  const viewer = await resolveViewer(opts);
153
120
  if (viewer === 'os')
154
121
  return osOpen(url, opts.spawnOpen);
122
+ // Delegate to the standalone `browser show`, which opens the URL in the viewer
123
+ // profile (or `browser.viewer`/`browser.profile`) and owns its own service
124
+ // lifecycle and viewer-suitability fallbacks. When the engine is not installed,
125
+ // or the call fails, degrade to the OS handler with one stderr line.
155
126
  try {
156
- const { sendIPCRequest } = await import('./browser/ipc.js');
157
- // Deliberately does NOT auto-start the browser daemon. Showing a page is a
158
- // side errand — `devices lease` opens a console and immediately prompts for
159
- // a pasted key — so blocking that on a daemon cold start is a surprising
160
- // multi-second stall. Daemon already up: use the viewer. Not up: the OS
161
- // handler is the fast, correct answer.
162
- const response = await sendIPCRequest({ action: 'show', url, profile: viewer.profile }, { autoStartDaemon: false });
163
- if (response.ok)
164
- return { via: 'profile', profile: viewer.profile, tabId: response.tabId };
165
- console.error(`[viewer] ${viewer.profile}: ${response.error} — using the OS browser.`);
127
+ const { browserInstalled, runBrowser } = await import('./browser-client.js');
128
+ if (!browserInstalled()) {
129
+ console.error(`[viewer] the standalone browser CLI is not installed — using the OS browser.`);
130
+ return osOpen(url, opts.spawnOpen);
131
+ }
132
+ const { buildBrowserContext } = await import('./browser/context.js');
133
+ const { exitCode } = await runBrowser({
134
+ argv: ['show', url, '--profile', viewer.profile],
135
+ context: await buildBrowserContext(),
136
+ });
137
+ if (exitCode === 0)
138
+ return { via: 'profile', profile: viewer.profile };
139
+ console.error(`[viewer] ${viewer.profile}: browser show exited ${exitCode} — using the OS browser.`);
166
140
  }
167
141
  catch (err) {
168
142
  console.error(`[viewer] ${viewer.profile}: ${err instanceof Error ? err.message : String(err)} — using the OS browser.`);
@@ -352,7 +352,6 @@ const MODEL_ENV_KEY_BY_HOST = {
352
352
  claude: 'ANTHROPIC_MODEL',
353
353
  opencode: 'OPENCODE_MODEL',
354
354
  grok: 'GROK_MODEL',
355
- gemini: 'GEMINI_MODEL',
356
355
  codex: 'OPENAI_MODEL',
357
356
  };
358
357
  /** Base-URL env var per host, for the OpenAI/Anthropic-compatible hosts where it applies. */
@@ -372,7 +371,6 @@ export function baseUrlEnvKeyForHost(host) {
372
371
  const AUTH_ENV_KEY_BY_HOST = {
373
372
  claude: 'ANTHROPIC_AUTH_TOKEN',
374
373
  codex: 'OPENAI_API_KEY',
375
- gemini: 'GEMINI_API_KEY',
376
374
  grok: 'XAI_API_KEY',
377
375
  opencode: 'OPENCODE_API_KEY',
378
376
  };
@@ -193,6 +193,22 @@ export declare function projectDirsAbs(def: ProjectDef, opts: {
193
193
  forRemote: boolean;
194
194
  primary?: string;
195
195
  }): string[];
196
+ /**
197
+ * The repository paths a project claims, as `{ slug, prefix }` with `prefix`
198
+ * repo-relative and `dir/` shaped — the same ownership {@link projectRootsAbs}
199
+ * applies to a session's cwd, expressed against the repository instead of a
200
+ * checkout: a `defaultPath` narrowed under `root` claims that subtree of the
201
+ * primary `repo`, and a `repos[]` entry with a `subpath` claims that subpath. A
202
+ * project with no narrowed claim on a repository claims all of it and yields no
203
+ * row for it. Two cases of {@link projectRootsAbs} have no repository form and
204
+ * yield nothing here: a `defaultPath` outside `root` (no repo-relative path),
205
+ * and the weak root of a narrowed project (it only takes what nobody claims,
206
+ * which for a PR is the `repo-wide` scope).
207
+ */
208
+ export declare function repoPathClaims(def: ProjectDef): Array<{
209
+ slug: string;
210
+ prefix: string;
211
+ }>;
196
212
  /**
197
213
  * Which defined project a session belongs to, derived from its working
198
214
  * directory. A session whose `cwd` sits inside a project's repo root (or a
@@ -411,6 +411,37 @@ function projectRootsAbs(defs) {
411
411
  }
412
412
  return out;
413
413
  }
414
+ /**
415
+ * The repository paths a project claims, as `{ slug, prefix }` with `prefix`
416
+ * repo-relative and `dir/` shaped — the same ownership {@link projectRootsAbs}
417
+ * applies to a session's cwd, expressed against the repository instead of a
418
+ * checkout: a `defaultPath` narrowed under `root` claims that subtree of the
419
+ * primary `repo`, and a `repos[]` entry with a `subpath` claims that subpath. A
420
+ * project with no narrowed claim on a repository claims all of it and yields no
421
+ * row for it. Two cases of {@link projectRootsAbs} have no repository form and
422
+ * yield nothing here: a `defaultPath` outside `root` (no repo-relative path),
423
+ * and the weak root of a narrowed project (it only takes what nobody claims,
424
+ * which for a PR is the `repo-wide` scope).
425
+ */
426
+ export function repoPathClaims(def) {
427
+ const out = [];
428
+ const add = (slug, rel) => {
429
+ const trimmed = rel.split(path.sep).join('/').replace(/^\.\/+/, '').replace(/\/+$/, '');
430
+ if (trimmed && trimmed !== '.')
431
+ out.push({ slug, prefix: `${trimmed}/` });
432
+ };
433
+ if (def.repo && def.root && def.defaultPath) {
434
+ const rootAbs = path.resolve(expandLocalHome(def.root));
435
+ const defaultAbs = path.resolve(expandLocalHome(def.defaultPath));
436
+ if (isUnder(defaultAbs, rootAbs))
437
+ add(def.repo, path.relative(rootAbs, defaultAbs));
438
+ }
439
+ for (const r of def.repos ?? []) {
440
+ if (r.slug && r.subpath)
441
+ add(r.slug, r.subpath);
442
+ }
443
+ return out;
444
+ }
414
445
  /** True when `child` is `parent` or nested under it (path-segment aware). */
415
446
  function isUnder(child, parent) {
416
447
  if (child === parent)
@@ -32,7 +32,7 @@ function migratePromptcutsToRoot(agentsDir, quiet = false) {
32
32
  const rootPath = path.join(agentsDir, 'promptcuts.yaml');
33
33
  if (fs.existsSync(rootPath))
34
34
  return;
35
- const agentDirs = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
35
+ const agentDirs = ['claude', 'codex', 'cursor', 'opencode'];
36
36
  for (const dir of agentDirs) {
37
37
  const legacyPath = path.join(agentsDir, dir, 'promptcuts.yaml');
38
38
  if (fs.existsSync(legacyPath)) {
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { JobConfig } from './scheduling/routines.js';
13
13
  import { type RoutineReadinessResult, type RoutineReadiness } from './routine-context.js';
14
+ import { type AuthVerdict } from './auth-health.js';
14
15
  /**
15
16
  * Fire-time auth preflight: read the daemon-warmed auth-health cache for the
16
17
  * exact (agent, version) a routine has resolved to run, and return an
@@ -48,6 +49,40 @@ export declare function parseRemoteProjectSnapshot(stdout: string): {
48
49
  export declare function buildRemoteWorkspaceProbe(cwd: string, windows: boolean): string;
49
50
  /** A successful probe must contain the exact requested postcondition, not merely exit zero. */
50
51
  export declare function probeOutputHasSentinel(stdout: string, sentinel: string): boolean;
52
+ /**
53
+ * ONE decision the local ({@link evaluateActivationReadinessLive}) and host
54
+ * ({@link evaluateHostActivationReadiness}) readiness paths share, so both reach
55
+ * the SAME answer for the same box (PHNX-4116). `row` is that box's probe verdict
56
+ * for the routine's agent — ABSENT when the box could not probe (a worker's
57
+ * setup-token box, or a headed box that spent its hourly usage-endpoint budget,
58
+ * both drop `no_evidence` in `probeLocalFleetAuth`). `launchable` is whether the
59
+ * box holds a launchable signed-in credential, from the SAME
60
+ * `collectRunCandidates`/`isLaunchableSignedIn` the run router uses — so a
61
+ * token-backed routine on a box that cannot probe stays activatable, a box with
62
+ * no credential at all fails `unconfigured`, and a server rejection (`revoked` —
63
+ * the only present-but-not-accepted verdict these force-probed paths produce)
64
+ * blocks regardless of a token on disk.
65
+ */
66
+ export declare function decideRoutineAuthReadiness(row: {
67
+ verdict: AuthVerdict;
68
+ } | undefined, launchable: boolean): {
69
+ ok: boolean;
70
+ reason?: string;
71
+ };
72
+ /**
73
+ * Decide a host-placed routine's auth readiness from the REMOTE box's `devices
74
+ * ping --local --json` payload (PHNX-4116). Pure over the payload TEXT so it is
75
+ * unit-tested without SSH: it finds this agent's probe row (ABSENT when the box
76
+ * dropped its `no_evidence` row) and reads the box's per-agent launchability the
77
+ * ping now carries, then runs {@link decideRoutineAuthReadiness} — so a host box
78
+ * that could not probe but holds a token is ready, exactly as the local path
79
+ * decides for the same box. Returns null when the payload cannot be parsed; the
80
+ * caller treats that as a probe failure, never as "no credential".
81
+ */
82
+ export declare function decideHostAuthFromPing(stdout: string, agent: string): {
83
+ ok: boolean;
84
+ reason?: string;
85
+ } | null;
51
86
  /**
52
87
  * Interactive setup/repair readiness. Unlike the scheduler's deterministic
53
88
  * structural gate, this completes a real local auth request and reads Codex's
@@ -162,6 +162,62 @@ function codexWorkspaceTrusted(version, cwd) {
162
162
  return false;
163
163
  }
164
164
  }
165
+ /**
166
+ * Verdicts a routine's activation auth check accepts as usable. `no_evidence` is
167
+ * here for robustness (a payload that still carries one), but a box that cannot
168
+ * probe DROPS it in {@link probeLocalFleetAuth}, so the real path for those boxes
169
+ * is the absent-row + launchability fallback in {@link decideRoutineAuthReadiness}
170
+ * (PHNX-4116).
171
+ */
172
+ const ROUTINE_AUTH_ACCEPTED = new Set([
173
+ 'live', 'rate_limited', 'unverified', 'no_evidence',
174
+ ]);
175
+ /**
176
+ * ONE decision the local ({@link evaluateActivationReadinessLive}) and host
177
+ * ({@link evaluateHostActivationReadiness}) readiness paths share, so both reach
178
+ * the SAME answer for the same box (PHNX-4116). `row` is that box's probe verdict
179
+ * for the routine's agent — ABSENT when the box could not probe (a worker's
180
+ * setup-token box, or a headed box that spent its hourly usage-endpoint budget,
181
+ * both drop `no_evidence` in `probeLocalFleetAuth`). `launchable` is whether the
182
+ * box holds a launchable signed-in credential, from the SAME
183
+ * `collectRunCandidates`/`isLaunchableSignedIn` the run router uses — so a
184
+ * token-backed routine on a box that cannot probe stays activatable, a box with
185
+ * no credential at all fails `unconfigured`, and a server rejection (`revoked` —
186
+ * the only present-but-not-accepted verdict these force-probed paths produce)
187
+ * blocks regardless of a token on disk.
188
+ */
189
+ export function decideRoutineAuthReadiness(row, launchable) {
190
+ if (row && ROUTINE_AUTH_ACCEPTED.has(row.verdict))
191
+ return { ok: true };
192
+ if (!row)
193
+ return launchable ? { ok: true } : { ok: false, reason: 'unconfigured' };
194
+ return { ok: false, reason: row.verdict };
195
+ }
196
+ /**
197
+ * Decide a host-placed routine's auth readiness from the REMOTE box's `devices
198
+ * ping --local --json` payload (PHNX-4116). Pure over the payload TEXT so it is
199
+ * unit-tested without SSH: it finds this agent's probe row (ABSENT when the box
200
+ * dropped its `no_evidence` row) and reads the box's per-agent launchability the
201
+ * ping now carries, then runs {@link decideRoutineAuthReadiness} — so a host box
202
+ * that could not probe but holds a token is ready, exactly as the local path
203
+ * decides for the same box. Returns null when the payload cannot be parsed; the
204
+ * caller treats that as a probe failure, never as "no credential".
205
+ */
206
+ export function decideHostAuthFromPing(stdout, agent) {
207
+ let payload;
208
+ try {
209
+ payload = JSON.parse(stdout);
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ if (!payload || typeof payload !== 'object')
215
+ return null;
216
+ const { rows, launchable } = payload;
217
+ const verdict = rows?.find((row) => row.agent === agent)?.health?.verdict;
218
+ const row = verdict ? { verdict } : undefined;
219
+ return decideRoutineAuthReadiness(row, launchable?.includes(agent) ?? false);
220
+ }
165
221
  /**
166
222
  * Interactive setup/repair readiness. Unlike the scheduler's deterministic
167
223
  * structural gate, this completes a real local auth request and reads Codex's
@@ -180,13 +236,14 @@ export async function evaluateActivationReadinessLive(config) {
180
236
  if (!version)
181
237
  return structural;
182
238
  const context = resolveJobExecutionContext(config, { mode: 'local' });
183
- let authVerdict;
239
+ // The probe row (absent when this box could not probe — a worker's setup-token
240
+ // box drops `no_evidence`) plus the run-router launchability, run through the
241
+ // ONE decision the host path also uses so both agree for the same box.
184
242
  const rows = await probeLocalFleetAuth({ agents: [config.agent] });
185
243
  const row = rows.find((candidate) => candidate.version === version);
186
- const accepted = new Set(['live', 'rate_limited', 'unverified']);
187
- authVerdict = row && accepted.has(row.health.verdict)
188
- ? { ok: true }
189
- : { ok: false, reason: row?.health.verdict ?? 'unconfigured' };
244
+ const { collectRunCandidates } = await import('./accounting/rotate.js');
245
+ const launchable = (await collectRunCandidates(config.agent)).some((candidate) => candidate.signedIn);
246
+ const authVerdict = decideRoutineAuthReadiness(row?.health, launchable);
190
247
  return evaluateRoutineReadiness(context, {
191
248
  agentInstalled: () => true,
192
249
  ...(config.agent === 'codex' && context.absoluteCwd
@@ -259,19 +316,20 @@ export async function evaluateHostActivationReadiness(config) {
259
316
  ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`${POWERSHELL_PROGRESS_SILENCE}; & agents ${pingArgs.map(powershellQuote).join(' ')}`)}`
260
317
  : `agents ${pingArgs.map(shellQuote).join(' ')}`;
261
318
  const probe = sshExec(target, command, { timeoutMs: 30_000, extraSshArgs: identity });
262
- let verdict = 'error';
263
- if (probe.code === 0) {
264
- try {
265
- const payload = JSON.parse(probe.stdout);
266
- verdict = payload.rows?.find((row) => row.agent === config.agent)?.health.verdict ?? 'unconfigured';
267
- }
268
- catch {
269
- verdict = 'error';
270
- }
319
+ // A ping that could not run or parse is a probe FAILURE, never mistaken for
320
+ // "no credential": block with the failure so the operator sees it. A parsed
321
+ // payload runs the shared decision — an ABSENT row (the remote dropped its
322
+ // `no_evidence`) plus the launchability the ping now carries is ready,
323
+ // exactly as the local path decides for the same box (PHNX-4116).
324
+ const decision = probe.code === 0 ? decideHostAuthFromPing(probe.stdout, config.agent) : null;
325
+ if (!decision) {
326
+ return evaluateRoutineReadiness(unprobed, {
327
+ authOk: () => ({ ok: false, reason: `devices ping --local failed on ${host.name}` }),
328
+ }, { agent: config.agent });
271
329
  }
272
- if (!new Set(['live', 'rate_limited', 'unverified']).has(verdict)) {
330
+ if (!decision.ok) {
273
331
  return evaluateRoutineReadiness(unprobed, {
274
- authOk: () => ({ ok: false, reason: verdict }),
332
+ authOk: () => decision,
275
333
  }, { agent: config.agent });
276
334
  }
277
335
  }
@@ -303,6 +303,12 @@ export function compileRulesForProject(cwd, opts = {}) {
303
303
  const fname = agent.instructionsFile;
304
304
  if (seen.has(fname))
305
305
  continue;
306
+ // Hard-deprecated agents (e.g. Gemini, retired by Google) never get an
307
+ // instruction-file symlink — the harness is gone, so GEMINI.md would only
308
+ // litter the tree. Mirrors the deprecated?.hard skip used everywhere else
309
+ // (capabilities.ts, MANAGED_AGENT_IDS, modes.ts).
310
+ if (agent.deprecated?.hard)
311
+ continue;
306
312
  // Skip agents whose instructions live at a nested path (e.g. OpenClaw's
307
313
  // workspace/AGENTS.md) — those are managed by their own setup paths.
308
314
  if (fname.includes('/') || fname.includes('\\'))
@@ -137,8 +137,6 @@ export function prepareJobHome(config, version) {
137
137
  const overlayHome = getJobHomePath(config.name);
138
138
  cleanJobHome(config.name);
139
139
  fs.mkdirSync(overlayHome, { recursive: true });
140
- // gemini is hard-deprecated: runner.ts rejects a gemini routine before it
141
- // ever reaches prepareJobHome, so there is deliberately no branch here.
142
140
  if (config.agent === 'claude') {
143
141
  generateClaudeConfig(overlayHome, config);
144
142
  }
@@ -2,7 +2,7 @@ import { type SecretsContext } from './secrets-client.js';
2
2
  import type { PushBundleResult } from './secrets-types.js';
3
3
  import type { AgentId, Meta, NativeAccountRecord } from './types.js';
4
4
  import { type DeviceProfile } from './devices/registry.js';
5
- import { type SharedAuthStatus } from './fleet-shared-state.js';
5
+ import { type FleetSharedDeviceState, type SharedAuthStatus } from './fleet-shared-state.js';
6
6
  import { selfConfiguredDeviceRole } from './device-config.js';
7
7
  import { AUTH_STORE_ALIAS, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, type StorableCredentialKind } from './reserved-stores.js';
8
8
  export { AUTH_STORE_ALIAS, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, type StorableCredentialKind, };
@@ -107,6 +107,25 @@ export declare function publishReservedAuthVerdict(options?: PublishAuthVerdictO
107
107
  * only peers whose shared verdict says the bundle is missing.
108
108
  */
109
109
  export declare function syncReservedAuthBundle(deps?: AuthSyncDeps): Promise<AuthSyncResult>;
110
+ /**
111
+ * Skip reason for a peer that has never sent a daemon-state reply (no
112
+ * `devices/<peer>/daemon-state.json`). auth-sync logs these at INFO — a
113
+ * brand-new or never-dialed worker legitimately has no reply on an early tick,
114
+ * so it is informational, not a warning. Stable so the service classifies the
115
+ * skip without matching a substring.
116
+ */
117
+ export declare const SKIP_REASON_NO_PEER_REPLY = "no daemon-state reply from this peer yet";
118
+ /**
119
+ * Skip reason for a peer whose reply exists but carries no `accounts.rows` field
120
+ * at all (an older CLI that does not publish per-account verdicts, or a partial
121
+ * state written before the account-state daemon ran). Without those rows there is
122
+ * no first-hand knowledge of what the peer holds, so the plan FAILS CLOSED and
123
+ * skips rather than pushing every key blindly. auth-sync logs it at INFO — it is
124
+ * transient during a rolling upgrade. An EMPTY `accounts.rows` array (a peer with
125
+ * no registered account) is NOT this case: it is a legitimate "holds nothing",
126
+ * and its keys are (correctly) pushed to provision it.
127
+ */
128
+ export declare const SKIP_REASON_NO_ACCOUNT_ROWS = "daemon-state reply carries no account rows (fail closed)";
110
129
  /** One account's durable worker credential, resolved to (bundle, key). */
111
130
  export interface ReservedSyncAccount {
112
131
  accountId: string;
@@ -116,8 +135,11 @@ export interface ReservedSyncAccount {
116
135
  /** Storage key `<ENV>_<accountId>` (or the legacy email-keyed claude key). */
117
136
  key: string;
118
137
  /**
119
- * `workerCredential.mintedAt` -- a re-mint bumps it, so a delivered-memo hit on
120
- * the old value re-pushes. `'legacy'` for a pre-T1 claude row with no field.
138
+ * The credential's rotation fingerprint: `workerCredential.mintedAt` for a T1
139
+ * row, `'legacy'` for a pre-T1 claude row. A re-mint (`accounts login`) bumps
140
+ * `mintedAt`, so this changes even though the OLD token still authenticates —
141
+ * which is why presence is gated on BOTH the peer's verdict AND a delivered-
142
+ * fingerprint match (see {@link peerPresentKeys}).
121
143
  */
122
144
  fingerprint: string;
123
145
  }
@@ -128,7 +150,24 @@ export interface ReservedSyncPeer {
128
150
  headed: boolean;
129
151
  reachable: boolean;
130
152
  pinned: boolean;
131
- /** bundle -> keys the peer already holds at the current fingerprint. Absent ⇒ none known. */
153
+ /**
154
+ * Whether this peer has ever sent a daemon-state reply on this box
155
+ * (`devices/<peer>/daemon-state.json` exists). Without one there is no
156
+ * first-hand knowledge of what it holds, so the peer is skipped this tick.
157
+ */
158
+ hasReply: boolean;
159
+ /**
160
+ * Whether the peer's reply actually carried an `accounts.rows` array (present,
161
+ * even if empty). A reply file with no such field (older CLI, partial state)
162
+ * has no first-hand inventory, so the plan skips it fail-closed rather than
163
+ * pushing blindly. See {@link SKIP_REASON_NO_ACCOUNT_ROWS}.
164
+ */
165
+ hasAccountRows: boolean;
166
+ /**
167
+ * bundle -> keys the peer is KNOWN to hold: its own reply verdict says present
168
+ * AND the fingerprint we last delivered to it matches the current one. Absent ⇒
169
+ * none known.
170
+ */
132
171
  presentKeys: Record<string, ReadonlySet<string>>;
133
172
  }
134
173
  type ReservedSyncPlanItem = {
@@ -144,7 +183,12 @@ type ReservedSyncPlanItem = {
144
183
  /**
145
184
  * Pure plan: for each worker peer, push each bundle it is missing at least one
146
185
  * key of. Deterministic (peers and bundles sorted) so it pins exactly in tests.
147
- * A headed peer is skipped BEFORE any key comparison -- it never receives a key.
186
+ * A headed peer is skipped BEFORE any key comparison -- it never receives a key;
187
+ * a peer with no reply yet (`hasReply: false`) is skipped next -- there is no
188
+ * first-hand knowledge of what it holds, so nothing is pushed to it this tick;
189
+ * a peer whose reply carries no `accounts.rows` field (`hasAccountRows: false`)
190
+ * is skipped FAIL-CLOSED for the same reason (an older CLI's reply is not proof
191
+ * of an empty inventory), rather than pushing every key blindly.
148
192
  */
149
193
  export declare function planReservedStoreSync(accounts: ReservedSyncAccount[], peers: ReservedSyncPeer[]): ReservedSyncPlanItem[];
150
194
  /**
@@ -156,14 +200,41 @@ export declare function planReservedStoreSync(accounts: ReservedSyncAccount[], p
156
200
  * `workerCredential`) is not a sync target.
157
201
  */
158
202
  export declare function reservedSyncTargets(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): ReservedSyncAccount[];
203
+ /** The subset of a peer's per-account verdict row this planner reads. */
204
+ export interface PeerAccountVerdict {
205
+ accountId: string;
206
+ harness: string;
207
+ /** `missing` ⇒ no working credential on the peer; anything else ⇒ present. */
208
+ verdict: string;
209
+ }
210
+ /** Narrow a peer's opaque `accounts.rows` to the verdict fields the planner reads. */
211
+ export declare function readPeerAccountVerdicts(state: FleetSharedDeviceState | undefined): PeerAccountVerdict[];
212
+ /**
213
+ * True when the peer's reply carried an `accounts.rows` array at all (present,
214
+ * even if empty). An older CLI, or a partial state written before the account-
215
+ * state daemon ran, has no such field — and its absence must NOT read as "the
216
+ * peer holds nothing", or the plan would push every key blindly. An EMPTY array
217
+ * (a peer with no registered account) IS a valid first-hand "holds nothing".
218
+ */
219
+ export declare function peerHasAccountRows(state: FleetSharedDeviceState | undefined): boolean;
159
220
  /**
160
- * The keys a peer is KNOWN to hold, per bundle, from what we have delivered to it
161
- * (memo, matched by fingerprint) plus the coarse legacy-`auth` verdict: a peer
162
- * reporting `auth.status === 'ready'` is known to hold every legacy `auth` key.
163
- * Reserved `__<harness>__` bundles have no coarse verdict, so their presence comes
164
- * from the memo alone -- which is what makes a newly-added reserved account push.
221
+ * The reserved keys a peer is KNOWN to hold, per bundle. Two conditions must BOTH
222
+ * hold (PHNX-4116):
223
+ *
224
+ * 1. FIRST-HAND — the peer's own daemon-state reply reports a NON-`missing`
225
+ * verdict for the account, so it has a working credential for it (its slot
226
+ * materialized). A `missing` verdict, or no row for the account at all, means
227
+ * it does not — self-correcting: a key removed on the worker flips its next
228
+ * verdict to `missing` and the push resumes, which a publisher-side memo alone
229
+ * could never see.
230
+ * 2. ROTATION — the fingerprint we last delivered to this peer (`delivered`)
231
+ * matches the account's CURRENT fingerprint. A re-mint bumps
232
+ * `workerCredential.mintedAt` while the old token still authenticates, so the
233
+ * verdict stays non-`missing`; without this the rotated key would never
234
+ * propagate. A never-delivered key (no memo entry ⇒ `undefined`) never
235
+ * matches, so a newly-added account still pushes.
165
236
  */
166
- export declare function peerPresentKeys(peer: string, accounts: ReservedSyncAccount[], memo: Record<string, string>, legacyAuthReady: boolean): Record<string, ReadonlySet<string>>;
237
+ export declare function peerPresentKeys(accounts: ReservedSyncAccount[], verdicts: PeerAccountVerdict[], delivered: (bundle: string, key: string) => string | undefined): Record<string, ReadonlySet<string>>;
167
238
  interface ReservedStoreSyncResult {
168
239
  publisher: string | null;
169
240
  /** Legacy raw reserved items adopted into their bundle on this box before planning (local repair). */
@@ -189,6 +260,7 @@ interface ReservedStoreSyncDeps {
189
260
  listDevices?: () => DeviceProfile[];
190
261
  localName?: string;
191
262
  userAgentsDir?: string;
263
+ /** Where the publisher-side delivery memo lives; defaults to `getCacheDir()`. */
192
264
  cacheDir?: string;
193
265
  readMetaFn?: () => Pick<Meta, 'accounts' | 'deviceAccounts'>;
194
266
  isPinned?: (name: string) => boolean;
@@ -221,6 +293,8 @@ interface ReservedStoreSyncDeps {
221
293
  export declare function syncReservedStores(deps?: ReservedStoreSyncDeps): Promise<ReservedStoreSyncResult>;
222
294
  interface ReconcileWorkerSlotsResult {
223
295
  provisioned: string[];
296
+ /** Stale slots removed from the device doc (accountId no longer registered). */
297
+ dropped: string[];
224
298
  skipped: Array<{
225
299
  accountId: string;
226
300
  reason: string;
@@ -237,6 +311,10 @@ interface ReconcileWorkerSlotsDeps {
237
311
  provision?: (account: NativeAccountRecord) => void;
238
312
  /** True when an existing durable slot already carries everything provisioning seeds. */
239
313
  slotSeeded?: (harness: AgentId, slotDir: string) => boolean;
314
+ /** Remove stale slot records from the device doc. Default: {@link dropSlots}. */
315
+ dropSlots?: (accountIds: readonly string[]) => void;
316
+ /** Emit an operational log line. Default: the daemon log (fire-and-forget). */
317
+ log?: (level: 'INFO' | 'WARN', message: string) => void;
240
318
  }
241
319
  /**
242
320
  * Worker-side: after a durable key lands, materialize a slot for each portable