@phnx-labs/agents-cli 1.22.116 → 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 (254) hide show
  1. package/CHANGELOG.md +247 -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.d.ts +18 -0
  9. package/dist/commands/exec.js +104 -12
  10. package/dist/commands/repo.js +13 -49
  11. package/dist/commands/route.js +5 -5
  12. package/dist/commands/routines.js +39 -12
  13. package/dist/commands/sessions-render.d.ts +1 -1
  14. package/dist/commands/sessions-render.js +0 -1
  15. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  16. package/dist/commands/sessions.test-fixture.js +0 -37
  17. package/dist/commands/setup-browser.d.ts +17 -12
  18. package/dist/commands/setup-browser.js +52 -87
  19. package/dist/commands/setup-preferences.d.ts +0 -17
  20. package/dist/commands/setup-preferences.js +18 -49
  21. package/dist/commands/setup-term.d.ts +4 -3
  22. package/dist/commands/setup-term.js +5 -4
  23. package/dist/commands/setup.js +11 -11
  24. package/dist/commands/ssh.js +10 -1
  25. package/dist/commands/sync.js +6 -44
  26. package/dist/commands/teams.js +0 -1
  27. package/dist/commands/update.d.ts +19 -0
  28. package/dist/commands/update.js +34 -6
  29. package/dist/commands/view.d.ts +12 -1
  30. package/dist/commands/view.js +47 -2
  31. package/dist/index.js +107 -26
  32. package/dist/lib/account-capabilities.js +0 -2
  33. package/dist/lib/account-catalog.d.ts +38 -0
  34. package/dist/lib/account-catalog.js +110 -26
  35. package/dist/lib/account-provider-registry.js +1 -1
  36. package/dist/lib/account-registry.d.ts +1 -1
  37. package/dist/lib/account-registry.js +36 -2
  38. package/dist/lib/accounting/rotate.js +22 -8
  39. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  40. package/dist/lib/accounting/usage-ingest.js +115 -45
  41. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  42. package/dist/lib/accounting/usage-sync.js +227 -66
  43. package/dist/lib/accounting/usage.d.ts +16 -3
  44. package/dist/lib/accounting/usage.js +26 -8
  45. package/dist/lib/accounts/slots.d.ts +8 -0
  46. package/dist/lib/accounts/slots.js +17 -0
  47. package/dist/lib/add-dir.js +0 -1
  48. package/dist/lib/agent-cli-commands.js +0 -1
  49. package/dist/lib/agent-spec/agents.d.ts +6 -7
  50. package/dist/lib/agent-spec/agents.js +6 -60
  51. package/dist/lib/auth-health.d.ts +69 -1
  52. package/dist/lib/auth-health.js +133 -4
  53. package/dist/lib/browser/context.d.ts +82 -0
  54. package/dist/lib/browser/context.js +88 -0
  55. package/dist/lib/browser/paths.d.ts +25 -0
  56. package/dist/lib/browser/paths.js +51 -0
  57. package/dist/lib/browser/record.d.ts +38 -0
  58. package/dist/lib/browser/record.js +57 -0
  59. package/dist/lib/browser/sessions-list.js +1 -2
  60. package/dist/lib/browser-client.d.ts +158 -0
  61. package/dist/lib/browser-client.js +234 -0
  62. package/dist/lib/channels/owner-sink.d.ts +1 -18
  63. package/dist/lib/channels/owner-sink.js +29 -82
  64. package/dist/lib/channels/providers/rush.d.ts +7 -7
  65. package/dist/lib/channels/providers/rush.js +127 -57
  66. package/dist/lib/claude-statusline.d.ts +1 -1
  67. package/dist/lib/claude-statusline.js +15 -3
  68. package/dist/lib/commands.js +1 -1
  69. package/dist/lib/crabbox/lease.js +1 -1
  70. package/dist/lib/crabbox/runtimes.js +1 -3
  71. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  72. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  73. package/dist/lib/daemon/auth-sync-service.js +37 -43
  74. package/dist/lib/daemon/daemon.d.ts +25 -14
  75. package/dist/lib/daemon/daemon.js +92 -154
  76. package/dist/lib/daemon/runner.d.ts +2 -2
  77. package/dist/lib/daemon/runner.js +32 -9
  78. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  79. package/dist/lib/daemon/self-update-service.js +40 -36
  80. package/dist/lib/daemon/service.d.ts +12 -8
  81. package/dist/lib/daemon/supervisor.d.ts +65 -54
  82. package/dist/lib/daemon/supervisor.js +136 -171
  83. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  84. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  85. package/dist/lib/daemon/usage-sync-service.js +46 -78
  86. package/dist/lib/daemon-health.d.ts +22 -5
  87. package/dist/lib/daemon-health.js +47 -3
  88. package/dist/lib/daemon-services.d.ts +1 -1
  89. package/dist/lib/daemon-services.js +0 -10
  90. package/dist/lib/daemon-ticks.js +4 -2
  91. package/dist/lib/devices/doctor-findings.js +8 -11
  92. package/dist/lib/devices/harness-inventory.js +14 -5
  93. package/dist/lib/exec-account-home.js +5 -0
  94. package/dist/lib/exec.js +0 -11
  95. package/dist/lib/feed/events.js +0 -1
  96. package/dist/lib/feed/tool-activity.js +5 -2
  97. package/dist/lib/fleet-shared-state.d.ts +45 -0
  98. package/dist/lib/fleet-shared-state.js +78 -9
  99. package/dist/lib/fs-atomic.js +25 -0
  100. package/dist/lib/harness-auth-capabilities.js +0 -1
  101. package/dist/lib/helper-versions.js +1 -1
  102. package/dist/lib/hooks/cache.d.ts +7 -0
  103. package/dist/lib/hooks/cache.js +27 -2
  104. package/dist/lib/hooks/install.js +7 -1
  105. package/dist/lib/hosts/ready.d.ts +38 -8
  106. package/dist/lib/hosts/ready.js +62 -14
  107. package/dist/lib/hosts/remote-cmd.js +1 -0
  108. package/dist/lib/installations/active-check.d.ts +27 -0
  109. package/dist/lib/installations/active-check.js +61 -0
  110. package/dist/lib/installations/migrate.js +6 -3
  111. package/dist/lib/installations/shims.js +9 -7
  112. package/dist/lib/installations/update.js +21 -15
  113. package/dist/lib/installations/versions.js +12 -1
  114. package/dist/lib/manifest.js +1 -1
  115. package/dist/lib/models.js +0 -2
  116. package/dist/lib/open-url.js +28 -54
  117. package/dist/lib/placement.d.ts +2 -0
  118. package/dist/lib/placement.js +5 -0
  119. package/dist/lib/profiles.js +0 -2
  120. package/dist/lib/refresh.js +1 -1
  121. package/dist/lib/routine-readiness.d.ts +35 -0
  122. package/dist/lib/routine-readiness.js +74 -16
  123. package/dist/lib/rules/compile.js +6 -0
  124. package/dist/lib/sandbox.js +0 -2
  125. package/dist/lib/secrets-policy.d.ts +89 -11
  126. package/dist/lib/secrets-policy.js +175 -28
  127. package/dist/lib/session/db.d.ts +1 -1
  128. package/dist/lib/session/db.js +65 -18
  129. package/dist/lib/session/discover.js +1 -283
  130. package/dist/lib/session/mirror.d.ts +4 -3
  131. package/dist/lib/session/mirror.js +4 -3
  132. package/dist/lib/session/parse.d.ts +0 -2
  133. package/dist/lib/session/parse.js +1 -138
  134. package/dist/lib/session/throughput.d.ts +1 -3
  135. package/dist/lib/session/throughput.js +0 -20
  136. package/dist/lib/session/types.d.ts +2 -2
  137. package/dist/lib/session/types.js +2 -2
  138. package/dist/lib/sessions-client.d.ts +60 -1
  139. package/dist/lib/sessions-client.js +227 -4
  140. package/dist/lib/setup-tool-status.js +8 -12
  141. package/dist/lib/signin-badge.d.ts +3 -2
  142. package/dist/lib/signin-badge.js +6 -3
  143. package/dist/lib/smart-launch.js +4 -1
  144. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  145. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  146. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  147. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  148. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  149. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  150. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  151. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  152. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  153. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  154. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  155. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  156. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  157. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  158. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  159. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  160. package/dist/lib/staleness/writers/sources.js +5 -0
  161. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  162. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  163. package/dist/lib/state.d.ts +6 -4
  164. package/dist/lib/state.js +7 -17
  165. package/dist/lib/teams/parsers.d.ts +1 -1
  166. package/dist/lib/teams/parsers.js +1 -136
  167. package/dist/lib/teams/placement-probe.js +50 -15
  168. package/dist/lib/teams/scheduler.d.ts +7 -0
  169. package/dist/lib/types.d.ts +10 -1
  170. package/dist/lib/types.js +1 -1
  171. package/dist/lib/view-types.d.ts +32 -0
  172. package/dist/lib/watchdog/read.js +0 -1
  173. package/dist/lib/watchdog/rotate.js +1 -2
  174. package/dist/lib/watchdog/runner.js +2 -2
  175. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  176. package/dist/lib/watchdog/watchdogTail.js +0 -31
  177. package/package.json +1 -1
  178. package/scripts/postinstall.js +8 -8
  179. package/dist/browser.d.ts +0 -2
  180. package/dist/browser.js +0 -17
  181. package/dist/commands/browser-picker.d.ts +0 -18
  182. package/dist/commands/browser-picker.js +0 -97
  183. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  184. package/dist/lib/browser/arc-discovery.js +0 -197
  185. package/dist/lib/browser/arc-dom.d.ts +0 -14
  186. package/dist/lib/browser/arc-dom.js +0 -121
  187. package/dist/lib/browser/caller-identity.d.ts +0 -40
  188. package/dist/lib/browser/caller-identity.js +0 -175
  189. package/dist/lib/browser/cdp.d.ts +0 -51
  190. package/dist/lib/browser/cdp.js +0 -254
  191. package/dist/lib/browser/chrome.d.ts +0 -141
  192. package/dist/lib/browser/chrome.js +0 -704
  193. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  194. package/dist/lib/browser/chromium-discovery.js +0 -96
  195. package/dist/lib/browser/devices.d.ts +0 -34
  196. package/dist/lib/browser/devices.js +0 -61
  197. package/dist/lib/browser/domain-skills.d.ts +0 -71
  198. package/dist/lib/browser/domain-skills.js +0 -195
  199. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  200. package/dist/lib/browser/drivers/arc.js +0 -276
  201. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  202. package/dist/lib/browser/drivers/firefox.js +0 -377
  203. package/dist/lib/browser/drivers/local.d.ts +0 -55
  204. package/dist/lib/browser/drivers/local.js +0 -266
  205. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  206. package/dist/lib/browser/drivers/ssh.js +0 -467
  207. package/dist/lib/browser/editor.d.ts +0 -3
  208. package/dist/lib/browser/editor.js +0 -50
  209. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  210. package/dist/lib/browser/ffmpeg.js +0 -184
  211. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  212. package/dist/lib/browser/firefox-discovery.js +0 -162
  213. package/dist/lib/browser/har.d.ts +0 -85
  214. package/dist/lib/browser/har.js +0 -77
  215. package/dist/lib/browser/hygiene.d.ts +0 -97
  216. package/dist/lib/browser/hygiene.js +0 -153
  217. package/dist/lib/browser/index.d.ts +0 -5
  218. package/dist/lib/browser/index.js +0 -5
  219. package/dist/lib/browser/input.d.ts +0 -7
  220. package/dist/lib/browser/input.js +0 -92
  221. package/dist/lib/browser/ipc.d.ts +0 -204
  222. package/dist/lib/browser/ipc.js +0 -1453
  223. package/dist/lib/browser/login-detection.d.ts +0 -87
  224. package/dist/lib/browser/login-detection.js +0 -274
  225. package/dist/lib/browser/profiles.d.ts +0 -422
  226. package/dist/lib/browser/profiles.js +0 -1157
  227. package/dist/lib/browser/refs.d.ts +0 -89
  228. package/dist/lib/browser/refs.js +0 -191
  229. package/dist/lib/browser/registry.d.ts +0 -70
  230. package/dist/lib/browser/registry.js +0 -252
  231. package/dist/lib/browser/remote-control.d.ts +0 -59
  232. package/dist/lib/browser/remote-control.js +0 -85
  233. package/dist/lib/browser/resolve-target.d.ts +0 -66
  234. package/dist/lib/browser/resolve-target.js +0 -264
  235. package/dist/lib/browser/runtime-state.d.ts +0 -291
  236. package/dist/lib/browser/runtime-state.js +0 -584
  237. package/dist/lib/browser/secret-ref.d.ts +0 -10
  238. package/dist/lib/browser/secret-ref.js +0 -14
  239. package/dist/lib/browser/service.d.ts +0 -812
  240. package/dist/lib/browser/service.js +0 -4693
  241. package/dist/lib/browser/stream.d.ts +0 -17
  242. package/dist/lib/browser/stream.js +0 -72
  243. package/dist/lib/browser/task-index.d.ts +0 -78
  244. package/dist/lib/browser/task-index.js +0 -199
  245. package/dist/lib/browser/types.d.ts +0 -647
  246. package/dist/lib/browser/types.js +0 -61
  247. package/dist/lib/browser/upload.d.ts +0 -29
  248. package/dist/lib/browser/upload.js +0 -298
  249. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  250. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  251. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  252. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  253. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  254. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -212,9 +212,18 @@ export function hasStaleUsage(candidate, nowMs = Date.now()) {
212
212
  const capturedAt = snapshot?.capturedAt;
213
213
  if (!capturedAt || !snapshot?.windows.length)
214
214
  return false;
215
+ // A row that arrived via sync (D1, PHNX-4116) cannot be refreshed on THIS box —
216
+ // the account's poller lives on the headed device that published it, not here.
217
+ // Its age is shown, never used to refuse: a blind synced pool takes a
218
+ // floor-weight pick (capacityWeight → UNVERIFIED_WEIGHT) and
219
+ // rotationFailoverChain handles a real 429. Only a locally-captured reading a
220
+ // broken poller left hours old is genuinely "stale" for a refusal. See the
221
+ // GWT-E5d amendment.
222
+ if (snapshot.freshness?.source === 'sync')
223
+ return false;
215
224
  return nowMs - capturedAt.getTime() > USAGE_STALE_REFUSAL_MAX_AGE_MS;
216
225
  }
