@phnx-labs/agents-cli 1.22.117 → 1.22.118

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 (248) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/browser.d.ts +52 -70
  4. package/dist/commands/browser.js +192 -3562
  5. package/dist/commands/daemon.d.ts +39 -2
  6. package/dist/commands/daemon.js +72 -73
  7. package/dist/commands/events.js +1 -1
  8. package/dist/commands/exec.js +53 -1
  9. package/dist/commands/repo.js +13 -49
  10. package/dist/commands/route.js +5 -5
  11. package/dist/commands/routines.js +39 -12
  12. package/dist/commands/sessions-render.d.ts +1 -1
  13. package/dist/commands/sessions-render.js +0 -1
  14. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  15. package/dist/commands/sessions.test-fixture.js +0 -37
  16. package/dist/commands/setup-browser.d.ts +17 -12
  17. package/dist/commands/setup-browser.js +52 -87
  18. package/dist/commands/setup-preferences.d.ts +0 -17
  19. package/dist/commands/setup-preferences.js +18 -49
  20. package/dist/commands/setup.js +9 -9
  21. package/dist/commands/ssh.js +10 -1
  22. package/dist/commands/sync.js +6 -44
  23. package/dist/commands/teams.js +0 -1
  24. package/dist/commands/update.d.ts +19 -0
  25. package/dist/commands/update.js +34 -6
  26. package/dist/commands/view.d.ts +12 -1
  27. package/dist/commands/view.js +47 -2
  28. package/dist/index.js +107 -26
  29. package/dist/lib/account-capabilities.js +0 -2
  30. package/dist/lib/account-catalog.d.ts +38 -0
  31. package/dist/lib/account-catalog.js +110 -26
  32. package/dist/lib/account-provider-registry.js +1 -1
  33. package/dist/lib/account-registry.d.ts +1 -1
  34. package/dist/lib/account-registry.js +36 -2
  35. package/dist/lib/accounting/rotate.js +22 -8
  36. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  37. package/dist/lib/accounting/usage-ingest.js +115 -45
  38. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  39. package/dist/lib/accounting/usage-sync.js +227 -66
  40. package/dist/lib/accounting/usage.d.ts +16 -3
  41. package/dist/lib/accounting/usage.js +26 -8
  42. package/dist/lib/accounts/slots.d.ts +8 -0
  43. package/dist/lib/accounts/slots.js +17 -0
  44. package/dist/lib/add-dir.js +0 -1
  45. package/dist/lib/agent-cli-commands.js +0 -1
  46. package/dist/lib/agent-spec/agents.d.ts +6 -7
  47. package/dist/lib/agent-spec/agents.js +6 -60
  48. package/dist/lib/auth-health.d.ts +69 -1
  49. package/dist/lib/auth-health.js +133 -4
  50. package/dist/lib/browser/context.d.ts +82 -0
  51. package/dist/lib/browser/context.js +88 -0
  52. package/dist/lib/browser/paths.d.ts +25 -0
  53. package/dist/lib/browser/paths.js +51 -0
  54. package/dist/lib/browser/record.d.ts +38 -0
  55. package/dist/lib/browser/record.js +57 -0
  56. package/dist/lib/browser/sessions-list.js +1 -2
  57. package/dist/lib/browser-client.d.ts +158 -0
  58. package/dist/lib/browser-client.js +234 -0
  59. package/dist/lib/channels/owner-sink.d.ts +1 -18
  60. package/dist/lib/channels/owner-sink.js +29 -82
  61. package/dist/lib/channels/providers/rush.d.ts +7 -7
  62. package/dist/lib/channels/providers/rush.js +127 -57
  63. package/dist/lib/claude-statusline.d.ts +1 -1
  64. package/dist/lib/claude-statusline.js +15 -3
  65. package/dist/lib/commands.js +1 -1
  66. package/dist/lib/crabbox/lease.js +1 -1
  67. package/dist/lib/crabbox/runtimes.js +1 -3
  68. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  69. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  70. package/dist/lib/daemon/auth-sync-service.js +37 -43
  71. package/dist/lib/daemon/daemon.d.ts +25 -14
  72. package/dist/lib/daemon/daemon.js +92 -154
  73. package/dist/lib/daemon/runner.d.ts +2 -2
  74. package/dist/lib/daemon/runner.js +32 -9
  75. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  76. package/dist/lib/daemon/self-update-service.js +40 -36
  77. package/dist/lib/daemon/service.d.ts +12 -8
  78. package/dist/lib/daemon/supervisor.d.ts +65 -54
  79. package/dist/lib/daemon/supervisor.js +136 -171
  80. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  81. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  82. package/dist/lib/daemon/usage-sync-service.js +46 -78
  83. package/dist/lib/daemon-health.d.ts +22 -5
  84. package/dist/lib/daemon-health.js +47 -3
  85. package/dist/lib/daemon-services.d.ts +1 -1
  86. package/dist/lib/daemon-services.js +0 -10
  87. package/dist/lib/daemon-ticks.js +4 -2
  88. package/dist/lib/devices/doctor-findings.js +8 -11
  89. package/dist/lib/devices/harness-inventory.js +14 -5
  90. package/dist/lib/exec-account-home.js +5 -0
  91. package/dist/lib/exec.js +0 -11
  92. package/dist/lib/feed/events.js +0 -1
  93. package/dist/lib/feed/tool-activity.js +5 -2
  94. package/dist/lib/fleet-shared-state.d.ts +45 -0
  95. package/dist/lib/fleet-shared-state.js +78 -9
  96. package/dist/lib/fs-atomic.js +25 -0
  97. package/dist/lib/harness-auth-capabilities.js +0 -1
  98. package/dist/lib/helper-versions.js +1 -1
  99. package/dist/lib/hooks/cache.d.ts +7 -0
  100. package/dist/lib/hooks/cache.js +27 -2
  101. package/dist/lib/hooks/install.js +7 -1
  102. package/dist/lib/hosts/ready.d.ts +38 -8
  103. package/dist/lib/hosts/ready.js +62 -14
  104. package/dist/lib/installations/active-check.d.ts +27 -0
  105. package/dist/lib/installations/active-check.js +61 -0
  106. package/dist/lib/installations/migrate.js +6 -3
  107. package/dist/lib/installations/shims.js +9 -7
  108. package/dist/lib/installations/update.js +21 -15
  109. package/dist/lib/installations/versions.js +12 -1
  110. package/dist/lib/manifest.js +1 -1
  111. package/dist/lib/models.js +0 -2
  112. package/dist/lib/open-url.js +28 -54
  113. package/dist/lib/profiles.js +0 -2
  114. package/dist/lib/refresh.js +1 -1
  115. package/dist/lib/routine-readiness.d.ts +35 -0
  116. package/dist/lib/routine-readiness.js +74 -16
  117. package/dist/lib/rules/compile.js +6 -0
  118. package/dist/lib/sandbox.js +0 -2
  119. package/dist/lib/secrets-policy.d.ts +89 -11
  120. package/dist/lib/secrets-policy.js +175 -28
  121. package/dist/lib/session/db.d.ts +1 -1
  122. package/dist/lib/session/db.js +65 -18
  123. package/dist/lib/session/discover.js +1 -283
  124. package/dist/lib/session/mirror.d.ts +4 -3
  125. package/dist/lib/session/mirror.js +4 -3
  126. package/dist/lib/session/parse.d.ts +0 -2
  127. package/dist/lib/session/parse.js +1 -138
  128. package/dist/lib/session/throughput.d.ts +1 -3
  129. package/dist/lib/session/throughput.js +0 -20
  130. package/dist/lib/session/types.d.ts +2 -2
  131. package/dist/lib/session/types.js +2 -2
  132. package/dist/lib/sessions-client.d.ts +60 -1
  133. package/dist/lib/sessions-client.js +227 -4
  134. package/dist/lib/setup-tool-status.js +5 -10
  135. package/dist/lib/signin-badge.d.ts +3 -2
  136. package/dist/lib/signin-badge.js +6 -3
  137. package/dist/lib/smart-launch.js +4 -1
  138. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  139. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  140. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  141. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  142. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  143. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  144. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  145. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  146. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  147. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  148. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  149. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  150. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  151. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  152. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  153. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  154. package/dist/lib/staleness/writers/sources.js +5 -0
  155. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  156. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  157. package/dist/lib/state.d.ts +6 -4
  158. package/dist/lib/state.js +7 -17
  159. package/dist/lib/teams/parsers.d.ts +1 -1
  160. package/dist/lib/teams/parsers.js +1 -136
  161. package/dist/lib/teams/placement-probe.js +50 -15
  162. package/dist/lib/teams/scheduler.d.ts +7 -0
  163. package/dist/lib/types.d.ts +10 -1
  164. package/dist/lib/types.js +1 -1
  165. package/dist/lib/view-types.d.ts +32 -0
  166. package/dist/lib/watchdog/read.js +0 -1
  167. package/dist/lib/watchdog/rotate.js +1 -2
  168. package/dist/lib/watchdog/runner.js +2 -2
  169. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  170. package/dist/lib/watchdog/watchdogTail.js +0 -31
  171. package/package.json +1 -1
  172. package/scripts/postinstall.js +8 -8
  173. package/dist/browser.d.ts +0 -2
  174. package/dist/browser.js +0 -17
  175. package/dist/commands/browser-picker.d.ts +0 -18
  176. package/dist/commands/browser-picker.js +0 -97
  177. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  178. package/dist/lib/browser/arc-discovery.js +0 -197
  179. package/dist/lib/browser/arc-dom.d.ts +0 -14
  180. package/dist/lib/browser/arc-dom.js +0 -121
  181. package/dist/lib/browser/caller-identity.d.ts +0 -40
  182. package/dist/lib/browser/caller-identity.js +0 -175
  183. package/dist/lib/browser/cdp.d.ts +0 -51
  184. package/dist/lib/browser/cdp.js +0 -254
  185. package/dist/lib/browser/chrome.d.ts +0 -141
  186. package/dist/lib/browser/chrome.js +0 -704
  187. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  188. package/dist/lib/browser/chromium-discovery.js +0 -96
  189. package/dist/lib/browser/devices.d.ts +0 -34
  190. package/dist/lib/browser/devices.js +0 -61
  191. package/dist/lib/browser/domain-skills.d.ts +0 -71
  192. package/dist/lib/browser/domain-skills.js +0 -195
  193. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  194. package/dist/lib/browser/drivers/arc.js +0 -276
  195. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  196. package/dist/lib/browser/drivers/firefox.js +0 -377
  197. package/dist/lib/browser/drivers/local.d.ts +0 -55
  198. package/dist/lib/browser/drivers/local.js +0 -266
  199. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  200. package/dist/lib/browser/drivers/ssh.js +0 -467
  201. package/dist/lib/browser/editor.d.ts +0 -3
  202. package/dist/lib/browser/editor.js +0 -50
  203. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  204. package/dist/lib/browser/ffmpeg.js +0 -184
  205. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  206. package/dist/lib/browser/firefox-discovery.js +0 -162
  207. package/dist/lib/browser/har.d.ts +0 -85
  208. package/dist/lib/browser/har.js +0 -77
  209. package/dist/lib/browser/hygiene.d.ts +0 -97
  210. package/dist/lib/browser/hygiene.js +0 -153
  211. package/dist/lib/browser/index.d.ts +0 -5
  212. package/dist/lib/browser/index.js +0 -5
  213. package/dist/lib/browser/input.d.ts +0 -7
  214. package/dist/lib/browser/input.js +0 -92
  215. package/dist/lib/browser/ipc.d.ts +0 -204
  216. package/dist/lib/browser/ipc.js +0 -1453
  217. package/dist/lib/browser/login-detection.d.ts +0 -87
  218. package/dist/lib/browser/login-detection.js +0 -274
  219. package/dist/lib/browser/profiles.d.ts +0 -422
  220. package/dist/lib/browser/profiles.js +0 -1157
  221. package/dist/lib/browser/refs.d.ts +0 -89
  222. package/dist/lib/browser/refs.js +0 -191
  223. package/dist/lib/browser/registry.d.ts +0 -70
  224. package/dist/lib/browser/registry.js +0 -252
  225. package/dist/lib/browser/remote-control.d.ts +0 -59
  226. package/dist/lib/browser/remote-control.js +0 -85
  227. package/dist/lib/browser/resolve-target.d.ts +0 -66
  228. package/dist/lib/browser/resolve-target.js +0 -264
  229. package/dist/lib/browser/runtime-state.d.ts +0 -291
  230. package/dist/lib/browser/runtime-state.js +0 -584
  231. package/dist/lib/browser/secret-ref.d.ts +0 -10
  232. package/dist/lib/browser/secret-ref.js +0 -14
  233. package/dist/lib/browser/service.d.ts +0 -812
  234. package/dist/lib/browser/service.js +0 -4693
  235. package/dist/lib/browser/stream.d.ts +0 -17
  236. package/dist/lib/browser/stream.js +0 -72
  237. package/dist/lib/browser/task-index.d.ts +0 -78
  238. package/dist/lib/browser/task-index.js +0 -199
  239. package/dist/lib/browser/types.d.ts +0 -647
  240. package/dist/lib/browser/types.js +0 -61
  241. package/dist/lib/browser/upload.d.ts +0 -29
  242. package/dist/lib/browser/upload.js +0 -298
  243. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  244. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  245. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  246. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  247. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  248. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -24,7 +24,7 @@ import { resolveClaudeSetupToken } from '../claude-account-token.js';
