@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
@@ -1,29 +1,59 @@
1
1
  /**
2
- * Fleet distribution of identity-keyed Claude usage through the user repo.
2
+ * Fleet distribution of identity-keyed Claude usage over SSH (PHNX-4116).
3
3
  *
4
- * A headed device can read authoritative usage; a worker's setup-token cannot.
5
- * Headed daemons therefore publish one snapshot into their conflict-free
6
- * `~/.agents/devices/<device>/daemon-state.json`. The daemon's bounded Git
7
- * transport delivers those files automatically; workers merge newest-wins.
8
- * There is deliberately no device-to-device SSH in this module or its tick.
4
+ * A headed device can read authoritative usage; a worker's setup-token cannot
5
+ * (credential-management.md invariant 5: a worker never polls
6
+ * `/api/oauth/usage`). So the headed daemon's `usage-sync` tick dials every
7
+ * dialable peer in parallel with `agents __usage-ingest --reply`, sending this
8
+ * box's own daemon-state envelope on stdin; the peer merges the usage rows into
9
+ * its cache newest-wins and prints its own envelope back, which the headed box
10
+ * stores in `~/.agents/devices/<peer>/daemon-state.json` stamped `receivedAt`.
11
+ * Workers never initiate. A peer that times out is skipped this tick and never
12
+ * blocks another. Only usage rows, verdicts, and session digests move here —
13
+ * never a credential (invariant 7: roles never cross).
14
+ *
15
+ * This replaced a git exchange over the fleet-synced user repo: every daemon
16
+ * committed its own file each tick, so the shared store reached 1.1 GiB /
17
+ * 18,358 commits with 100% of the last 2,000 being `chore(devices): publish
18
+ * <device> daemon state`, and a worker's clone fell 10k commits behind with
19
+ * `git fetch timed out` on every tick — a valid setup-token could not be
20
+ * scheduled because a repo was bloated. Git now carries human-authored
21
+ * resources only; `devices/<device>/daemon-state.json` is untracked.
9
22
  */
10
23
  import { type ConfiguredDeviceRole } from '../device-config.js';
24
+ import { type FleetSharedDeviceState } from '../fleet-shared-state.js';
25
+ import type { DeviceProfile } from '../devices/registry.js';
26
+ import type { SshExecResult } from '../ssh-exec.js';
11
27
  import { type CachedUsageSnapshot } from './usage.js';
12
- /** Cadence of the usage-sync git tick — the trust window for a `sync` snapshot. */
28
+ /** Cadence of the usage-sync tick — the trust window for a `sync` snapshot. */
13
29
  export declare const USAGE_SYNC_INTERVAL_MS: number;
14
30
  /** Re-publish an unchanged windowed row at least this often so workers' 15-min trust does not lapse. */
15
31
  export declare const USAGE_PUBLISH_HEARTBEAT_MS: number;
32
+ /** Per-peer SSH budget for one exchange; a peer past it is skipped this tick. */
33
+ export declare const USAGE_EXCHANGE_PEER_TIMEOUT_MS = 20000;
34
+ /** Marks the start of the JSON reply on the peer's stdout, so login-shell noise before it is ignored. */
35
+ export declare const FLEET_STATE_REPLY_MARKER = "@@AGENTS_FLEET_STATE@@";
16
36
  /**
17
37
  * Keep the previously published `capturedAt` when meters have not moved and
18
38
  * the last publish is still inside the heartbeat. Stops statusline re-renders
19
- * from dirtying the shared store (and taking the git lock) every few seconds.
39
+ * from rewriting the own-state file every few seconds.
20
40
  */
21
41
  export declare function mergeUsageRowsForPublish(previous: Record<string, CachedUsageSnapshot> | undefined, next: Record<string, CachedUsageSnapshot>, nowMs?: number): Record<string, CachedUsageSnapshot>;
22
- /** Legacy hidden ingest/export envelope kept for older fleet CLI compatibility. */
42
+ /** Legacy `__usage-ingest` envelope (v1): bare rows from an older headed peer. */
23
43
  export interface UsageSyncPayload {
24
44
  v: 1;
25
45
  rows: Record<string, CachedUsageSnapshot>;
26
46
  }
