@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
@@ -8,7 +8,9 @@ import { readSlots } from './accounts/slots.js';
8
8
  import { harnessWorkerIsPerDevice } from './harness-auth-capabilities.js';
9
9
  import { hasKeychainTokenSync, isSecretsTransportError } from './secrets-client.js';
10
10
  import { isLaunchableSignedIn as isCredentialLaunchable } from './accounting/rotate.js';
11
- import { authCacheKey, readAuthHealthCache, slotAuthVersionKey } from './auth-health.js';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { authCacheKey, formatAuthFact, readAuthHealthCache, slotAuthVersionKey } from './auth-health.js';
12
14
  import { readFleetSharedDeviceStates } from './fleet-shared-state.js';
13
15
  import { machineId } from './machine-id.js';
14
16
  import { isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
@@ -180,15 +182,21 @@ export function buildNativeCatalog(rows, meta, globalDefault = getGlobalDefault)
180
182
  state: signedIn ? 'connected' : 'reconnect-needed',
181
183
  identityLabel: email ?? account?.name ?? group.identityKey,
182
184
  provisioning: provisioningFor(group.agent),
183
- verdict: signedIn ? 'unverified' : 'missing',
185
+ // A credential is present but nothing has probed or run it here yet — that
186
+ // is `no_evidence` ("we did not look"), not `unverified` (PHNX-4116). The
187
+ // real per-box verdict is recomputed in loadAccountCatalog; the display is
188
+ // a fact (token + run outcomes), never this word.
189
+ verdict: signedIn ? 'no_evidence' : 'missing',
184
190
  checkedAt: null,
185
191
  devices: [],
186
192
  usage: null,
187
193
  usageSnapshot: null,
188
194
  usageError: null,
195
+ token: 'no token',
196
+ lastAuth: 'not used on this box yet',
189
197
  fix: fixFor({
190
198
  agent: group.agent,
191
- verdict: signedIn ? 'unverified' : 'missing',
199
+ verdict: signedIn ? 'no_evidence' : 'missing',
192
200
  name: account?.name,
193
201
  version: home,
194
202
  provisioning: provisioningFor(group.agent),
@@ -204,6 +212,54 @@ export function buildNativeCatalog(rows, meta, globalDefault = getGlobalDefault)
204
212
  function provisioningFor(agent) {
205
213
  return harnessWorkerIsPerDevice(agent) ? 'per-device' : 'portable';
206
214
  }
215
+ const TOKEN_FACT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
216
+ /** `Sep 16` — the credential file's date, the only mutable part of the token fact. */
217
+ function tokenFileDate(mtimeMs) {
218
+ const d = new Date(mtimeMs);
219
+ return `${TOKEN_FACT_MONTHS[d.getMonth()]} ${d.getDate()}`;
220
+ }
221
+ /**
222
+ * The non-secret scheme prefix of a Claude token — `sk-ant-oat01` for a
223
+ * setup-token, `sk-ant` for another Claude scheme, `token` otherwise. NEVER the
224
+ * secret body: only a fixed, publicly-documented prefix is returned so the fact
225
+ * can be printed and fleet-synced safely (root AGENTS.md §Security).
226
+ */
227
+ function safeTokenPrefix(raw) {
228
+ const value = raw.trim();
229
+ if (value.startsWith('sk-ant-oat01'))
230
+ return 'sk-ant-oat01';
231
+ if (value.startsWith('sk-ant'))
232
+ return 'sk-ant';
233
+ return 'token';
234
+ }
235
+ /**
236
+ * The token FACT for a Claude account ON THIS BOX (PHNX-4116): the slot/home
237
+ * `.claude/.oauth_token` scheme prefix + its file date, else a present native
238
+ * credential, else `no token`. For non-Claude native rows it reports credential
239
+ * presence generically. `dir` is the slot dir when the account has one, else the
240
+ * local version home. A fact, never a verdict.
241
+ */
242
+ export function readTokenFact(agent, dir) {
243
+ if (!dir)
244
+ return 'no token';
245
+ if (agent === 'claude') {
246
+ const oauth = path.join(dir, '.claude', '.oauth_token');
247
+ try {
248
+ const stat = fs.statSync(oauth);
249
+ const prefix = safeTokenPrefix(fs.readFileSync(oauth, 'utf-8'));
250
+ return `${prefix} (${tokenFileDate(stat.mtimeMs)})`;
251
+ }
252
+ catch { /* fall through to the native-credential check */ }
253
+ try {
254
+ const creds = path.join(dir, '.claude', '.credentials.json');
255
+ const stat = fs.statSync(creds);
256
+ return `native login (${tokenFileDate(stat.mtimeMs)})`;
257
+ }
258
+ catch { /* no credential file */ }
259
+ return 'no token';
260
+ }
261
+ return credentialPresence(agent, dir).perVersion ? 'credential present' : 'no token';
262
+ }
207
263
  export function toProviderRow(account, meta) {
208
264
  const harnesses = ALL_AGENT_IDS.filter((agent) => providerAuthenticatesHarness(account.provider, account.auth, agent));
209
265
  const defaults = meta.accounts?.defaults ?? {};
@@ -286,6 +342,13 @@ export async function loadAccountCatalog() {
286
342
  const inv = localHome ? inventoryByHome.get(`${row.agent}:${localHome.label}`) : undefined;
287
343
  row.usageSnapshot = inv?.snapshot ?? null;
288
344
  row.usageError = inv?.usageError ?? null;
345
+ // The per-box FACTS (PHNX-4116): the token file on disk and the recorded run
346
+ // (or probe) outcome — never a verdict word. `cached` is the same row the
347
+ // observation used (slot key first, then the version-home label), so the
348
+ // fact and the internal verdict read the same evidence.
349
+ const credentialDir = slot?.slotDir ?? (localHome ? getVersionHomePath(row.agent, localHome.label) : null);
350
+ row.token = readTokenFact(row.agent, credentialDir);
351
+ row.lastAuth = formatAuthFact(cached ?? null);
289
352
  row.fix = fixFor({
290
353
  agent: row.agent,
291
354
  verdict: row.verdict,
@@ -326,6 +389,10 @@ function providerListEntry(row, harness) {
326
389
  checkedAt: null,
327
390
  devices: [],
328
391
  usage: null,
392
+ // A durable provider credential is a key, not a run-tracked login: state its
393
+ // presence as the token fact; there is no per-run auth evidence to render.
394
+ token: row.verdict === 'ready' ? 'api key (present)' : 'no key',
395
+ lastAuth: 'not used on this box yet',
329
396
  fix: row.fix,
330
397
  };
331
398
  }
@@ -361,6 +428,8 @@ export function accountListJson(native, providers = [], harness) {
361
428
  usage: row.usage,
362
429
  ...(row.usageSnapshot ? { usageSnapshot: row.usageSnapshot } : {}),
363
430
  ...((() => { const v = usageErrorForDisplay(row.usageError); return v ? { usageError: v } : {}; })()),
431
+ token: row.token,
432
+ lastAuth: row.lastAuth,
364
433
  fix: row.fix,
365
434
  })),
366
435
  ...providers.flatMap((row) => providerJsonEntries(row, harness)),
@@ -403,7 +472,7 @@ export function coverageNote(row, localDevice) {
403
472
  return absent.length > 0 && absent.length < row.devices.length ? `not on ${absent.join(', ')}` : null;
404
473
  }
405
474
  const provisioned = row.devices.filter((device) => device.verdict !== 'missing').length;
406
- const usable = row.devices.filter((device) => device.verdict === 'live' || device.verdict === 'rate_limited' || device.verdict === 'unverified').length;
475
+ const usable = row.devices.filter((device) => device.verdict === 'live' || device.verdict === 'rate_limited' || device.verdict === 'unverified' || device.verdict === 'no_evidence').length;
407
476
  if (usable === 0 || usable === provisioned)
408
477
  return null;
409
478
  return `usable on ${usable} of ${provisioned} boxes`;
@@ -416,10 +485,19 @@ export const OVERVIEW_MAX_USAGE_WINDOWS = 2;
416
485
  * cannot drift.
417
486
  */
418
487
  export const ACCOUNT_LISTING_LEGEND = '* stale usage · a healthy account carries no state · identity + per-box state: agents accounts list --fleet';
488
+ /**
489
+ * When a usage reading arrived from ANOTHER box's poller over the fleet store,
490
+ * name its origin — `(from zion)` — so the number reads as a synced fact, not a
491
+ * local capture (PHNX-4116). Local captures (statusline/poll) carry no suffix.
492
+ */
493
+ function usageOriginSuffix(snapshot) {
494
+ const poller = snapshot?.freshness?.source === 'sync' ? snapshot.freshness.poller : undefined;
495
+ return poller ? ` ${chalk.gray(`(from ${poller})`)}` : '';
496
+ }
419
497
  function usageText(row, maxWindows) {
420
498
  if (row.usageSnapshot) {
421
499
  const usageInfo = { snapshot: row.usageSnapshot, error: row.usageError ?? null };
422
- return formatUsageSummary(null, usageInfo.snapshot, 3, viewUsageSummaryOptions(row.agent, row.state === 'connected', usageInfo, maxWindows));
500
+ return formatUsageSummary(null, usageInfo.snapshot, 3, viewUsageSummaryOptions(row.agent, row.state === 'connected', usageInfo, maxWindows)) + usageOriginSuffix(row.usageSnapshot);
423
501
  }
424
502
  if (row.usage?.status === 'rate_limited' && (row.usage.usedPercent === null || row.usage.usedPercent === undefined)) {
425
503
  return 'limited';
@@ -432,31 +510,32 @@ function usageText(row, maxWindows) {
432
510
  return `${renderBar(row.usage.usedPercent, LISTING_USAGE_BAR_LEN)} ${percent}`;
433
511
  }
434
512
  /**
435
- * True only when the USAGE cell is certain to print a throttle marker, so the
436
- * `rate-limited` note would say the same thing twice. Mirrors `usageText`
437
- * branch for branch: with a snapshot, `formatUsageSummary` appends
438
- * 'out of credits' or 'session-limited (…)' exactly for these `unavailable`
439
- * reasons; without one, `usageText` prints 'limited' or 'no credits'. A verdict
440
- * thrown by a maxed WINDOW is deliberately NOT here: the overview caps the cell
441
- * at `OVERVIEW_MAX_USAGE_WINDOWS`, so the window that tripped it can be hidden
442
- * behind the `+N` count with no color at all, and the note is the only signal.
513
+ * Colour the auth FACT by what it states (PHNX-4116): a recent success is green,
514
+ * a server rejection red, a throttle yellow, and "not used on this box yet" the
515
+ * neutral gray of a fact that carries no alarm.
443
516
  */
444
- function usageCellNamesThrottle(row) {
445
- if (row.usageSnapshot) {
446
- const unavailable = row.usageSnapshot.unavailable;
447
- return unavailable?.reason === 'out_of_credits'
448
- || (unavailable?.reason === 'session_limit' && !!unavailable.resetsAt);
449
- }
450
- return row.usage?.status === 'out_of_credits'
451
- || (row.usage?.status === 'rate_limited' && (row.usage.usedPercent === null || row.usage.usedPercent === undefined));
517
+ export function authFactNote(lastAuth) {
518
+ if (lastAuth.startsWith('last used ok'))
519
+ return chalk.green(lastAuth);
520
+ if (lastAuth.startsWith('last auth failure'))
521
+ return chalk.red(lastAuth);
522
+ if (lastAuth.startsWith('rate-limited'))
523
+ return chalk.yellow(lastAuth);
524
+ return chalk.gray(lastAuth);
452
525
  }
453
526
  function nativeLine(row, localDevice, maxWindows) {
454
527
  return {
455
528
  // A discovered login nobody has named is still identified by what it is:
456
529
  // the row no longer carries an IDENTITY column, so the identity IS the name.
457
530
  name: row.name ?? row.identityLabel,
531
+ // FACTS, in order: the token on disk, what happened when it was last used, a
532
+ // signed-out flag when there is no credential at all, fleet coverage, then the
533
+ // repair. The auth fact already carries a throttle/failure with its time, so
534
+ // there is no separate verdict word (PHNX-4116).
458
535
  notes: [
459
- usageCellNamesThrottle(row) && row.verdict === 'rate_limited' ? null : verdictNote(row.verdict),
536
+ chalk.gray(row.token),
537
+ authFactNote(row.lastAuth),
538
+ row.verdict === 'missing' ? chalk.red('signed out') : null,
460
539
  coverageNote(row, localDevice),
461
540
  row.fix ? chalk.gray(`fix: ${row.fix}`) : null,
462
541
  ].filter((note) => !!note),
@@ -565,7 +644,7 @@ export function readSharedAccountVerdicts(userAgentsDir) {
565
644
  continue;
566
645
  if (!['native', 'durable', 'per-device'].includes(row.authMode))
567
646
  continue;
568
- if (!['live', 'expired', 'revoked', 'rate_limited', 'unverified', 'missing'].includes(row.verdict))
647
+ if (!['live', 'expired', 'revoked', 'rate_limited', 'unverified', 'no_evidence', 'missing'].includes(row.verdict))
569
648
  continue;
570
649
  const key = `${row.harness}:${row.accountId}`;
571
650
  const values = out.get(key) ?? [];
@@ -600,11 +679,14 @@ function normalizeAuthVerdict(verdict, signedIn) {
600
679
  // result — the daemon never publishes it (probeLocalFleetAuth drops those
601
680
  // rows). So it says nothing about the credential on disk; the live signedIn
602
681
  // read of the slot decides, exactly as it does when no verdict exists at all.
682
+ // No probe row, or one that says nothing about the credential: a present
683
+ // credential with no evidence either way is `no_evidence`, never a word that
684
+ // claims we looked (PHNX-4116).
603
685
  if (verdict === 'unconfigured')
604
- return signedIn ? 'unverified' : 'missing';
686
+ return signedIn ? 'no_evidence' : 'missing';
605
687
  if (verdict === 'error')
606
- return signedIn ? 'unverified' : 'missing';
607
- return verdict ?? (signedIn ? 'unverified' : 'missing');
688
+ return signedIn ? 'no_evidence' : 'missing';
689
+ return verdict ?? (signedIn ? 'no_evidence' : 'missing');
608
690
  }
609
691
  /**
610
692
  * Merge the two writers of the same local truth — the T1 slot store and the
@@ -650,6 +732,8 @@ export function aggregateAccountVerdict(provisioning, devices, localQuota) {
650
732
  return 'live';
651
733
  if (verdicts.includes('unverified'))
652
734
  return 'unverified';
735
+ if (verdicts.includes('no_evidence'))
736
+ return 'no_evidence';
653
737
  return provisioning === 'per-device' ? 'per-device' : 'missing';
654
738
  }
655
739
  function newestCheckedAt(devices) {
@@ -38,7 +38,7 @@ const ADAPTERS = new Map([
38
38
  ['deepinfra', fixed('deepinfra', ['api-key'], { codex: 'OPENAI_API_KEY' }, { codex: { OPENAI_BASE_URL: 'https://api.deepinfra.com/v1/openai' } })],
39
39
  ['openai', fixed('openai', ['api-key'], { codex: 'OPENAI_API_KEY', opencode: 'OPENAI_API_KEY' }, {}, { codex: 'OPENAI_BASE_URL', opencode: 'OPENAI_BASE_URL' })],
40
40
  ['xai', fixed('xai', ['api-key'], { grok: 'XAI_API_KEY', claude: 'ANTHROPIC_AUTH_TOKEN' })],
41
- ['google', fixed('google', ['api-key'], { gemini: 'GEMINI_API_KEY', antigravity: 'ANTIGRAVITY_API_KEY' })],
41
+ ['google', fixed('google', ['api-key'], { antigravity: 'ANTIGRAVITY_API_KEY' })],
42
42
  ['opencode', fixed('opencode', ['api-key'], { opencode: 'OPENCODE_API_KEY' })],
43
43
  ['proxy', fixed('proxy', ['api-key', 'bearer-token'], { claude: 'ANTHROPIC_AUTH_TOKEN', codex: 'OPENAI_API_KEY' }, {}, { claude: 'ANTHROPIC_BASE_URL', codex: 'OPENAI_BASE_URL' })],
44
44
  ['truefoundry', fixed('truefoundry', ['api-key', 'bearer-token'], { claude: 'ANTHROPIC_AUTH_TOKEN' }, {}, { claude: 'ANTHROPIC_BASE_URL' })],
@@ -22,7 +22,7 @@ interface ResolvedCredentialAccount {
22
22
  export interface NativeAccount extends NativeAccountRecord {
23
23
  kind: 'native';
24
24
  }
25
- export { recordSlot, readSlots } from './accounts/slots.js';
25
+ export { recordSlot, readSlots, dropSlots } from './accounts/slots.js';
26
26
  export { registeredNativeAccountForEmail, parseNativeIdentityKey } from './native-accounts.js';
27
27
  export type UnifiedAccount = (CredentialAccount & {
28
28
  kind: 'provider';
@@ -27,7 +27,9 @@ import { isAgentId } from './types.js';
27
27
  import { bundleExistsSync, deleteBundleSync, deleteKeychainTokenSync, getKeychainTokenSync, hasKeychainTokenSync, listBundlesSync, readAndResolveBundleEnvSync, readBundleSync, renameBundleSync, writeBundleWithItemsSync, } from './secrets-client.js';
28
28
  import { getAccountProvider } from './account-provider-registry.js';
29
29
  import { accountSecretItem, buildAccountBundle, parseAccountBundle, secretVarFor } from './account-schema.js';
30
- export { recordSlot, readSlots } from './accounts/slots.js';
30
+ import { readClaudeHomeConfig } from './agent-spec/agents.js';
31
+ import { getVersionHomePath, listInstalledVersions } from './installations/store.js';
32
+ export { recordSlot, readSlots, dropSlots } from './accounts/slots.js';
31
33
  export { registeredNativeAccountForEmail, parseNativeIdentityKey } from './native-accounts.js';
32
34
  const NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
33
35
  const NATIVE_LABEL = /^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$/;
@@ -663,6 +665,35 @@ export function resolveCredentialAccount(name, host, expectedProvider, base = ge
663
665
  env: { ...connectionEnv, [envVar]: secret },
664
666
  };
665
667
  }
668
+ /**
669
+ * Sync fallback: scan installed version homes to discover a native identity
670
+ * that matches the given email, for accounts that predate the registration
671
+ * system (empty `accounts.native`). Returns a synthetic SpawnAccount or null.
672
+ */
673
+ function discoverUnregisteredNativeAccount(email, agent) {
674
+ if (agent !== 'claude')
675
+ return null;
676
+ const needle = email.toLowerCase();
677
+ for (const label of listInstalledVersions(agent)) {
678
+ const home = getVersionHomePath(agent, label);
679
+ if (!fs.existsSync(home))
680
+ continue;
681
+ const config = readClaudeHomeConfig(home);
682
+ if (!config?.identity?.email)
683
+ continue;
684
+ if (config.identity.email.toLowerCase() === needle) {
685
+ return {
686
+ kind: 'native',
687
+ id: email,
688
+ name: email,
689
+ agent,
690
+ identityKey: config.identity.accountKey ?? email.toLowerCase(),
691
+ scope: 'version',
692
+ };
693
+ }
694
+ }
695
+ return null;
696
+ }
666
697
  /**
667
698
  * Resolve the account a run should launch under, following the binding order
668
699
  * (explicit → exact `agent@version` → device-scoped `agent` → per-harness
@@ -689,7 +720,10 @@ export function resolveSpawnAccount(explicit, agent, version, meta, opts = {}) {
689
720
  // Scope the lookup to the harness being launched: a bare identity selector
690
721
  // (`claude#muqsitnawaz@gmail.com`) matches every harness that identity is signed
691
722
  // into, and only this one can authenticate the spawn.
692
- const unified = findUnifiedAccount(selection.id, meta, undefined, agent);
723
+ let unified = findUnifiedAccount(selection.id, meta, undefined, agent);
724
+ if (!unified && selection.source === 'explicit' && selection.id.includes('@')) {
725
+ unified = discoverUnregisteredNativeAccount(selection.id, agent);
726
+ }
693
727
  if (!unified) {
694
728
  // A stale per-harness default is a preference, not a hard requirement: the
695
729
  // machine stays runnable by falling back to balanced rotation. Bindings and
@@ -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
  }