24
24
  import { formatBackoffRemaining, noteUsageRateLimited, usageRateLimitedUntil, } from '../usage-backoff.js';
25
25
  import { getCacheDir } from '../state.js';
26
26
  import { mapBounded } from '../concurrency.js';
27
- import { atomicWriteFileSync, ensureLockTarget, withFileLock } from '../fs-atomic.js';
27
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock, withFileLockAsync } from '../fs-atomic.js';
28
28
  import { withRefreshLease } from '../refresh-coordinator.js';
29
29
  import { padToWidth } from '../session/width.js';
30
30
  const execFileAsync = promisify(execFile);
@@ -723,6 +723,19 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
723
723
  }
724
724
  return parts.join(' ');
725
725
  }
726
+ /**
727
+ * The snapshot's windows that are still LIVE — reset time not yet passed. A
728
+ * window past its `resetsAt` has rolled over: its `usedPercent` is a reading of
729
+ * the PREVIOUS period, not a live throttle (PHNX-4116). Both the throttle
730
+ * verdict ({@link deriveUsageStatusFromSnapshot}) and the displayed
731
+ * `usedPercent` (`devices/harness-inventory.ts`) MUST select from this SAME set,
732
+ * or a maxed-then-reset account reads `available` yet shows a stale 99% next to
733
+ * its "ready" verdict (#3705). Callers still narrow further (dropping
734
+ * `sonnet_week`, the model sub-limit); this is only the reset-rollover cut.
735
+ */
736
+ export function liveUsageWindows(snapshot, now = Date.now()) {
737
+ return snapshot.windows.filter((window) => !(window.resetsAt && window.resetsAt.getTime() <= now));
738
+ }
726
739
  /**
727
740
  * Derive an account's real throttle state from its live usage windows — the
728
741
  * single signal both the `agents view` badge and run-rotation eligibility share
@@ -742,7 +755,7 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
742
755
  * The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
743
756
  * one model, not the account, so it shouldn't flip the whole row to throttled.
744
757
  */