47
+ /**
48
+ * The exchange envelope (v2), identical in both directions: a device's own
49
+ * daemon-state envelope. `errors` carries the replying peer's non-fatal
50
+ * publisher failures so the headed box can log them against that peer.
51
+ */
52
+ export interface FleetStateExchangePayload {
53
+ v: 2;
54
+ state: FleetSharedDeviceState;
55
+ errors?: string[];
56
+ }
27
57
  interface PublishUsageSnapshotOptions {
28
58
  userAgentsDir?: string;
29
59
  cachePath?: string;
@@ -37,39 +67,112 @@ interface PublishUsageSnapshotResult {
37
67
  error: string | null;
38
68
  path: string | null;
39
69
  }
40
- /** Publish this headed device's stable usage snapshot into its owned store file. */
70
+ /** Publish this headed device's stable usage snapshot into its own state file. */
41
71
  export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): Promise<PublishUsageSnapshotResult>;
72
+ export interface PublishOwnFleetStateResult {
73
+ usage: PublishUsageSnapshotResult;
74
+ mirror: {
75
+ changed: boolean;
76
+ count: number;
77
+ error: string | null;
78
+ };
79
+ auth: {
80
+ status: string;
81
+ error: string | null;
82
+ };
83
+ /** Every publisher failure, as `<field>: <message>` — logged by the caller, never fatal. */
84
+ errors: string[];
85
+ }
42
86
  /**
43
- * Publish a changed snapshot immediately and exchange the shared repo so
44
- * workers see it without waiting for the 15-minute tick. No-ops when the
45
- * serialized store is unchanged (statusline re-renders with the same windows).
87
+ * Refresh every field this device owns in its own state file: the usage
88
+ * snapshot (headed only), the session digests, and the reserved-auth readiness
89
+ * verdict. Run before the envelope is sent (the headed tick) or printed back
90
+ * (`__usage-ingest --reply`) so a peer always receives current fields. Each
91
+ * publisher is independent: one failing is reported in `errors` and the others
92
+ * still land. All three writers are the async, non-blocking variants
93
+ * (`updateFleetSharedDeviceStateAsync` underneath).
46
94
  */