217
- function hasUsageAvailable(candidate) {
226
+ function hasUsageAvailable(candidate, now = Date.now()) {
218
227
  const snapshot = candidate.usageSnapshot;
219
228
  if (snapshot) {
220
229
  // Eligibility mirrors the `agents view` throttle badge exactly
@@ -225,7 +234,7 @@ function hasUsageAvailable(candidate) {
225
234
  // stayed "eligible" and the router kept launching into it while `ag view`
226
235
  // showed it rate-limited. Capacity *weighting* still ranks eligible accounts
227
236
  // by weekly headroom; this gate only decides can-it-run-right-now.
228
- const status = deriveUsageStatusFromSnapshot(snapshot);
237
+ const status = deriveUsageStatusFromSnapshot(snapshot, now);
229
238
  if (status !== null)
230
239
  return status !== 'rate_limited';
231
240
  }
@@ -279,11 +288,11 @@ export function readinessFromCandidate(candidate, now = Date.now(), model) {
279
288
  return { ready: false, reason: 'model_limited', email: candidate.email };
280
289
  }
281
290
  }
282
- if (hasUsageAvailable(candidate)) {
291
+ if (hasUsageAvailable(candidate, now)) {
283
292
  return { ready: true };
284
293
  }
285
294
  const snap = candidate.usageSnapshot;
286
- const snapRateLimited = !!snap && snap.windows.length > 0 && deriveUsageStatusFromSnapshot(snap) === 'rate_limited';
295
+ const snapRateLimited = !!snap && snap.windows.length > 0 && deriveUsageStatusFromSnapshot(snap, now) === 'rate_limited';
287
296
  const reason = !snapRateLimited && candidate.usageStatus === 'out_of_credits' ? 'out_of_credits' : 'rate_limited';
288
297
  return { ready: false, reason, email: candidate.email };
289
298
  }
@@ -573,11 +582,11 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
573
582
  export function classifyHarnessCandidates(byHarness, nowMs = Date.now()) {
574
583
  const summaries = [];
575
584
  for (const [agent, candidates] of byHarness) {
576
- const eligible = candidates.filter((c) => isRotationEligible(c));
585
+ const eligible = candidates.filter((c) => isRotationEligible(c, nowMs));
577
586
  if (eligible.length === 0) {
578
587
  const counts = new Map();
579
588
  for (const c of candidates) {
580
- const readiness = readinessFromCandidate(c);
589
+ const readiness = readinessFromCandidate(c, nowMs);
581
590
  const reason = readiness.ready ? 'ineligible' : readiness.reason;
582
591
  counts.set(reason, (counts.get(reason) ?? 0) + 1);
583
592
  }
@@ -668,7 +677,7 @@ export function formatNoHealthyAccountError(agent, strategy, excluded, nowMs = D
668
677
  const excludedStr = excluded.length === 0
669
678
  ? 'no installed versions'
670
679
  : excluded.map((c) => {
671
- const readiness = readinessFromCandidate(c);
680
+ const readiness = readinessFromCandidate(c, nowMs);
672
681
  const reason = readiness.ready ? 'ineligible' : readiness.reason;
673
682
  return `${c.version} (${reason})`;
674
683
  }).join(', ');
@@ -700,7 +709,12 @@ export function formatNoVerifiedUsageError(agent, strategy, candidates, nowMs =
700
709
  ? 'no signed-in accounts'
701
710
  : candidates.map((c) => {
702
711
  const age = snapshotAgeMinutes(c, nowMs);
703
- const staleness = age === null ? 'no usage snapshot' : `usage ${age}m old`;
712
+ // A synced row's age never drives a refusal (see hasStaleUsage), so name
713
+ // it as synced rather than letting the operator read it as the culprit.
714
+ const synced = c.usageSnapshot?.freshness?.source === 'sync';
715
+ const staleness = age === null
716
+ ? 'no usage snapshot'
717
+ : `usage ${age}m old${synced ? ', synced' : ''}`;
704
718
  return `${c.version} (${staleness})`;
705
719
  }).join(', ');
706
720
  const maxAgeMin = Math.round(USAGE_STALE_REFUSAL_MAX_AGE_MS / 60_000);
@@ -1 +1,6 @@
1
- export declare function runUsageIngest(): Promise<number>;
1
+ /** Stdin grew past {@link REMOTE_STDOUT_MAX_BYTES}; the payload was refused unread. */
2
+ export declare class UsageIngestInputTooLargeError extends Error {
3
+ readonly limitBytes: number;
4
+ constructor(limitBytes: number);
5
+ }
6
+ export declare function runUsageIngest(argv?: string[]): Promise<number>;
@@ -1,35 +1,80 @@
1
1
  /**
2
- * The `agents __usage-ingest` receiver (PHNX-3392 usage-sync).
2
+ * The `agents __usage-ingest` receiver — the worker side of the usage exchange
3
+ * (PHNX-3392 usage-sync, PHNX-4116 transport over SSH).
3
4
  *
4
- * A legacy headed peer may pipe a {@link UsageSyncPayload} JSON envelope to our
5
- * stdin; we merge its identity-keyed rows into the local usage cache newest-wins
6
- * ({@link ingestPeerClaudeUsageRows}). Hidden internal verb — intercepted in
7
- * index.ts before bootstrap, so it never triggers an update check or a detached
8
- * sync, and it writes NOTHING to stdout (the caller only reads the exit code).
5
+ * A headed peer pipes its daemon-state envelope ({@link FleetStateExchangePayload},
6
+ * v2) to our stdin. We store it as that peer's `devices/<peer>/daemon-state.json`
7
+ * (stamped `receivedAt`) and merge its usage rows into the local cache
8
+ * newest-wins ({@link applyPeerFleetState}). With `--reply` we then refresh our
9
+ * own fields (session digests, reserved-auth verdict, and usage if this box is
10
+ * headed) and print our own envelope to stdout after a marker line, so the
11
+ * headed box holds this device's state without a second round-trip. Without
12
+ * `--reply` nothing is written to stdout: the ready probe runs this verb ahead
13
+ * of `agents --version`/`agents view --json` in one shell and parses that
14
+ * stdout, so a silent ingest is what keeps the probe parseable.
9
15
  *
10
- * Exit codes: 0 = merged (or nothing to merge — an empty payload is not an error),
11
- * 2 = malformed input. It fails loud on a bad envelope rather than silently
12
- * accepting a wrong shape, but a busy cache lock degrades to best-effort inside
13
- * `ingestPeerClaudeUsageRows` like every other cache writer.
16
+ * The legacy v1 envelope (`{v:1, rows}`) from an older headed peer is still
17
+ * merged. Hidden internal verb — intercepted in index.ts before bootstrap, so
18
+ * it never triggers an update check or a detached sync.
14
19
  *
15
- * New daemon ticks use the fleet-shared store instead. This compatibility
16
- * receiver remains for older installed versions. The payload arrives on stdin,
17
- * EXCEPT on a Windows receiver: the `agents.ps1`
18
- * shim does not forward ssh-piped stdin to the node process, so the pusher writes
19
- * the payload to a temp file and passes `agents __usage-ingest --from <path>`
20
- * (the same workaround the secrets push uses — `buildWindowsStdinImportCommand`).
20
+ * Exit codes: 0 = applied (or nothing to apply — an empty payload is not an
21
+ * error), 2 = malformed or oversized input. It fails loud on a bad envelope
22
+ * rather than silently accepting a wrong shape, but a busy cache lock degrades
23
+ * to best-effort inside `ingestPeerClaudeUsageRows` like every other cache
24
+ * writer. Stdin is bounded at {@link REMOTE_STDOUT_MAX_BYTES} — the same ceiling
25
+ * the dialer puts on a peer's reply — so a runaway pusher cannot grow this
26
+ * every-15-min receiver's heap; an overflow stops the read, exits 2, and writes
27
+ * nothing.
28
+ *
29
+ * The payload arrives on stdin, EXCEPT on a Windows receiver: the `agents.ps1`
30
+ * shim does not forward ssh-piped stdin to the node process, so the pusher
31
+ * writes the payload to a temp file and passes `--from <path>`
32
+ * (`buildWindowsStdinAgentsCommand`).
21
33
  */
22
34
  import * as fs from 'fs';
35
+ import { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
23
36
  import { ingestPeerClaudeUsageRows } from './usage.js';
24
- function readStdin() {
25
- return new Promise((resolve) => {
26
- let buf = '';
27
- process.stdin.setEncoding('utf-8');
37
+ import { applyPeerFleetState, buildFleetStatePayload, formatFleetStateReply, parseFleetStateExchangeInput, publishOwnFleetState, } from './usage-sync.js';
38
+ /** Stdin grew past {@link REMOTE_STDOUT_MAX_BYTES}; the payload was refused unread. */
39
+ export class UsageIngestInputTooLargeError extends Error {
40
+ limitBytes;
41
+ constructor(limitBytes) {
42
+ super(`stdin payload exceeds ${limitBytes} bytes (${limitBytes / (1024 * 1024)} MiB); refusing it unread`);
43
+ this.limitBytes = limitBytes;
44
+ this.name = 'UsageIngestInputTooLargeError';
45
+ }
46
+ }
47
+ /**
48
+ * Read stdin to EOF, bounded at {@link REMOTE_STDOUT_MAX_BYTES}. On overflow the
49
+ * stream is destroyed so the pusher gets EPIPE instead of a full drain, and the
50
+ * promise rejects with {@link UsageIngestInputTooLargeError} — the caller exits
51
+ * 2 without parsing or writing anything.
52
+ */
53
+ function readStdin(limitBytes = REMOTE_STDOUT_MAX_BYTES) {
54
+ return new Promise((resolve, reject) => {
55
+ const acc = new RemoteUtf8Accumulator();
56
+ let bytes = 0;
57
+ let settled = false;
28
58
  process.stdin.on('data', (chunk) => {
29
- buf += chunk;
59
+ if (settled)
60
+ return;
61
+ bytes += chunk.byteLength;
62
+ if (bytes > limitBytes) {
63
+ settled = true;
64
+ process.stdin.destroy();
65
+ reject(new UsageIngestInputTooLargeError(limitBytes));
66
+ return;
67
+ }
68
+ acc.write(chunk);
30
69
  });
31
- process.stdin.on('end', () => resolve(buf));
32
- process.stdin.on('error', () => resolve(buf));
70
+ process.stdin.on('end', () => { if (!settled) {
71
+ settled = true;
72
+ resolve(acc.end());
73
+ } });
74
+ process.stdin.on('error', () => { if (!settled) {
75
+ settled = true;
76
+ resolve(acc.end());
77
+ } });
33
78
  });
34
79
  }
35
80
  /** `--from <path>` reads the payload from a file instead of stdin (Windows path). */
@@ -37,8 +82,9 @@ function fromFileArg(argv) {
37
82
  const i = argv.indexOf('--from');
38
83
  return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
39
84
  }
40
- export async function runUsageIngest() {
41
- const fromPath = fromFileArg(process.argv.slice(3));
85
+ export async function runUsageIngest(argv = process.argv.slice(3)) {
86
+ const reply = argv.includes('--reply');
87
+ const fromPath = fromFileArg(argv);
42
88
  let source;
43
89
  if (fromPath) {
44
90
  try {
@@ -50,28 +96,52 @@ export async function runUsageIngest() {
50
96
  }
51
97
  }
52
98
  else {
53
- source = await readStdin();
99
+ try {
100
+ source = await readStdin();
101
+ }
102
+ catch (err) {
103
+ if (!(err instanceof UsageIngestInputTooLargeError))
104
+ throw err;
105
+ process.stderr.write(`[agents] __usage-ingest: ${err.name}: ${err.message}\n`);
106
+ return 2;
107
+ }
54
108
  }
55
109
  const raw = source.trim();
56
- if (!raw)
57
- return 0; // nothing piped — a no-op tick, not a failure.
58
- let payload;
59
- try {
60
- payload = JSON.parse(raw);
61
- }
62
- catch {
63
- process.stderr.write('[agents] __usage-ingest: malformed JSON payload\n');
64
- return 2;
65
- }
66
- if (!payload ||
67
- payload.v !== 1 ||
68
- typeof payload.rows !== 'object' ||
69
- payload.rows === null ||
70
- Array.isArray(payload.rows) // `typeof [] === 'object'` — an array is NOT a rows map
71
- ) {
72
- process.stderr.write('[agents] __usage-ingest: unrecognized usage-sync payload shape\n');
73
- return 2;
110
+ const errors = [];
111
+ if (raw) {
112
+ let payload;
113
+ try {
114
+ payload = parseFleetStateExchangeInput(raw);
115
+ }
116
+ catch (err) {
117
+ process.stderr.write(`[agents] __usage-ingest: ${err.message}\n`);
118
+ return 2;
119
+ }
120
+ if (payload.v === 1) {
121
+ await ingestPeerClaudeUsageRows(payload.rows);
122
+ }
123
+ else {
124
+ try {
125
+ await applyPeerFleetState(payload.state);
126
+ }
127
+ catch (err) {
128
+ // A well-formed envelope we refuse (it names this device) is the
129
+ // sender's mistake, not malformed input: say so on stderr and in the
130
+ // reply, and still answer so the sender learns what this box holds.
131
+ const message = err.message;
132
+ process.stderr.write(`[agents] __usage-ingest: ${message}\n`);
133
+ errors.push(`ingest: ${message}`);
134
+ if (!reply)
135
+ return 2;
136
+ }
137
+ }
74
138
  }
75
- ingestPeerClaudeUsageRows(payload.rows);
139
+ if (!reply)
140
+ return 0;
141
+ const published = await publishOwnFleetState();
142
+ errors.push(...published.errors);
143
+ for (const message of published.errors)
144
+ process.stderr.write(`[agents] __usage-ingest: ${message}\n`);
145
+ process.stdout.write(formatFleetStateReply(buildFleetStatePayload({ errors })));
76
146
  return 0;
77
147
  }
@@ -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 {};