745
- export function deriveUsageStatusFromSnapshot(snapshot) {
758
+ export function deriveUsageStatusFromSnapshot(snapshot, now = Date.now()) {
746
759
  if (!snapshot)
747
760
  return null;
748
761
  if (snapshot.unavailable) {
@@ -750,14 +763,17 @@ export function deriveUsageStatusFromSnapshot(snapshot) {
750
763
  // clears it. session_limit blocks only until its reset time.
751
764
  if (snapshot.unavailable.reason === 'out_of_credits')
752
765
  return 'rate_limited';
753
- if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() > Date.now()) {
766
+ if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() > now) {
754
767
  return 'rate_limited';
755
768
  }
756
769
  }
757
770
  if (snapshot.windows.length === 0)
758
771
  return null;
759
- const blocking = snapshot.windows.filter((window) => window.key !== 'sonnet_week');
760
- const windows = blocking.length > 0 ? blocking : snapshot.windows;
772
+ const live = liveUsageWindows(snapshot, now);
773
+ if (live.length === 0)
774
+ return 'available';
775
+ const blocking = live.filter((window) => window.key !== 'sonnet_week');
776
+ const windows = blocking.length > 0 ? blocking : live;
761
777
  const maxUsed = Math.max(...windows.map((window) => window.usedPercent));
762
778
  return maxUsed >= 100 ? 'rate_limited' : 'available';
763
779
  }
@@ -1897,7 +1913,9 @@ function parseCapturedAtMs(capturedAt) {
1897
1913
  * a fresher row another peer (or, on a headed receiver, the local status-line)
1898
1914
  * already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
1899
1915
  * it never displaces an existing timestamped row. Returns the count updated.
1900
- * Locked + atomic like every other cache writer.
1916
+ * Locked + atomic like every other cache writer, but through the ASYNC lock: it
1917
+ * runs on the daemon's usage-sync tick once per peer reply (PHNX-4116), where
1918
+ * the sync lock's `sleepSync` would freeze every other service under contention.
1901
1919
  *
1902
1920
  * Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
1903
1921
  * is a SENDER-side optimization (don't waste a push on a headed peer that reads
@@ -1907,14 +1925,14 @@ function parseCapturedAtMs(capturedAt) {
1907
1925
  * runs now shows a usage bar), so gating here on the receiver's own — laggier —
1908
1926
  * view of its role would only reject legitimate data.
1909
1927
  */
1910
- export function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
1928
+ export async function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
1911
1929
  const incoming = Object.entries(rows).filter(([, row]) => row && Array.isArray(row.windows) && row.windows.length > 0);
1912
1930
  if (incoming.length === 0)
1913
1931
  return 0;
1914
1932
  let merged = 0;
1915
1933
  try {
1916
1934
  ensureLockTarget(cachePath, '{}');
1917
- withFileLock(cachePath, () => {
1935
+ await withFileLockAsync(cachePath, () => {
1918
1936
  const cache = readClaudeUsageCacheFile(cachePath);
1919
1937
  for (const [key, row] of incoming) {
1920
1938
  const prior = cache[key];
@@ -2,6 +2,14 @@ import type { AgentId, DeviceAccountSlot, Meta } from '../types.js';
2
2
  export type { DeviceAccountSlot };
3
3
  export declare function slotDir(harness: AgentId, accountId: string): string;
4
4
  export declare function readSlots(meta: Pick<Meta, 'deviceAccounts'>): Record<string, DeviceAccountSlot>;
5
+ /**
6
+ * Remove slot records from THIS box's device doc (`deviceAccounts.slots`).
7
+ * Symmetric with {@link recordSlot}. The slot DIRECTORY is not touched — a
8
+ * caller that also wants the on-disk home gone removes it separately; a stale
9
+ * worker slot keeps its dir because `.claude/projects` holds transcripts
10
+ * (PHNX-4116). A no-op for an empty list or an id with no record.
11
+ */
12
+ export declare function dropSlots(accountIds: readonly string[]): void;
5
13
  export declare function recordSlot(accountId: string, slot: DeviceAccountSlot): void;
6
14
  export interface SlotProjection {
7
15
  accountId: string;
@@ -35,6 +35,23 @@ export function slotDir(harness, accountId) {
35
35
  export function readSlots(meta) {
36
36
  return { ...(meta.deviceAccounts?.slots ?? {}) };
37
37
  }
38
+ /**
39
+ * Remove slot records from THIS box's device doc (`deviceAccounts.slots`).
40
+ * Symmetric with {@link recordSlot}. The slot DIRECTORY is not touched — a
41
+ * caller that also wants the on-disk home gone removes it separately; a stale
42
+ * worker slot keeps its dir because `.claude/projects` holds transcripts
43
+ * (PHNX-4116). A no-op for an empty list or an id with no record.
44
+ */
45
+ export function dropSlots(accountIds) {
46
+ if (accountIds.length === 0)
47
+ return;
48
+ updateMeta((current) => {
49
+ const slots = { ...current.deviceAccounts?.slots };
50
+ for (const id of accountIds)
51
+ delete slots[id];
52
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, slots } };
53
+ });
54
+ }
38
55
  export function recordSlot(accountId, slot) {
39
56
  if (slot.accountId !== accountId) {
40
57
  throw new Error(`recordSlot accountId mismatch: key '${accountId}' vs slot.accountId '${slot.accountId}'.`);
@@ -32,7 +32,6 @@ export const ADD_DIR_STRATEGY = {
32
32
  grok: 'grok-sandbox',
33
33
  // No multi-root CLI surface today (single --dir / project path).
34
34
  opencode: 'none',
35
- gemini: 'none',
36
35
  openclaw: 'none',
37
36
  copilot: 'none',
38
37
  amp: 'none',
@@ -11,7 +11,6 @@
11
11
  export const AGENT_CLI_COMMANDS = [
12
12
  'claude',
13
13
  'codex',
14
- 'gemini',
15
14
  'cursor-agent',
16
15
  'opencode',
17
16
  'openclaw',
@@ -89,9 +89,9 @@ export declare const ALL_AGENT_IDS: AgentId[];
89
89
  * import (runner.ts already imports from routines.ts).
90
90
  *
91
91
  * This is a curated subset of AGENT_COMMANDS, not "every harness that can run
92
- * headlessly". Expanding it is a product change (gemini is hard-deprecated and
93
- * is deliberately absent). Argv itself is baked from AGENT_COMMANDS in
94
- * daemon/runner.ts bakeRoutineArgv — do not reintroduce a second token table.
92
+ * headlessly". Expanding it is a product change. Argv itself is baked from
93
+ * AGENT_COMMANDS in daemon/runner.ts bakeRoutineArgv — do not reintroduce a
94
+ * second token table.
95
95
  */
96
96
  export declare const ROUTINE_AGENT_IDS: readonly string[];
97
97
  /** Agents that can receive managed installs, imports, and resource sync writes. */
@@ -222,7 +222,7 @@ export declare function formatClaudeOrgLabel(orgType: string | null | undefined)
222
222
  */
223
223
  export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
224
224
  /** Agents whose local credential formats expose enough state for account selection. */
225
- export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "cursor", "grok", "antigravity", "kimi", "droid", "opencode", "muse"];
225
+ export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "cursor", "grok", "antigravity", "kimi", "droid", "opencode", "muse"];
226
226
  /** Whether agents-cli can determine this agent's per-version sign-in state. */
227
227
  export declare function supportsAccountInspection(agentId: AgentId): boolean;
228
228
  /**
@@ -573,9 +573,8 @@ export declare function stripJsonComments(content: string): string;
573
573
  *
574
574
  * All three MCP path resolvers read `MCP_TARGETS`, so the file the writer
575
575
  * targets, the file the parser reads, and the file the staleness detector
576
- * watches cannot drift apart. An agent with no MCP target (the hard-deprecated
577
- * `gemini`) keeps the historical settings.json default so old configs still
578
- * parse.
576
+ * watches cannot drift apart. An agent with no MCP target keeps the historical
577
+ * settings.json default so old configs still parse.
579
578
  */
580
579
  export declare function getUserMcpConfigPath(agentId: AgentId): string;
581
580
  /**
@@ -268,6 +268,7 @@ export const AGENTS = {
268
268
  format: 'markdown',
269
269
  variableSyntax: '$ARGUMENTS',
270
270
  supportsHooks: true,
271
+ nativePluginSkills: true,
271
272
  // Claude Code grew a native `claude --cloud "<prompt>"` (Anthropic-managed
272
273
  // infra, claude.ai/code; requires claude.ai subscription auth). Routing
273
274
  // still goes to Rush Cloud deliberately — it keeps cloud tasks in one
@@ -305,46 +306,6 @@ export const AGENTS = {
305
306
  // ~/.codex/agents/*.toml (name, description, developer_instructions).
306
307
  capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: { since: '0.138.0' }, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'auto', 'skip'], interactiveRepl: true },
307
308
  },
308
- gemini: {
309
- id: 'gemini',
310
- name: 'Gemini',
311
- sessionDir: ['.gemini', 'tmp'],
312
- sessionFileExt: '.json',
313
- versionStdoutMatch: 'semver',
314
- unmanagedBinary: 'path',
315
- mcpRegister: 'cli',
316
- mcpAddHttp: 'transport',
317
- mcpAddStdio: 'simple',
318
- mcpConfigWrite: 'json-mcpServers',
319
- color: 'blue',
320
- cliCommand: 'gemini',
321
- npmPackage: '@google/gemini-cli',
322
- configDir: path.join(HOME, '.gemini'),
323
- commandsDir: path.join(HOME, '.gemini', 'commands'),
324
- commandsSubdir: 'commands',
325
- skillsDir: path.join(HOME, '.gemini', 'skills'),
326
- hooksDir: 'hooks',
327
- instructionsFile: 'GEMINI.md',
328
- format: 'toml',
329
- variableSyntax: '{{args}}',
330
- supportsHooks: true,
331
- nativeAgentsSkillsDir: true,
332
- // Google retired the Gemini CLI (announced at Google I/O 2026, May 19); the `gemini`
333
- // command stopped serving free/Pro/Ultra requests on June 18, 2026. Antigravity CLI
334
- // (`agy`) is the official successor. Hard deprecation keeps legacy Gemini
335
- // sessions/config parseable while blocking install/import/sync.
336
- deprecated: {
337
- by: 'Google',
338
- date: 'June 18, 2026',
339
- reason: 'The Gemini CLI was retired for free, Pro, and Ultra tiers and no longer serves requests (announced at Google I/O 2026 on May 19).',
340
- replacement: 'antigravity',
341
- url: 'https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/',
342
- hard: true,
343
- },
344
- // gemini hooks: shipped in v0.26.0 (Jan 2026); older binaries silently ignore the `hooks` key.
345
- // extensions: gemini-extension.json bundles shipped in v0.8.0; custom subagents in v0.36.0.
346
- capabilities: { hooks: { since: '0.26.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: { since: '0.8.0' }, subagents: { since: '0.36.0' }, rules: { file: 'GEMINI.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], rulesImports: true, interactiveRepl: false },
347
- },
348
309
  cursor: {
349
310
  id: 'cursor',
350
311
  name: 'Cursor',
@@ -1019,16 +980,13 @@ export const ALL_AGENT_IDS = Object.keys(AGENTS);
1019
980
  * import (runner.ts already imports from routines.ts).
1020
981
  *
1021
982
  * This is a curated subset of AGENT_COMMANDS, not "every harness that can run
1022
- * headlessly". Expanding it is a product change (gemini is hard-deprecated and
1023
- * is deliberately absent). Argv itself is baked from AGENT_COMMANDS in
1024
- * daemon/runner.ts bakeRoutineArgv — do not reintroduce a second token table.
983
+ * headlessly". Expanding it is a product change. Argv itself is baked from
984
+ * AGENT_COMMANDS in daemon/runner.ts bakeRoutineArgv — do not reintroduce a
985
+ * second token table.
1025
986
  */
1026
987
  export const ROUTINE_AGENT_IDS = Object.freeze([
1027
988
  'claude',
1028
989
  'codex',
1029
- // gemini is hard-deprecated (Antigravity replaced it) — no routine target. A
1030
- // legacy gemini routine now fails validateJob loud instead of firing a
1031
- // retired backend; the id survives only for reading old sessions/config.
1032
990
  'cursor',
1033
991
  'kimi',
1034
992
  'droid',
@@ -1338,7 +1296,6 @@ export function accountOrgBadge(info) {
1338
1296
  export const ACCOUNT_INSPECTION_AGENT_IDS = [
1339
1297
  'claude',
1340
1298
  'codex',
1341
- 'gemini',
1342
1299
  'cursor',
1343
1300
  'grok',
1344
1301
  'antigravity',
@@ -1429,7 +1386,6 @@ function resolveAccountCredentialPath(base, ...segments) {
1429
1386
  const CREDENTIAL_FILE_SEGMENTS = {
1430
1387
  claude: [['.claude', '.claude.json'], ['.claude.json']],
1431
1388
  codex: [['.codex', 'auth.json']],
1432
- gemini: [['.gemini', 'google_accounts.json']],
1433
1389
  grok: [['.grok', 'auth.json']],
1434
1390
  kimi: [['.kimi-code', 'credentials', 'kimi-code.json']],
1435
1391
  droid: [['.factory', 'auth.v2.file']],
@@ -2028,7 +1984,6 @@ export async function getAccountInfo(agentId, home) {
2028
1984
  const configFiles = {
2029
1985
  claude: path.join(base, '.claude.json'),
2030
1986
  codex: path.join(base, '.codex', 'auth.json'),
2031
- gemini: path.join(base, '.gemini', 'google_accounts.json'),
2032
1987
  // OpenCode keeps every session in ONE sqlite file rather than a directory of
2033
1988
  // per-session transcripts, so the session-file walk resolveLastActive runs
2034
1989
  // for other agents finds nothing. Its mtime fallback is the right read here:
@@ -2151,11 +2106,6 @@ export async function getAccountInfo(agentId, home) {
2151
2106
  signedIn: !!email,
2152
2107
  };
2153
2108
  }
2154
- case 'gemini': {
2155
- const data = JSON.parse(await fs.promises.readFile(path.join(base, '.gemini', 'google_accounts.json'), 'utf-8'));
2156
- const email = data.active || null;
2157
- return { ...empty, email, signedIn: !!email, lastActive };
2158
- }
2159
2109
  case 'cursor': {
2160
2110
  // Cursor CLI keeps account metadata in ~/.cursor/cli-config.json
2161
2111
  // (authInfo: { email, userId, authId }) and its OAuth tokens SEPARATELY
@@ -2902,9 +2852,8 @@ function parseMcpFromOpenCodeConfig(configPath) {
2902
2852
  *
2903
2853
  * All three MCP path resolvers read `MCP_TARGETS`, so the file the writer
2904
2854
  * targets, the file the parser reads, and the file the staleness detector
2905
- * watches cannot drift apart. An agent with no MCP target (the hard-deprecated
2906
- * `gemini`) keeps the historical settings.json default so old configs still
2907
- * parse.
2855
+ * watches cannot drift apart. An agent with no MCP target keeps the historical
2856
+ * settings.json default so old configs still parse.
2908
2857
  */
2909
2858
  export function getUserMcpConfigPath(agentId) {
2910
2859
  return getMcpConfigPathForHome(agentId, HOME);
@@ -3036,9 +2985,6 @@ const AGENT_NAME_ALIASES = {
3036
2985
  codex: 'codex',
3037
2986
  'openai-codex': 'codex',
3038
2987
  cx: 'codex',
3039
- gemini: 'gemini',
3040
- 'gemini-cli': 'gemini',
3041
- gx: 'gemini',
3042
2988
  cursor: 'cursor',
3043
2989
  'cursor-agent': 'cursor',
3044
2990
  cr: 'cursor',
@@ -19,10 +19,21 @@ import { type ProviderProbe } from './accounting/usage.js';
19
19
  * endpoint cannot prove live for a known non-revocation
20
20
  * reason (Claude setup-token usage-scope gap — RUSH-2392),
21
21
  * OR a throttled probe with no fresh previous verdict (PHNX-4051).
22
+ * - `no_evidence` — a credential is present on disk but THIS box has no evidence,
23
+ * either way, that it can authenticate: no probe was possible
24
+ * (a worker's setup-token box does not probe the usage endpoint)
25
+ * and no run outcome has been recorded here yet. Distinct from
26
+ * `unverified` (which is "signed in, no probe endpoint exists for
27
+ * this harness") — `no_evidence` is "we did not look", the exact
28
+ * state PHNX-4116 stops rendering as a verdict word. A worker
29
+ * never PUBLISHES this row (it is dropped in
30
+ * {@link probeLocalFleetAuth}); it surfaces only as an in-memory
31
+ * account-catalog verdict whose display is a FACT (token presence
32
+ * + recorded run outcomes), never the word "no_evidence".
22
33
  * - `unconfigured`— no usable credential on disk.
23
34
  * - `error` — network/other failure; verdict indeterminate (keep the last known one).
24
35
  */
25
- export type AuthVerdict = 'live' | 'revoked' | 'expired' | 'rate_limited' | 'unverified' | 'unconfigured' | 'error';
36
+ export type AuthVerdict = 'live' | 'revoked' | 'expired' | 'rate_limited' | 'unverified' | 'no_evidence' | 'unconfigured' | 'error';
26
37
  export interface AuthHealth {
27
38
  verdict: AuthVerdict;
28
39
  /** epoch ms of the probe. */
@@ -33,6 +44,15 @@ export interface AuthHealth {
33
44
  account?: string;
34
45
  /** Stable registered account id. Display labels are never used as identity. */
35
46
  accountId?: string;
47
+ /**
48
+ * How this row was observed (PHNX-4116). `probe` (default when absent) is a
49
+ * network/usage-endpoint probe the daemon ran; `run` is a REAL agent run
50
+ * outcome recorded on exit — a successful inference (`live`) or an auth failure
51
+ * (`revoked`/`expired`/`rate_limited`). The auth FACT `agents view` renders
52
+ * (`last used ok 12m ago` vs `last auth failure 401 …`) reads this so a token's
53
+ * usability is stated as evidence, not inferred from a stale usage file.
54
+ */
55
+ source?: 'probe' | 'run';
36
56
  }
37
57
  /** Maximum age of an auth verdict used for automatic routing decisions. */
38
58
  export declare const AUTH_PROBE_MAX_AGE_MS: number;
@@ -284,4 +304,52 @@ export declare function probeLocalFleetAuth(opts?: {
284
304
  * merges as before.
285
305
  */
286
306
  export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[], installed?: ReadonlySet<string>): void;
307
+ /**
308
+ * The installed-version label a run outcome falls back to when the account has no
309
+ * slot on this box: the explicit `version`, else the label derived from `home` (a
310
+ * version home is `<versionDir>/home`, so the label is its parent dir's basename).
311
+ * Null when neither resolves. Pure (no fs) so it is unit-tested directly.
312
+ */
313
+ export declare function runOutcomeVersionKey(opts: {
314
+ version?: string | null;
315
+ home?: string | null;
316
+ }): string | null;
317
+ /** A real agent-run outcome — the evidence behind `last used ok` / `last auth failure`. */
318
+ export type RunAuthOutcome = {
319
+ ok: true;
320
+ } | {
321
+ ok: false;
322
+ verdict: 'revoked' | 'expired' | 'rate_limited' | 'error';
323
+ detail?: string;
324
+ resetsAt?: number | null;
325
+ };
326
+ /**
327
+ * Record a REAL run's auth outcome into the auth-health cache with
328
+ * `source: 'run'`. Best-effort and never throws: an unattributable run (no
329
+ * accountId/version/home) writes nothing, and the underlying write already
330
+ * swallows IO errors. Keyed exactly as the catalog reads, so the recorded fact
331
+ * surfaces on the same account row.
332
+ */
333
+ export declare function recordRunAuthOutcome(opts: {
334
+ agent: AgentId | string;
335
+ accountId?: string | null;
336
+ version?: string | null;
337
+ home?: string | null;
338
+ account?: string;
339
+ outcome: RunAuthOutcome;
340
+ host?: string;
341
+ now?: number;
342
+ }): void;
343
+ /** `Sep 20 14:02` — the fact timestamp for an auth failure. */
344
+ export declare function factTimestamp(ms: number): string;
345
+ /**
346
+ * The auth FACT `agents view` / `agents accounts` render per account per box
347
+ * (PHNX-4116): what actually happened here, with its time — never a word that
348
+ * means "we did not look". A `live` row (a recorded run OR a real probe) reads
349
+ * `last used ok <age>`; a server rejection reads `last auth failure <detail>
350
+ * <time>`; a throttle reads `rate-limited <detail> (<time>)`; anything with no
351
+ * usable evidence — including `no_evidence`/`unverified` — reads `not used on
352
+ * this box yet`.
353
+ */
354
+ export declare function formatAuthFact(health: AuthHealth | null | undefined, now?: number): string;
287
355
  export {};
@@ -27,6 +27,7 @@ import { getVersionHomePath, listInstalledVersions } from './installations/versi
27
27
  import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
28
28
  import { selfConfiguredDeviceRole } from './device-config.js';
29
29
  import { mayIssueUsageEndpointProbe, trySpendUsageApiCall } from './usage-refresh.js';
30
+ import { machineId } from './machine-id.js';
30
31
  /** Maximum age of an auth verdict used for automatic routing decisions. */
31
32
  export const AUTH_PROBE_MAX_AGE_MS = 20 * 60_000;
32
33
  /** Agents with a live network probe wired up today. The rest are best-effort. */
@@ -82,6 +83,7 @@ const VERDICT_GLYPHS = {
82
83
  expired: '○', // ○
83
84
  rate_limited: '◐', // ◐
84
85
  unverified: '◐', // ◐
86
+ no_evidence: '◌', // ◌ — credential present, no evidence either way
85
87
  unconfigured: '·', // ·
86
88
  error: '·', // ·
87
89
  };
@@ -97,6 +99,7 @@ export function verdictLabel(verdict) {
97
99
  case 'expired': return 'expired';
98
100
  case 'rate_limited': return 'limited';
99
101
  case 'unverified': return 'unverified';
102
+ case 'no_evidence': return 'no evidence';
100
103
  case 'unconfigured': return '—';
101
104
  case 'error': return '?';
102
105
  }
@@ -109,7 +112,7 @@ export function summarizeVerdicts(verdicts) {
109
112
  for (const v of verdicts) {
110
113
  if (v === 'live')
111
114
  live++;
112
- else if (v === 'unverified')
115
+ else if (v === 'unverified' || v === 'no_evidence')
113
116
  present++;
114
117
  else if (v === 'revoked')
115
118
  bad++;
@@ -132,6 +135,7 @@ export function verdictColor(verdict) {
132
135
  case 'live': return 'green';
133
136
  case 'revoked': return 'red';
134
137
  case 'unverified': return 'gray';
138
+ case 'no_evidence': return 'gray';
135
139
  case 'unconfigured': return 'dim';
136
140
  default: return 'yellow'; // expired / rate_limited / error — soft, self-healing/indeterminate
137
141
  }
@@ -188,6 +192,9 @@ export function summarizeHostAuth(cache, host) {
188
192
  case 'unverified':
189
193
  present++;
190
194
  break; // signed in, no probe — benign
195
+ case 'no_evidence':
196
+ present++;
197
+ break; // credential present, no evidence — benign
191
198
  case 'revoked':
192
199
  revoked++;
193
200
  break; // server said no — re-login
@@ -437,6 +444,14 @@ function verdictFromFreshUsage(usageKey, signedIn, now) {
437
444
  const capturedAt = snapshot?.capturedAt?.getTime();
438
445
  if (!capturedAt || now - capturedAt >= FRESH_USAGE_VERDICT_MAX_AGE_MS)
439
446
  return null;
447
+ // A `sync`-sourced snapshot arrived from ANOTHER box's poller over the fleet
448
+ // store — it proves the shared setup-token works SOMEWHERE, not that THIS box
449
+ // can authenticate (PHNX-4116). Deriving `live` from it is exactly the false
450
+ // "we did not look here, but a synced file is fresh, so call it live" the
451
+ // ticket removes. Only a reading THIS box captured itself (statusline/poll)
452
+ // is admissible live evidence.
453
+ if (snapshot?.freshness?.source === 'sync')
454
+ return null;
440
455
  const ageMin = Math.max(1, Math.round((now - capturedAt) / 60_000));
441
456
  return { verdict: 'live', checkedAt: now, detail: `token proven live by a usage fetch ${ageMin}m ago` };
442
457
  }
@@ -461,15 +476,21 @@ export async function probeAuthHealth(agent, home, opts) {
461
476
  role: selfConfiguredDeviceRole(),
462
477
  forceLive: opts?.forceLive,
463
478
  })) {
479
+ // A worker's setup-token cannot read the usage endpoint (RUSH-2392), so this
480
+ // box has no probe evidence for the account. This is NOT `unverified` (which
481
+ // conflated it with codex/grok's permanent no-probe-endpoint state): it is
482
+ // "we did not look here". A worker never publishes it — probeLocalFleetAuth
483
+ // drops `no_evidence` like `unconfigured` — so the account's facts come from
484
+ // token presence + recorded run outcomes instead (PHNX-4116).
464
485
  return {
465
- verdict: 'unverified',
486
+ verdict: 'no_evidence',
466
487
  checkedAt,
467
488
  detail: 'setup-token box does not probe the usage endpoint',
468
489
  };
469
490
  }
470
491
  if (opts?.forceLive !== true && usageScope && !trySpendUsageApiCall(usageScope, agent, checkedAt)) {
471
492
  return {
472
- verdict: 'unverified',
493
+ verdict: 'no_evidence',
473
494
  checkedAt,
474
495
  detail: 'usage-endpoint budget already spent this hour',
475
496
  };
@@ -573,7 +594,12 @@ export async function probeLocalFleetAuth(opts) {
573
594
  const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive, signal: opts?.signal });
574
595
  health.account = authAccountLabel(rep.info);
575
596
  health.accountId = rep.accountId;
576
- if (health.verdict === 'unconfigured') {
597
+ health.source = 'probe';
598
+ // `unconfigured` (no credential) and `no_evidence` (a worker that cannot probe)
599
+ // both write NO row: the first has nothing to say, the second says only "we
600
+ // did not look", which is not a fleet-publishable verdict — the account's
601
+ // facts come from token presence + recorded run outcomes instead (PHNX-4116).
602
+ if (health.verdict === 'unconfigured' || health.verdict === 'no_evidence') {
577
603
  perGroup.push([]);
578
604
  }
579
605
  else {
@@ -618,3 +644,106 @@ export function writeFleetAuthRows(host, rows, installed) {
618
644
  : undefined;
619
645
  writeAuthHealthEntries(entries, drop);
620
646
  }
647
+ // ---------------------------------------------------------------------------
648
+ // Run-outcome recording + auth-fact rendering (PHNX-4116)
649
+ //
650
+ // A worker never probes, so the honest evidence that its token authenticates is
651
+ // a REAL run: a successful inference (`live`) or an auth failure. These rows are
652
+ // written with `source: 'run'` and read back as a FACT — `last used ok 12m ago`
653
+ // / `last auth failure 401 Sep 20 14:02` / `not used on this box yet` — instead
654
+ // of the misleading verdict word the ticket removes.
655
+ // ---------------------------------------------------------------------------
656
+ /**
657
+ * The installed-version label a run outcome falls back to when the account has no
658
+ * slot on this box: the explicit `version`, else the label derived from `home` (a
659
+ * version home is `<versionDir>/home`, so the label is its parent dir's basename).
660
+ * Null when neither resolves. Pure (no fs) so it is unit-tested directly.
661
+ */
662
+ export function runOutcomeVersionKey(opts) {
663
+ if (opts.version)
664
+ return opts.version;
665
+ if (opts.home) {
666
+ const label = path.basename(path.dirname(opts.home));
667
+ if (label && label !== '.' && label !== path.sep)
668
+ return label;
669
+ }
670
+ return null;
671
+ }
672
+ /**
673
+ * Record a REAL run's auth outcome into the auth-health cache with
674
+ * `source: 'run'`. Best-effort and never throws: an unattributable run (no
675
+ * accountId/version/home) writes nothing, and the underlying write already
676
+ * swallows IO errors. Keyed exactly as the catalog reads, so the recorded fact
677
+ * surfaces on the same account row.
678
+ */
679
+ export function recordRunAuthOutcome(opts) {
680
+ // Key exactly as the account catalog READS (slot key first, then the version
681
+ // label) so the fact lands on the row: the slot key ONLY when a slot dir
682
+ // actually exists for this account on this box (workers), else the installed
683
+ // version label (headed native logins have no slot). A last-resort slot key
684
+ // keeps an attributable-by-id run recorded even with no version hint.
685
+ const slotKey = opts.accountId && readSlots(readMeta())[opts.accountId]
686
+ ? slotAuthVersionKey(opts.accountId)
687
+ : null;
688
+ const versionKey = slotKey
689
+ ?? runOutcomeVersionKey(opts)
690
+ ?? (opts.accountId ? slotAuthVersionKey(opts.accountId) : null);
691
+ if (!versionKey)
692
+ return;
693
+ const checkedAt = opts.now ?? Date.now();
694
+ const host = opts.host ?? machineId();
695
+ let detail;
696
+ if (opts.outcome.ok) {
697
+ detail = 'run ok';
698
+ }
699
+ else if (opts.outcome.verdict === 'rate_limited' && opts.outcome.resetsAt) {
700
+ detail = `until ${factClock(opts.outcome.resetsAt)}`;
701
+ }
702
+ else {
703
+ detail = opts.outcome.detail;
704
+ }
705
+ const health = {
706
+ verdict: opts.outcome.ok ? 'live' : opts.outcome.verdict,
707
+ checkedAt,
708
+ source: 'run',
709
+ ...(detail ? { detail } : {}),
710
+ ...(opts.account ? { account: opts.account } : {}),
711
+ ...(opts.accountId ? { accountId: opts.accountId } : {}),
712
+ };
713
+ writeAuthHealthEntries({ [authCacheKey(host, opts.agent, versionKey)]: health });
714
+ }
715
+ const FACT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
716
+ /** `HH:MM` local clock — used for a throttle reset time. */
717
+ function factClock(ms) {
718
+ const d = new Date(ms);
719
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
720
+ }
721
+ /** `Sep 20 14:02` — the fact timestamp for an auth failure. */
722
+ export function factTimestamp(ms) {
723
+ const d = new Date(ms);
724
+ return `${FACT_MONTHS[d.getMonth()]} ${d.getDate()} ${factClock(ms)}`;
725
+ }
726
+ /**
727
+ * The auth FACT `agents view` / `agents accounts` render per account per box
728
+ * (PHNX-4116): what actually happened here, with its time — never a word that
729
+ * means "we did not look". A `live` row (a recorded run OR a real probe) reads
730
+ * `last used ok <age>`; a server rejection reads `last auth failure <detail>
731
+ * <time>`; a throttle reads `rate-limited <detail> (<time>)`; anything with no
732
+ * usable evidence — including `no_evidence`/`unverified` — reads `not used on
733
+ * this box yet`.
734
+ */
735
+ export function formatAuthFact(health, now = Date.now()) {
736
+ if (!health)
737
+ return 'not used on this box yet';
738
+ switch (health.verdict) {
739
+ case 'live':
740
+ return `last used ok ${formatCheckedAge(health.checkedAt, now)}`;
741
+ case 'revoked':
742
+ case 'expired':
743
+ return `last auth failure${health.detail ? ` ${health.detail}` : ''} ${factTimestamp(health.checkedAt)}`;
744
+ case 'rate_limited':
745
+ return `rate-limited${health.detail ? ` ${health.detail}` : ''} (${factTimestamp(health.checkedAt)})`;
746
+ default:
747
+ return 'not used on this box yet';
748
+ }
749
+ }