47
- export declare function pushUsageSnapshotNow(options?: PublishUsageSnapshotOptions & {
48
- lockPath?: string;
49
- timeoutMs?: number;
50
- }): Promise<{
51
- published: PublishUsageSnapshotResult;
52
- transport?: import('../fleet-shared-repo-sync.js').FleetSharedRepoSyncResult;
53
- }>;
54
- interface ConsumeUsageSnapshotsOptions {
95
+ export declare function publishOwnFleetState(options?: PublishUsageSnapshotOptions): Promise<PublishOwnFleetStateResult>;
96
+ interface BuildPayloadOptions {
97
+ device?: string;
55
98
  userAgentsDir?: string;
56
99
  cachePath?: string;
57
100
  role?: ConfiguredDeviceRole;
101
+ /**
102
+ * Send only the usage rows (read live from the cache, headed only) — the
103
+ * placement probe's shape: the dispatcher hands the chosen worker current
104
+ * numbers without the sessions/auth fields the tick carries.
105
+ */
106
+ usageOnly?: boolean;
107
+ errors?: string[];
108
+ }
109
+ /** This device's exchange envelope, from its own state file (or usage-only from the cache). */
110
+ export declare function buildFleetStatePayload(options?: BuildPayloadOptions): FleetStateExchangePayload;
111
+ /** Serialize the reply as the peer prints it: marker line, then one JSON line. */
112
+ export declare function formatFleetStateReply(payload: FleetStateExchangePayload): string;
113
+ /**
114
+ * Parse what a peer sent — a v2 exchange envelope, or the legacy v1 bare-rows
115
+ * envelope from an older headed peer. Throws a clear message on anything else;
116
+ * the caller decides whether that is exit code 2 (the receiver) or a per-peer
117
+ * error (the headed tick).
118
+ */
119
+ export declare function parseFleetStateExchangeInput(raw: string): FleetStateExchangePayload | UsageSyncPayload;
120
+ /** Extract the reply envelope from a peer's stdout (text before the marker is login-shell noise). */
121
+ export declare function parseFleetStateReply(stdout: string): FleetStateExchangePayload;
122
+ interface ApplyPeerStateOptions {
123
+ userAgentsDir?: string;
124
+ cachePath?: string;
58
125
  device?: string;
59
- roles?: Record<string, ConfiguredDeviceRole>;
126
+ receivedAt?: number;
60
127
  }
61
- interface ConsumeUsageSnapshotsResult {
62
- sources: string[];
128
+ export interface ApplyPeerStateResult {
129
+ path: string;
130
+ /** Usage rows merged into the local cache (newest-wins per identity). */
63
131
  merged: number;
132
+ receivedAt: number;
133
+ }
134
+ /**
135
+ * Take a peer's envelope in: store it as that peer's file (stamped `receivedAt`)
136
+ * and merge its usage rows into this box's cache newest-wins. The same step runs
137
+ * on both ends — the worker on the pushed envelope, the headed box on the reply.
138
+ * Both writes take their file lock asynchronously: this runs on the daemon tick
139
+ * once per peer, and a `sleepSync` lock there freezes every other service.
140
+ */
141
+ export declare function applyPeerFleetState(state: FleetSharedDeviceState, options?: ApplyPeerStateOptions): Promise<ApplyPeerStateResult>;
142
+ /** The remote command one exchange runs on a peer, in the peer's shell dialect. */
143
+ export declare function buildFleetStateExchangeCommand(os?: string): Promise<string>;
144
+ /** The SSH boundary: run `remoteCmd` on `peer` with `input` on stdin. Injected by tests. */
145
+ export type FleetStateDial = (peer: DeviceProfile, remoteCmd: string, input: string) => Promise<SshExecResult>;
146
+ interface ExchangeOptions {
147
+ peers?: DeviceProfile[];
148
+ dial?: FleetStateDial;
149
+ payload?: FleetStateExchangePayload;
150
+ device?: string;
151
+ userAgentsDir?: string;
152
+ cachePath?: string;
153
+ timeoutMs?: number;
154
+ }
155
+ export interface PeerExchangeOutcome {
156
+ device: string;
157
+ delivered: boolean;
158
+ /** Set when the peer's reply was stored — PR 5's per-peer freshness input. */
159
+ receivedAt: number | null;
160
+ merged: number;
161
+ /** Why this peer got nothing this tick (timeout, old CLI, mismatched name, …). */
162
+ error: string | null;
163
+ /** The peer's own publisher failures, verbatim from its reply. */
164
+ peerErrors: string[];
165
+ }
166
+ export interface ExchangeResult {
64
167
  skipped: string | null;
65
- errors: Array<{
66
- device: string;
67
- message: string;
68
- }>;
168
+ outcomes: PeerExchangeOutcome[];
69
169
  }
70
170
  /**
71
- * Read headed publishers from the local user-repo checkout and merge one
72
- * newest-wins batch into the worker cache. No network or subprocess is touched.
171
+ * The headed publisher's fan-out: dial every peer in parallel, each under its
172
+ * own timeout, and apply each reply as it arrives. Never throws for a peer — a
173
+ * timeout, an old CLI with no `--reply`, or a reply naming the wrong device is
174
+ * that peer's `error` and the others are unaffected. A non-headed device skips:
175
+ * workers never initiate (they only answer), so a worker's tick is a no-op here.
73
176
  */
74
- export declare function consumeUsageSnapshotsFromSharedStore(options?: ConsumeUsageSnapshotsOptions): ConsumeUsageSnapshotsResult;
177
+ export declare function exchangeFleetStateWithPeers(options?: ExchangeOptions): Promise<ExchangeResult>;
75
178
  export {};
@@ -1,21 +1,38 @@
1
1
  /**
2
- * Fleet distribution of identity-keyed Claude usage through the user repo.
2
+ * Fleet distribution of identity-keyed Claude usage over SSH (PHNX-4116).
3
3
  *
4
- * A headed device can read authoritative usage; a worker's setup-token cannot.
5
- * Headed daemons therefore publish one snapshot into their conflict-free
6
- * `~/.agents/devices/<device>/daemon-state.json`. The daemon's bounded Git
7
- * transport delivers those files automatically; workers merge newest-wins.
8
- * There is deliberately no device-to-device SSH in this module or its tick.
4
+ * A headed device can read authoritative usage; a worker's setup-token cannot
5
+ * (credential-management.md invariant 5: a worker never polls
6
+ * `/api/oauth/usage`). So the headed daemon's `usage-sync` tick dials every
7
+ * dialable peer in parallel with `agents __usage-ingest --reply`, sending this
8
+ * box's own daemon-state envelope on stdin; the peer merges the usage rows into
9
+ * its cache newest-wins and prints its own envelope back, which the headed box
10
+ * stores in `~/.agents/devices/<peer>/daemon-state.json` stamped `receivedAt`.
11
+ * Workers never initiate. A peer that times out is skipped this tick and never
12
+ * blocks another. Only usage rows, verdicts, and session digests move here —
13
+ * never a credential (invariant 7: roles never cross).
14
+ *
15
+ * This replaced a git exchange over the fleet-synced user repo: every daemon
16
+ * committed its own file each tick, so the shared store reached 1.1 GiB /
17
+ * 18,358 commits with 100% of the last 2,000 being `chore(devices): publish
18
+ * <device> daemon state`, and a worker's clone fell 10k commits behind with
19
+ * `git fetch timed out` on every tick — a valid setup-token could not be
20
+ * scheduled because a repo was bloated. Git now carries human-authored
21
+ * resources only; `devices/<device>/daemon-state.json` is untracked.
9
22
  */
10
- import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole } from '../device-config.js';
11
- import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
23
+ import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
24
+ import { FLEET_SHARED_STATE_VERSION, parseFleetSharedDeviceStateEnvelope, readFleetSharedDeviceStates, readOwnFleetSharedDeviceState, storePeerFleetSharedDeviceState, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
12
25
  import { getUserAgentsDir } from '../state.js';
13
26
  import { machineId, normalizeHost } from '../session/sync/config.js';
14
27
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
15
- /** Cadence of the usage-sync git tick — the trust window for a `sync` snapshot. */
28
+ /** Cadence of the usage-sync tick — the trust window for a `sync` snapshot. */
16
29
  export const USAGE_SYNC_INTERVAL_MS = 15 * 60_000;
17
30
  /** Re-publish an unchanged windowed row at least this often so workers' 15-min trust does not lapse. */
18
31
  export const USAGE_PUBLISH_HEARTBEAT_MS = 10 * 60_000;
32
+ /** Per-peer SSH budget for one exchange; a peer past it is skipped this tick. */
33
+ export const USAGE_EXCHANGE_PEER_TIMEOUT_MS = 20_000;
34
+ /** Marks the start of the JSON reply on the peer's stdout, so login-shell noise before it is ignored. */
35
+ export const FLEET_STATE_REPLY_MARKER = '@@AGENTS_FLEET_STATE@@';
19
36
  function usageMeterSignature(row) {
20
37
  return JSON.stringify({
21
38
  windows: row.windows,
@@ -28,7 +45,7 @@ function usageMeterSignature(row) {
28
45
  /**
29
46
  * Keep the previously published `capturedAt` when meters have not moved and
30
47
  * the last publish is still inside the heartbeat. Stops statusline re-renders
31
- * from dirtying the shared store (and taking the git lock) every few seconds.
48
+ * from rewriting the own-state file every few seconds.
32
49
  */
33
50
  export function mergeUsageRowsForPublish(previous, next, nowMs = Date.now()) {
34
51
  if (!previous)
@@ -47,7 +64,7 @@ export function mergeUsageRowsForPublish(previous, next, nowMs = Date.now()) {
47
64
  }
48
65
  return out;
49
66
  }
50
- /** Publish this headed device's stable usage snapshot into its owned store file. */
67
+ /** Publish this headed device's stable usage snapshot into its own state file. */
51
68
  export async function publishUsageSnapshotToSharedStore(options = {}) {
52
69
  const result = {
53
70
  published: false,
@@ -84,68 +101,212 @@ export async function publishUsageSnapshotToSharedStore(options = {}) {
84
101
  return result;
85
102
  }
86
103
  /**
87
- * Publish a changed snapshot immediately and exchange the shared repo so
88
- * workers see it without waiting for the 15-minute tick. No-ops when the
89
- * serialized store is unchanged (statusline re-renders with the same windows).
104
+ * Refresh every field this device owns in its own state file: the usage
105
+ * snapshot (headed only), the session digests, and the reserved-auth readiness
106
+ * verdict. Run before the envelope is sent (the headed tick) or printed back
107
+ * (`__usage-ingest --reply`) so a peer always receives current fields. Each
108
+ * publisher is independent: one failing is reported in `errors` and the others
109
+ * still land. All three writers are the async, non-blocking variants
110
+ * (`updateFleetSharedDeviceStateAsync` underneath).
90
111
  */
91
- export async function pushUsageSnapshotNow(options = {}) {
92
- const published = await publishUsageSnapshotToSharedStore(options);
93
- if (!published.changed)
94
- return { published };
95
- const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
96
- const transport = await syncFleetSharedStateRepo({
97
- userAgentsDir: options.userAgentsDir,
98
- device: options.device,
99
- timeoutMs: options.timeoutMs,
100
- lockPath: options.lockPath,
101
- });
102
- return { published, transport };
112
+ export async function publishOwnFleetState(options = {}) {
113
+ const errors = [];
114
+ const usage = await publishUsageSnapshotToSharedStore(options);
115
+ if (usage.error)
116
+ errors.push(`usage: ${usage.error}`);
117
+ let mirror = { changed: false, count: 0, error: null };
118
+ try {
119
+ const { publishSessionMirrorToSharedStore } = await import('../session/mirror.js');
120
+ const published = await publishSessionMirrorToSharedStore({ userAgentsDir: options.userAgentsDir, device: options.device });
121
+ mirror = { changed: published.changed, count: published.count, error: published.error };
122
+ }
123
+ catch (err) {
124
+ mirror = { changed: false, count: 0, error: err.message };
125
+ }
126
+ if (mirror.error)
127
+ errors.push(`sessions: ${mirror.error}`);
128
+ let auth = { status: 'unknown', error: null };
129
+ try {
130
+ const { publishReservedAuthVerdict } = await import('../secrets-policy.js');
131
+ const verdict = await publishReservedAuthVerdict({ userAgentsDir: options.userAgentsDir, localName: options.device });
132
+ auth = { status: verdict.status, error: verdict.error };
133
+ }
134
+ catch (err) {
135
+ auth = { status: 'unknown', error: err.message };
136
+ }
137
+ if (auth.error)
138
+ errors.push(`auth: ${auth.error}`);
139
+ return { usage, mirror, auth, errors };
140
+ }
141
+ /** This device's exchange envelope, from its own state file (or usage-only from the cache). */
142
+ export function buildFleetStatePayload(options = {}) {
143
+ const device = options.device ?? machineId();
144
+ if (options.usageOnly) {
145
+ const state = { version: FLEET_SHARED_STATE_VERSION, device };
146
+ if (isHeadedDeviceRole(options.role ?? selfConfiguredDeviceRole())) {
147
+ const rows = exportClaudeUsageCacheRows(options.cachePath);
148
+ if (Object.keys(rows).length > 0)
149
+ state.usage = { rows };
150
+ }
151
+ return { v: 2, state };
152
+ }
153
+ const state = readOwnFleetSharedDeviceState(device, options.userAgentsDir);
154
+ return { v: 2, state, ...(options.errors?.length ? { errors: options.errors } : {}) };
103
155
  }
104
- function capturedAtMs(row) {
105
- if (!row.capturedAt)
106
- return null;
107
- const ms = Date.parse(row.capturedAt);
108
- return Number.isFinite(ms) ? ms : null;
156
+ /** Serialize the reply as the peer prints it: marker line, then one JSON line. */
157
+ export function formatFleetStateReply(payload) {
158
+ return `${FLEET_STATE_REPLY_MARKER}\n${JSON.stringify(payload)}\n`;
109
159
  }
110
160
  /**
111
- * Read headed publishers from the local user-repo checkout and merge one
112
- * newest-wins batch into the worker cache. No network or subprocess is touched.
161
+ * Parse what a peer sent — a v2 exchange envelope, or the legacy v1 bare-rows
162
+ * envelope from an older headed peer. Throws a clear message on anything else;
163
+ * the caller decides whether that is exit code 2 (the receiver) or a per-peer
164
+ * error (the headed tick).
113
165
  */
114
- export function consumeUsageSnapshotsFromSharedStore(options = {}) {
115
- const result = { sources: [], merged: 0, skipped: null, errors: [] };
116
- if ((options.role ?? selfConfiguredDeviceRole()) !== 'worker') {
117
- result.skipped = 'this device is not a worker';
118
- return result;
166
+ export function parseFleetStateExchangeInput(raw) {
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(raw);
119
170
  }
120
- const read = readFleetSharedDeviceStates(options.userAgentsDir ?? getUserAgentsDir());
121
- result.errors.push(...read.errors);
122
- const self = normalizeHost(options.device ?? machineId());
123
- const roles = options.roles ?? listConfiguredDeviceRoles(read.states.map((state) => state.device));
124
- const rows = {};
125
- for (const state of read.states) {
126
- if (normalizeHost(state.device) === self || !isHeadedDeviceRole(roles[state.device]) || !state.usage)
171
+ catch {
172
+ throw new Error('malformed JSON payload');
173
+ }
174
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
175
+ throw new Error('unrecognized usage-sync payload shape');
176
+ }
177
+ const record = parsed;
178
+ if (record.v === 2) {
179
+ const state = parseFleetSharedDeviceStateEnvelope(record.state);
180
+ const errors = Array.isArray(record.errors) ? record.errors.filter((e) => typeof e === 'string') : undefined;
181
+ return { v: 2, state, ...(errors?.length ? { errors } : {}) };
182
+ }
183
+ if (record.v === 1) {
184
+ const rows = record.rows;
185
+ // `typeof [] === 'object'` — an array is NOT a rows map.
186
+ if (typeof rows !== 'object' || rows === null || Array.isArray(rows)) {
187
+ throw new Error('unrecognized usage-sync payload shape');
188
+ }
189
+ return { v: 1, rows: rows };
190
+ }
191
+ throw new Error('unrecognized usage-sync payload shape');
192
+ }
193
+ /** Extract the reply envelope from a peer's stdout (text before the marker is login-shell noise). */
194
+ export function parseFleetStateReply(stdout) {
195
+ const idx = stdout.lastIndexOf(FLEET_STATE_REPLY_MARKER);
196
+ if (idx === -1) {
197
+ throw new Error(stdout.trim()
198
+ ? 'no reply envelope on stdout (peer agents-cli predates `__usage-ingest --reply`?)'
199
+ : 'empty reply (peer agents-cli predates `__usage-ingest --reply`?)');
200
+ }
201
+ const body = stdout.slice(idx + FLEET_STATE_REPLY_MARKER.length).trim();
202
+ const payload = parseFleetStateExchangeInput(body);
203
+ if (payload.v !== 2)
204
+ throw new Error('reply is not a v2 exchange envelope');
205
+ return payload;
206
+ }
207
+ /**
208
+ * Rows as the receiver stores them: provenance `sync` (so the trust window is
209
+ * `USAGE_SYNC_TRUST_MS`, not the local-capture bar) and the poller pinned to the
210
+ * device that actually read the endpoint, defaulting to the sender.
211
+ */
212
+ function stampSyncRows(rows, sender) {
213
+ const out = {};
214
+ for (const [identity, incoming] of Object.entries(rows)) {
215
+ if (!incoming || !Array.isArray(incoming.windows) || incoming.windows.length === 0)
127
216
  continue;
128
- result.sources.push(state.device);
129
- for (const [identity, incoming] of Object.entries(state.usage.rows)) {
130
- if (!incoming || !Array.isArray(incoming.windows) || incoming.windows.length === 0)
131
- continue;
132
- const stamped = {
133
- ...incoming,
134
- freshnessSource: 'sync',
135
- pollerDevice: incoming.pollerDevice ?? state.device,
136
- };
137
- const current = rows[identity];
138
- if (!current) {
139
- rows[identity] = stamped;
140
- continue;
217
+ out[identity] = { ...incoming, freshnessSource: 'sync', pollerDevice: incoming.pollerDevice ?? sender };
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * Take a peer's envelope in: store it as that peer's file (stamped `receivedAt`)
223
+ * and merge its usage rows into this box's cache newest-wins. The same step runs
224
+ * on both ends — the worker on the pushed envelope, the headed box on the reply.
225
+ * Both writes take their file lock asynchronously: this runs on the daemon tick
226
+ * once per peer, and a `sleepSync` lock there freezes every other service.
227
+ */
228
+ export async function applyPeerFleetState(state, options = {}) {
229
+ const self = normalizeHost(options.device ?? machineId());
230
+ if (normalizeHost(state.device) === self) {
231
+ throw new Error(`peer envelope names this device (${state.device}); refusing to overwrite the own state file`);
232
+ }
233
+ const receivedAt = options.receivedAt ?? Date.now();
234
+ const write = await storePeerFleetSharedDeviceState(state, options.userAgentsDir ?? getUserAgentsDir(), receivedAt);
235
+ const merged = state.usage
236
+ ? await ingestPeerClaudeUsageRows(stampSyncRows(state.usage.rows, state.device), options.cachePath)
237
+ : 0;
238
+ return { path: write.path, merged, receivedAt };
239
+ }
240
+ /** The remote command one exchange runs on a peer, in the peer's shell dialect. */
241
+ export async function buildFleetStateExchangeCommand(os) {
242
+ const { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor } = await import('../hosts/remote-cmd.js');
243
+ // The Windows `agents.ps1` shim does not forward ssh-piped stdin, so the
244
+ // payload goes through a temp file and `--from <path>` there.
245
+ return remoteShellFor(os) === 'powershell'
246
+ ? buildWindowsStdinAgentsCommand(['__usage-ingest', '--reply'])
247
+ : buildRemoteAgentsInvocation(['__usage-ingest', '--reply'], undefined, os);
248
+ }
249
+ async function sshDial(timeoutMs) {
250
+ const { sshExecAsync } = await import('../ssh-exec.js');
251
+ const { sshTargetFor, deviceIdentityArgs } = await import('../devices/connect.js');
252
+ return (peer, remoteCmd, input) => sshExecAsync(sshTargetFor(peer), remoteCmd, {
253
+ input,
254
+ timeoutMs,
255
+ extraSshArgs: deviceIdentityArgs(peer),
256
+ });
257
+ }
258
+ async function defaultPeers(self) {
259
+ const { isDialableDevice, loadDevicesSync } = await import('../devices/registry.js');
260
+ return Object.values(loadDevicesSync())
261
+ .filter((d) => normalizeHost(d.name) !== self && isDialableDevice(d))
262
+ .sort((a, b) => a.name.localeCompare(b.name));
263
+ }
264
+ /**
265
+ * The headed publisher's fan-out: dial every peer in parallel, each under its
266
+ * own timeout, and apply each reply as it arrives. Never throws for a peer — a
267
+ * timeout, an old CLI with no `--reply`, or a reply naming the wrong device is
268
+ * that peer's `error` and the others are unaffected. A non-headed device skips:
269
+ * workers never initiate (they only answer), so a worker's tick is a no-op here.
270
+ */
271
+ export async function exchangeFleetStateWithPeers(options = {}) {
272
+ const device = options.device ?? machineId();
273
+ const self = normalizeHost(device);
274
+ const peers = options.peers ?? await defaultPeers(self);
275
+ if (peers.length === 0)
276
+ return { skipped: 'no dialable peer in the device registry', outcomes: [] };
277
+ const timeoutMs = options.timeoutMs ?? USAGE_EXCHANGE_PEER_TIMEOUT_MS;
278
+ const dial = options.dial ?? await sshDial(timeoutMs);
279
+ const payload = options.payload ?? buildFleetStatePayload({ device, userAgentsDir: options.userAgentsDir });
280
+ const input = JSON.stringify(payload);
281
+ const outcomes = await Promise.all(peers.map(async (peer) => {
282
+ const outcome = { device: peer.name, delivered: false, receivedAt: null, merged: 0, error: null, peerErrors: [] };
283
+ try {
284
+ const remoteCmd = await buildFleetStateExchangeCommand(peer.shell === 'powershell' ? 'windows' : undefined);
285
+ const res = await dial(peer, remoteCmd, input);
286
+ if (res.timedOut)
287
+ throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`);
288
+ if (res.code !== 0) {
289
+ const detail = res.stderr.trim().split('\n').pop() ?? '';
290
+ throw new Error(`exited ${res.code ?? 'without a status'}${detail ? `: ${detail}` : ''}`);
291
+ }
292
+ const reply = parseFleetStateReply(res.stdout);
293
+ if (normalizeHost(reply.state.device) !== normalizeHost(peer.name)) {
294
+ throw new Error(`reply names device '${reply.state.device}', expected '${peer.name}'`);
141
295
  }
142
- const incomingMs = capturedAtMs(stamped);
143
- const currentMs = capturedAtMs(current);
144
- if (incomingMs !== null && (currentMs === null || incomingMs > currentMs))
145
- rows[identity] = stamped;
296
+ const applied = await applyPeerFleetState(reply.state, {
297
+ device,
298
+ userAgentsDir: options.userAgentsDir,
299
+ cachePath: options.cachePath,
300
+ });
301
+ outcome.delivered = true;
302
+ outcome.receivedAt = applied.receivedAt;
303
+ outcome.merged = applied.merged;
304
+ outcome.peerErrors = reply.errors ?? [];
146
305
  }
147
- }
148
- result.sources.sort();
149
- result.merged = ingestPeerClaudeUsageRows(rows, options.cachePath);
150
- return result;
306
+ catch (err) {
307
+ outcome.error = err.message;
308
+ }
309
+ return outcome;
310
+ }));
311
+ return { skipped: null, outcomes };
151
312
  }
@@ -494,6 +494,17 @@ export interface FormatUsageSummaryOpts {
494
494
  export declare function viewUsageSummaryOptions(agentId: AgentId, signedIn: boolean, usageInfo: UsageInfo | undefined, maxWindows: number | undefined, version?: string): FormatUsageSummaryOpts;
495
495
  /** Format a one-line usage summary with compact bars for inline display. */
496
496
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number, opts?: FormatUsageSummaryOpts): string;
497
+ /**
498
+ * The snapshot's windows that are still LIVE — reset time not yet passed. A
499
+ * window past its `resetsAt` has rolled over: its `usedPercent` is a reading of
500
+ * the PREVIOUS period, not a live throttle (PHNX-4116). Both the throttle
501
+ * verdict ({@link deriveUsageStatusFromSnapshot}) and the displayed
502
+ * `usedPercent` (`devices/harness-inventory.ts`) MUST select from this SAME set,
503
+ * or a maxed-then-reset account reads `available` yet shows a stale 99% next to
504
+ * its "ready" verdict (#3705). Callers still narrow further (dropping
505
+ * `sonnet_week`, the model sub-limit); this is only the reset-rollover cut.
506
+ */
507
+ export declare function liveUsageWindows(snapshot: UsageSnapshot, now?: number): UsageWindow[];
497
508
  /**
498
509
  * Derive an account's real throttle state from its live usage windows — the
499
510
  * single signal both the `agents view` badge and run-rotation eligibility share
@@ -513,7 +524,7 @@ export declare function formatUsageSummary(plan: string | null, snapshot: UsageS
513
524
  * The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
514
525
  * one model, not the account, so it shouldn't flip the whole row to throttled.
515
526
  */
516
- export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot | null | undefined): 'available' | 'rate_limited' | null;
527
+ export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot | null | undefined, now?: number): 'available' | 'rate_limited' | null;
517
528
  /** A prior sample of one window's utilization, for burn-rate projection. */
518
529
  interface UsagePriorSample {
519
530
  /** Epoch ms the prior snapshot was captured. */
@@ -768,7 +779,9 @@ export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<s
768
779
  * a fresher row another peer (or, on a headed receiver, the local status-line)
769
780
  * already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
770
781
  * it never displaces an existing timestamped row. Returns the count updated.
771
- * Locked + atomic like every other cache writer.
782
+ * Locked + atomic like every other cache writer, but through the ASYNC lock: it
783
+ * runs on the daemon's usage-sync tick once per peer reply (PHNX-4116), where
784
+ * the sync lock's `sleepSync` would freeze every other service under contention.
772
785
  *
773
786
  * Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
774
787
  * is a SENDER-side optimization (don't waste a push on a headed peer that reads
@@ -778,7 +791,7 @@ export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<s
778
791
  * runs now shows a usage bar), so gating here on the receiver's own — laggier —
779
792
  * view of its role would only reject legitimate data.
780
793
  */
781
- export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): number;
794
+ export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): Promise<number>;
782
795
  /**
783
796
  * Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
784
797
  * spend limit`) from a real run. Unlike a rate/session limit this does NOT reset