@phnx-labs/agents-cli 1.22.66 → 1.22.69

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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -318,6 +318,16 @@ export async function getUsageInfo(agentId, options) {
318
318
  const source = getUsageSource(agentId);
319
319
  return source ? source.fetch(options) : { snapshot: null, error: null };
320
320
  }
321
+ /**
322
+ * Combine a caller-supplied abort signal (the daemon tick deadline) with a
323
+ * per-fetch timeout, so a provider fetch is bounded by whichever fires first
324
+ * (PHNX-3608). With no caller signal it degrades to the timeout alone —
325
+ * byte-identical to the previous `AbortSignal.timeout(ms)` behaviour.
326
+ */
327
+ function usageFetchSignal(signal, timeoutMs) {
328
+ const timeout = AbortSignal.timeout(timeoutMs);
329
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
330
+ }
321
331
  /** Derive a stable lookup key from account info for usage deduplication. */
322
332
  export function getUsageLookupKey(info) {
323
333
  return info?.usageKey || info?.accountKey || null;
@@ -421,6 +431,7 @@ export async function getUsageInfoForIdentity(input, opts) {
421
431
  organizationId: input.info.organizationId,
422
432
  fileOnly: opts?.fileOnly,
423
433
  allowInteractiveLogin: opts?.allowInteractiveLogin,
434
+ signal: opts?.signal,
424
435
  });
425
436
  }
426
437
  const cached = readClaudeUsageCache(usageKey);
@@ -458,6 +469,7 @@ export async function getUsageInfoForIdentity(input, opts) {
458
469
  // Explicit refresh: block on the shared device collector.
459
470
  return fetchLiveUsageDeduped(input, usageKey, cached, opts?.fileOnly === true, {
460
471
  allowInteractiveLogin: opts?.allowInteractiveLogin === true,
472
+ signal: opts?.signal,
461
473
  });
462
474
  }
463
475
  /**
@@ -489,6 +501,7 @@ async function fetchLiveUsageDeduped(input, usageKey, cached, fileOnly, opts) {
489
501
  usageScope: usageKey,
490
502
  fileOnly,
491
503
  allowInteractiveLogin: opts?.allowInteractiveLogin === true,
504
+ signal: opts?.signal,
492
505
  });
493
506
  if (usage.snapshot) {
494
507
  if (!usage.snapshot.capturedAt || usage.snapshot.capturedAt.getTime() <= previousCapturedAt) {
@@ -931,7 +944,7 @@ async function getClaudeUsageInfo(options) {
931
944
  'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
932
945
  'User-Agent': getClaudeUserAgent(options?.cliVersion),
933
946
  },
934
- signal: AbortSignal.timeout(5000),
947
+ signal: usageFetchSignal(options?.signal, 5000),
935
948
  });
936
949
  if (!response.ok) {
937
950
  if (response.status === 429) {
@@ -1038,7 +1051,7 @@ async function getKimiUsageInfo(options) {
1038
1051
  Authorization: `Bearer ${accessToken}`,
1039
1052
  Accept: 'application/json',
1040
1053
  },
1041
- signal: AbortSignal.timeout(5000),
1054
+ signal: usageFetchSignal(options?.signal, 5000),
1042
1055
  });
1043
1056
  // 401/403 => expired token, 404 => no Kimi For Coding subscription. Either
1044
1057
  // way there are no bars to draw, and the status is what tells them apart.
@@ -1172,7 +1185,7 @@ async function getDroidUsageInfo(options) {
1172
1185
  Authorization: `Bearer ${accessToken}`,
1173
1186
  Accept: 'application/json',
1174
1187
  },
1175
- signal: AbortSignal.timeout(5000),
1188
+ signal: usageFetchSignal(options?.signal, 5000),
1176
1189
  });
1177
1190
  // 401 => revoked/expired token. No bars to draw, and the status says why.
1178
1191
  if (!response.ok) {
@@ -1204,7 +1217,7 @@ async function getDroidUsageInfo(options) {
1204
1217
  }
1205
1218
  }
1206
1219
  /** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
1207
- export async function probeClaudeStatus(home, cliVersion, usageScope) {
1220
+ export async function probeClaudeStatus(home, cliVersion, usageScope, signal) {
1208
1221
  // accessTokenCache: the daemon warms this probe every ~3 min per account, so it
1209
1222
  // reads ONLY the file-based setup-token and never the interactive login —
1210
1223
  // transmitting that ACL-bound token to the usage API from a background loop is
@@ -1240,7 +1253,7 @@ export async function probeClaudeStatus(home, cliVersion, usageScope) {
1240
1253
  'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
1241
1254
  'User-Agent': getClaudeUserAgent(cliVersion),
1242
1255
  },
1243
- signal: AbortSignal.timeout(8000),
1256
+ signal: usageFetchSignal(signal, 8000),
1244
1257
  });
1245
1258
  if (response.status === 429) {
1246
1259
  noteUsageRateLimited('claude', response.headers.get('retry-after'), { account: usageScope });
@@ -1271,7 +1284,7 @@ export async function probeClaudeStatus(home, cliVersion, usageScope) {
1271
1284
  }
1272
1285
  }
1273
1286
  /** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
1274
- export async function probeKimiStatus(home, usageScope) {
1287
+ export async function probeKimiStatus(home, usageScope, signal) {
1275
1288
  const credPath = resolveKimiCredentialPath(home);
1276
1289
  if (!credPath)
1277
1290
  return { status: null, token: 'missing' };
@@ -1301,7 +1314,7 @@ export async function probeKimiStatus(home, usageScope) {
1301
1314
  const response = await fetch(KIMI_USAGES_URL, {
1302
1315
  method: 'GET',
1303
1316
  headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
1304
- signal: AbortSignal.timeout(8000),
1317
+ signal: usageFetchSignal(signal, 8000),
1305
1318
  });
1306
1319
  if (response.status === 429) {
1307
1320
  noteUsageRateLimited('kimi', response.headers.get('retry-after'), { account: usageScope });
@@ -1313,7 +1326,7 @@ export async function probeKimiStatus(home, usageScope) {
1313
1326
  }
1314
1327
  }
1315
1328
  /** Probe Droid's WorkOS token against the billing-limits endpoint. Never refreshes (single-use rotation — see getDroidUsageInfo). */
1316
- export async function probeDroidStatus(home, usageScope) {
1329
+ export async function probeDroidStatus(home, usageScope, signal) {
1317
1330
  const cred = decryptDroidAuthPayload(home || os.homedir());
1318
1331
  const accessToken = cred?.access_token;
1319
1332
  if (typeof accessToken !== 'string' || !accessToken)
@@ -1331,7 +1344,7 @@ export async function probeDroidStatus(home, usageScope) {
1331
1344
  const response = await fetch(DROID_USAGE_URL, {
1332
1345
  method: 'GET',
1333
1346
  headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
1334
- signal: AbortSignal.timeout(8000),
1347
+ signal: usageFetchSignal(signal, 8000),
1335
1348
  });
1336
1349
  if (response.status === 429) {
1337
1350
  noteUsageRateLimited('droid', response.headers.get('retry-after'), { account: usageScope });
@@ -1887,6 +1900,17 @@ function writeClaudeUsageCacheFile(cache, cachePath) {
1887
1900
  }
1888
1901
  /** Convert a live UsageSnapshot to its JSON-serializable cached form. */
1889
1902
  function serializeClaudeUsageSnapshot(snapshot) {
1903
+ // Persist the union of fresh `windows` and last-known `staleWindows`.
1904
+ // `deserializeClaudeUsageSnapshot` re-runs the freshness gate on read and
1905
+ // re-partitions the serialized windows into fresh vs. stale, so what matters
1906
+ // is that every last-known reading reaches disk. Claude's collector returns
1907
+ // raw windows (no `staleWindows`) and relies on that read-side partition. But
1908
+ // Grok's collector pre-partitions in the fetch, moving an ended-period reading
1909
+ // onto `staleWindows` — serializing only `windows` dropped it, so the very
1910
+ // number a daemon `--refresh` just captured was gone from the next cached
1911
+ // `agents view grok`, which rendered the plan alone (no bar). Include the
1912
+ // stale windows here so the round-trip preserves them for any collector.
1913
+ const persistedWindows = [...snapshot.windows, ...(snapshot.staleWindows ?? [])];
1890
1914
  return {
1891
1915
  capturedAt: snapshot.capturedAt?.toISOString() || null,
1892
1916
  plan: snapshot.plan ?? null,
@@ -1897,7 +1921,7 @@ function serializeClaudeUsageSnapshot(snapshot) {
1897
1921
  resetsAt: snapshot.unavailable.resetsAt?.toISOString(),
1898
1922
  }
1899
1923
  : undefined,
1900
- windows: snapshot.windows.map((window) => ({
1924
+ windows: persistedWindows.map((window) => ({
1901
1925
  key: window.key,
1902
1926
  label: window.label,
1903
1927
  shortLabel: window.shortLabel,
@@ -2387,17 +2411,131 @@ function safeStatSync(filePath) {
2387
2411
  return null;
2388
2412
  }
2389
2413
  }
2414
+ /**
2415
+ * Resolve the Grok billing log to read usage from.
2416
+ *
2417
+ * `agents view grok` reads usage per INSTALLED VERSION, passing each version's
2418
+ * isolated home (`~/.agents/.history/versions/grok/<ver>`). Grok writes
2419
+ * `unified.jsonl` only to the shared real home `~/.grok/logs/unified.jsonl`
2420
+ * even though GROK_HOME isolates auth/config/models per version. A per-version
2421
+ * log, if one ever appears, still wins; otherwise we return the shared path
2422
+ * marked `shared: true` so the caller can attribute it to at most one identity.
2423
+ * Grok accounts are version-scoped (`NATIVE_ACCOUNT_CAPABILITIES.grok.scope ===
2424
+ * 'version'`) — the shared file has no owner, so it must not be stamped onto
2425
+ * every version home.
2426
+ */
2427
+ function resolveGrokBillingLogPath(home) {
2428
+ const rel = ['.grok', 'logs', 'unified.jsonl'];
2429
+ const perVersion = path.join(home || os.homedir(), ...rel);
2430
+ try {
2431
+ if (fs.existsSync(perVersion))
2432
+ return { logPath: perVersion, shared: false };
2433
+ }
2434
+ catch { /* unreadable */ }
2435
+ // `AGENTS_REAL_HOME` is the seam every version-home consumer honors: a
2436
+ // daemon/service-manager child's HOME can be baked to something other than
2437
+ // the account's real home, so os.homedir() alone is not a reliable stand-in.
2438
+ const shared = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...rel);
2439
+ if (shared !== perVersion) {
2440
+ try {
2441
+ if (fs.existsSync(shared))
2442
+ return { logPath: shared, shared: true };
2443
+ }
2444
+ catch { /* unreadable */ }
2445
+ }
2446
+ return null;
2447
+ }
2448
+ /** This home's own `.grok/auth.json` only — never the shared-home fallback. */
2449
+ function readGrokAuthIdentity(home) {
2450
+ const authPath = path.join(home, '.grok', 'auth.json');
2451
+ try {
2452
+ if (!fs.existsSync(authPath))
2453
+ return null;
2454
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
2455
+ const records = (data && typeof data === 'object' ? [data, ...Object.values(data)] : [])
2456
+ .filter((r) => !!r && typeof r === 'object' && !Array.isArray(r));
2457
+ const account = records
2458
+ .filter((r) => typeof r.refresh_token === 'string' || typeof r.email === 'string' || typeof r.user_id === 'string')
2459
+ .sort((a, b) => String(b.create_time || '').localeCompare(String(a.create_time || '')))[0];
2460
+ if (!account)
2461
+ return null;
2462
+ const userRaw = account.user_id ?? account.principal_id;
2463
+ const userId = typeof userRaw === 'string' && userRaw.trim() ? userRaw.trim() : null;
2464
+ const email = typeof account.email === 'string' && account.email.trim()
2465
+ ? account.email.trim().toLowerCase()
2466
+ : null;
2467
+ if (!userId && !email)
2468
+ return null;
2469
+ return { userId, email };
2470
+ }
2471
+ catch {
2472
+ return null;
2473
+ }
2474
+ }
2475
+ function grokIdentitiesMatch(a, b) {
2476
+ if (!a || !b)
2477
+ return false;
2478
+ if (a.userId && b.userId)
2479
+ return a.userId === b.userId;
2480
+ if (a.email && b.email)
2481
+ return a.email === b.email;
2482
+ return false;
2483
+ }
2484
+ function grokIdentityFromBillingPayload(parsed) {
2485
+ const ctx = parsed.ctx && typeof parsed.ctx === 'object' && !Array.isArray(parsed.ctx)
2486
+ ? parsed.ctx
2487
+ : null;
2488
+ const config = ctx?.config && typeof ctx.config === 'object' && !Array.isArray(ctx.config)
2489
+ ? ctx.config
2490
+ : null;
2491
+ const pick = (...cands) => {
2492
+ for (const c of cands) {
2493
+ if (typeof c === 'string' && c.trim())
2494
+ return c.trim();
2495
+ }
2496
+ return null;
2497
+ };
2498
+ const userId = pick(ctx?.user_id, ctx?.userId, ctx?.principal_id, config?.user_id, parsed.user_id);
2499
+ const emailRaw = pick(ctx?.email, config?.email, parsed.email);
2500
+ if (!userId && !emailRaw)
2501
+ return null;
2502
+ return { userId, email: emailRaw ? emailRaw.toLowerCase() : null };
2503
+ }
2504
+ function sameHomePath(a, b) {
2505
+ return (safeRealpathSync(a) ?? path.resolve(a)) === (safeRealpathSync(b) ?? path.resolve(b));
2506
+ }
2507
+ /**
2508
+ * Whether the shared `~/.grok` billing log may be attached to this home.
2509
+ * Grok logins are per version home; the shared last line is one account's
2510
+ * meter. Fail loud: never copy it onto every installed identity.
2511
+ */
2512
+ function sharedGrokLogAppliesToHome(home, match) {
2513
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
2514
+ const requestedHome = home || os.homedir();
2515
+ const thisId = readGrokAuthIdentity(requestedHome);
2516
+ if (match.identity) {
2517
+ return grokIdentitiesMatch(match.identity, thisId);
2518
+ }
2519
+ // No identity on the line (the live Grok shape). Attribute the reading to
2520
+ // exactly one canonical identity: the real home itself, or the version home
2521
+ // whose own auth.json matches the shared `~/.grok/auth.json`.
2522
+ if (sameHomePath(requestedHome, realHome))
2523
+ return true;
2524
+ return grokIdentitiesMatch(thisId, readGrokAuthIdentity(realHome));
2525
+ }
2390
2526
  /** Parse the latest billing info from Grok's unified log. */
2391
2527
  async function getGrokUsageInfo(options) {
2392
2528
  try {
2393
- const base = options?.home || os.homedir();
2394
- const logPath = path.join(base, '.grok', 'logs', 'unified.jsonl');
2529
+ const resolved = resolveGrokBillingLogPath(options?.home);
2395
2530
  // No log yet: a benign "nothing recorded here", not a failure (RUSH-3040).
2396
- if (!fs.existsSync(logPath))
2531
+ if (!resolved)
2397
2532
  return usageNoRecentUsageInfo();
2398
- const match = await readLatestGrokBilling(logPath);
2533
+ const match = await readLatestGrokBilling(resolved.logPath);
2399
2534
  if (!match)
2400
2535
  return usageNoRecentUsageInfo();
2536
+ if (resolved.shared && !sharedGrokLogAppliesToHome(options?.home, match)) {
2537
+ return usageNoRecentUsageInfo();
2538
+ }
2401
2539
  // Grok has no live usage API (`network: false`) — bars are last-seen from
2402
2540
  // this machine's unified.jsonl only. Drop windows whose billing period has
2403
2541
  // already ended so a stale 100% does not paint "rate-limited" after reset,
@@ -2690,10 +2828,16 @@ async function readLatestGrokBilling(filePath) {
2690
2828
  return;
2691
2829
  try {
2692
2830
  const parsed = JSON.parse(line);
2693
- if (parsed.msg === 'billing: fetched credits config' && parsed.ctx?.config) {
2694
- const config = parsed.ctx.config;
2831
+ const ctx = parsed.ctx && typeof parsed.ctx === 'object' && !Array.isArray(parsed.ctx)
2832
+ ? parsed.ctx
2833
+ : null;
2834
+ if (parsed.msg === 'billing: fetched credits config' && ctx?.config) {
2835
+ const config = ctx.config;
2695
2836
  const windows = [];
2696
- if (config.currentPeriod?.end && typeof config.creditUsagePercent === 'number') {
2837
+ const currentPeriod = config.currentPeriod && typeof config.currentPeriod === 'object'
2838
+ ? config.currentPeriod
2839
+ : null;
2840
+ if (currentPeriod?.end && typeof config.creditUsagePercent === 'number') {
2697
2841
  // `creditUsagePercent` is Grok's weekly credit consumption (0-100);
2698
2842
  // the billing period's `end` is when that window resets.
2699
2843
  // Do NOT coerce a missing percent to 0 — a new period often lands a
@@ -2705,14 +2849,15 @@ async function readLatestGrokBilling(filePath) {
2705
2849
  label: 'Current week',
2706
2850
  shortLabel: 'W',
2707
2851
  usedPercent: Math.max(0, Math.min(100, rawPercent)),
2708
- resetsAt: parseDateValue(config.currentPeriod.end),
2852
+ resetsAt: parseDateValue(currentPeriod.end),
2709
2853
  windowMinutes: inferWindowMinutes('week'),
2710
2854
  });
2711
2855
  }
2712
2856
  latest = {
2713
2857
  capturedAt: parseDateValue(parsed.ts),
2714
- subscriptionTier: parsed.ctx.subscriptionTier || null,
2858
+ subscriptionTier: typeof ctx.subscriptionTier === 'string' ? ctx.subscriptionTier : null,
2715
2859
  windows,
2860
+ identity: grokIdentityFromBillingPayload(parsed),
2716
2861
  };
2717
2862
  }
2718
2863
  }
@@ -2963,7 +3108,7 @@ async function getCursorUsageInfo(options) {
2963
3108
  Cookie: cookie,
2964
3109
  Accept: 'application/json',
2965
3110
  },
2966
- signal: AbortSignal.timeout(5000),
3111
+ signal: usageFetchSignal(options?.signal, 5000),
2967
3112
  });
2968
3113
  // 401/redirect => revoked/expired session. No bars to draw, and the status
2969
3114
  // says why.
@@ -163,6 +163,8 @@ export declare function probeAuthHealth(agent: AgentId, home: string | undefined
163
163
  * a genuinely live request that surfaces `revoked` immediately.
164
164
  */
165
165
  forceLive?: boolean;
166
+ /** Daemon tick deadline signal, combined with each probe fetch's own timeout (PHNX-3608). */
167
+ signal?: AbortSignal;
166
168
  }): Promise<AuthHealth>;
167
169
  /** One probed install on a host. */
168
170
  export interface AuthProbeRow {
@@ -225,6 +227,12 @@ export declare function probeLocalFleetAuth(opts?: {
225
227
  agents?: readonly AgentId[];
226
228
  /** Fire real network probes even when fresh usage evidence exists (RUSH-3036) — the `devices ping [--strict]` contract. */
227
229
  forceLive?: boolean;
230
+ /**
231
+ * Deadline signal from the daemon's supervised auth tick (PHNX-3608). Aborts
232
+ * the probe's in-flight network work when the tick deadline elapses so it
233
+ * unwinds instead of leaking a runaway await; on-demand CLI callers omit it.
234
+ */
235
+ signal?: AbortSignal;
228
236
  }): Promise<AuthProbeRow[]>;
229
237
  /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
230
238
  export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[]): void;
@@ -347,11 +347,11 @@ export async function probeAuthHealth(agent, home, opts) {
347
347
  }
348
348
  let probe;
349
349
  if (agent === 'claude')
350
- probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope);
350
+ probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope, opts?.signal);
351
351
  else if (agent === 'kimi')
352
- probe = await probeKimiStatus(home, usageScope);
352
+ probe = await probeKimiStatus(home, usageScope, opts?.signal);
353
353
  else
354
- probe = await probeDroidStatus(home, usageScope);
354
+ probe = await probeDroidStatus(home, usageScope, opts?.signal);
355
355
  return { verdict: verdictFromProbe(probe), checkedAt, detail: probeDetail(probe) };
356
356
  }
357
357
  const info = opts?.info !== undefined ? opts.info : await getAccountInfo(agent, home).catch(() => null);
@@ -426,7 +426,7 @@ export async function probeLocalFleetAuth(opts) {
426
426
  // accounts, so no same-account concurrency is left to trip the throttle.
427
427
  const perGroup = await Promise.all(groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent)).map(async (group) => {
428
428
  const rep = group.probe;
429
- const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive });
429
+ const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive, signal: opts?.signal });
430
430
  health.account = authAccountLabel(rep.info);
431
431
  if (health.verdict === 'unconfigured')
432
432
  return [];
@@ -0,0 +1,14 @@
1
+ /** True when `AGENTS_PROFILE_BOOT` is set — callers can skip building label strings. */
2
+ export declare function bootProfileEnabled(): boolean;
3
+ /**
4
+ * Record a named stage boundary. No-op unless `AGENTS_PROFILE_BOOT` is set, so
5
+ * this is free to call unconditionally on the launch path.
6
+ */
7
+ export declare function bootMark(label: string): void;
8
+ /**
9
+ * Print the collected timeline to stderr, once. Called right before the harness
10
+ * child is spawned (the end of the pre-exec window) and again as an
11
+ * `process.on('exit')` backstop for launch paths that error out before spawn.
12
+ * `reason` labels the final boundary (e.g. `spawn`, `exit`).
13
+ */
14
+ export declare function flushBootProfile(reason: string): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Boot-time profiler for the `agents run` pre-exec phase (PHNX-3585).
3
+ *
4
+ * The AGI EXT "New Claude" boot spends the whole `agents run` wrapper cost
5
+ * BEFORE the harness prints anything — version resolution, account rotation,
6
+ * config sync, login preflight. This module makes that window measurable
7
+ * without a debugger: gate it on `AGENTS_PROFILE_BOOT=1` and the run path
8
+ * stamps named marks, then flushes a per-stage timeline to stderr the instant
9
+ * before the child is spawned (the moment the wrapper's work ends).
10
+ *
11
+ * All marks are `performance.now()` values, which Node measures from
12
+ * `performance.timeOrigin` ≈ process start — so a mark's absolute value is
13
+ * "ms since the `agents` process began", and consecutive marks give the cost
14
+ * of each stage. When the env flag is off, every function here is a couple of
15
+ * cheap branches and pushes nothing, so it is safe to leave wired on the hot
16
+ * path (the committed `scripts/bench-boot.sh` benchmark drives it).
17
+ */
18
+ import { performance } from 'node:perf_hooks';
19
+ const ENABLED = process.env.AGENTS_PROFILE_BOOT === '1' || process.env.AGENTS_PROFILE_BOOT === 'true';
20
+ const marks = [];
21
+ let flushed = false;
22
+ /** True when `AGENTS_PROFILE_BOOT` is set — callers can skip building label strings. */
23
+ export function bootProfileEnabled() {
24
+ return ENABLED;
25
+ }
26
+ /**
27
+ * Record a named stage boundary. No-op unless `AGENTS_PROFILE_BOOT` is set, so
28
+ * this is free to call unconditionally on the launch path.
29
+ */
30
+ export function bootMark(label) {
31
+ if (!ENABLED)
32
+ return;
33
+ marks.push({ label, at: performance.now() });
34
+ }
35
+ /**
36
+ * Print the collected timeline to stderr, once. Called right before the harness
37
+ * child is spawned (the end of the pre-exec window) and again as an
38
+ * `process.on('exit')` backstop for launch paths that error out before spawn.
39
+ * `reason` labels the final boundary (e.g. `spawn`, `exit`).
40
+ */
41
+ export function flushBootProfile(reason) {
42
+ if (!ENABLED || flushed)
43
+ return;
44
+ flushed = true;
45
+ bootMark(reason);
46
+ if (marks.length === 0)
47
+ return;
48
+ const start = 0; // process start
49
+ const end = marks[marks.length - 1].at;
50
+ const width = Math.max(...marks.map((m) => m.label.length));
51
+ const lines = [];
52
+ lines.push(`[boot-profile] pre-exec timeline (total ${(end - start).toFixed(1)}ms since process start)`);
53
+ let prev = start;
54
+ for (const m of marks) {
55
+ const delta = m.at - prev;
56
+ prev = m.at;
57
+ lines.push(` ${m.label.padEnd(width)} +${delta.toFixed(1).padStart(7)}ms @${m.at.toFixed(1).padStart(8)}ms`);
58
+ }
59
+ process.stderr.write(lines.join('\n') + '\n');
60
+ }
61
+ // Backstop: a run that exits before reaching the spawn (a login dead-end, a
62
+ // missing install) still emits whatever stages it reached, so the profile is
63
+ // never silently empty.
64
+ if (ENABLED) {
65
+ process.on('exit', () => flushBootProfile('exit'));
66
+ }
@@ -13,6 +13,18 @@ export interface CallerIdentity {
13
13
  * agent comm is found.
14
14
  */
15
15
  export declare function resolveCallerIdentity(env?: NodeJS.ProcessEnv): CallerIdentity;
16
+ /**
17
+ * The env a `browser --device` dispatch forwards so the HUB resolves the SAME
18
+ * caller identity the worker would stamp locally. Without it a forwarded verb
19
+ * lands on the hub with BLANK identity, and the daemon's no-identity task bucket
20
+ * matches any other no-identity task — so a cold verb could attach to (or a
21
+ * `done`/`stop` could close) an unrelated agent's or a human's browser. This is
22
+ * the same surface-parity fix the `run --device` path already carries via
23
+ * `--env AGENT_LAUNCH_ID` (remote-session-id.ts). Absent fields are omitted so a
24
+ * genuinely-unidentifiable caller forwards nothing rather than an empty string
25
+ * that would read as a real (blank) identity on the far side.
26
+ */
27
+ export declare function callerIdentityEnv(caller: Pick<CallerIdentity, 'sessionId' | 'launchId'>): Record<string, string>;
16
28
  /**
17
29
  * True when a live task belongs to this caller. Match on sessionId or
18
30
  * launchId — either is enough. A task with no recorded identity never
@@ -141,6 +141,25 @@ export function resolveCallerIdentity(env = process.env) {
141
141
  }
142
142
  return { actor, sessionId, launchId, agentPid };
143
143
  }
144
+ /**
145
+ * The env a `browser --device` dispatch forwards so the HUB resolves the SAME
146
+ * caller identity the worker would stamp locally. Without it a forwarded verb
147
+ * lands on the hub with BLANK identity, and the daemon's no-identity task bucket
148
+ * matches any other no-identity task — so a cold verb could attach to (or a
149
+ * `done`/`stop` could close) an unrelated agent's or a human's browser. This is
150
+ * the same surface-parity fix the `run --device` path already carries via
151
+ * `--env AGENT_LAUNCH_ID` (remote-session-id.ts). Absent fields are omitted so a
152
+ * genuinely-unidentifiable caller forwards nothing rather than an empty string
153
+ * that would read as a real (blank) identity on the far side.
154
+ */
155
+ export function callerIdentityEnv(caller) {
156
+ const env = {};
157
+ if (caller.sessionId)
158
+ env.AGENT_SESSION_ID = caller.sessionId;
159
+ if (caller.launchId)
160
+ env.AGENT_LAUNCH_ID = caller.launchId;
161
+ return env;
162
+ }
144
163
  /**
145
164
  * True when a live task belongs to this caller. Match on sessionId or
146
165
  * launchId — either is enough. A task with no recorded identity never
@@ -4,24 +4,24 @@ import type { IPCRequest, IPCResponse } from './types.js';
4
4
  export interface IPCRequestOptions {
5
5
  autoStartDaemon?: boolean;
6
6
  }
7
- export declare class BrowserDaemonNotRunningError extends Error {
7
+ export declare class BrowserServiceNotRunningError extends Error {
8
8
  constructor();
9
9
  }
10
- export declare function formatBrowserDaemonNotRunningError(): string;
10
+ export declare function formatBrowserServiceNotRunningError(daemonRunning?: boolean): string;
11
11
  export declare function getSocketPath(): string;
12
12
  /** Is the daemon reachable? A real connect probe on every platform — a socket
13
13
  * file existing on disk is not proof a daemon is listening on it. */
14
- export declare function isDaemonReachable(): Promise<boolean>;
14
+ export declare function isBrowserServiceReachable(): Promise<boolean>;
15
15
  /**
16
16
  * Is a *reachable* daemon actually responsive, or is its event loop wedged
17
- * (PHNX-3411)? Retries {@link probeDaemonResponsive} up to
17
+ * (PHNX-3411)? Retries {@link probeBrowserServiceResponsive} up to
18
18
  * {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
19
19
  * pause) never condemns a healthy daemon; returns true as soon as any attempt
20
20
  * gets a reply, and false only when every attempt fails.
21
21
  */
22
- export declare function isDaemonResponsive(endpoint?: string, attempts?: number): Promise<boolean>;
22
+ export declare function isBrowserServiceResponsive(endpoint?: string, attempts?: number): Promise<boolean>;
23
23
  /**
24
- * Wait until the browser daemon is genuinely reachable, or throw.
24
+ * Wait until the browser service is genuinely reachable, or throw.
25
25
  *
26
26
  * Re-probes across the whole window rather than latching on the first accept, so
27
27
  * it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
@@ -30,32 +30,35 @@ export declare function isDaemonResponsive(endpoint?: string, attempts?: number)
30
30
  * that never comes up throws a message naming the endpoint and the budget, never
31
31
  * a silent hang.
32
32
  */
33
- export declare function waitForSocket(_socketPath: string, timeoutMs?: number): Promise<void>;
34
- /** Outcome of {@link resetBrowserDaemon} — what the wedge-recovery actually did. */
35
- export interface BrowserDaemonResetResult {
36
- /** The daemon was reachable before the reset and a stop was issued. */
33
+ export declare function waitForBrowserService(_socketPath: string, timeoutMs?: number): Promise<void>;
34
+ /** Outcome of {@link stopBrowserService} — what the service-scoped stop did. */
35
+ export interface BrowserServiceStopResult {
36
+ /** The browser IPC service was reachable before the stop was requested. */
37
37
  wasRunning: boolean;
38
+ /** The shared daemon stayed running while only browser IPC was stopped. */
39
+ daemonRunning: boolean;
38
40
  /** A leftover `browser.sock` file was unlinked (POSIX only). */
39
41
  socketCleared: boolean;
40
42
  }
41
43
  /**
42
- * Clear a wedged browser daemon so a subsequent `start` comes up clean
43
- * (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
44
- * `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
45
- * endpoint to stop accepting, then unlinks any stale `browser.sock` a
46
- * hard-crashed daemon left behind — the file a fresh `start` would otherwise
47
- * `unlink` blindly, racing whatever still holds it.
44
+ * Stop only the daemon-hosted browser IPC service so a subsequent `start` comes
45
+ * up clean (PHNX-3605). The short-lived browser client persists the
46
+ * `browser-ipc` service off and asks the daemon to apply that transition over
47
+ * its existing SIGHUP control path. It never stops or restarts the shared
48
+ * supervisor, so routines, usage sync, secrets, and every sibling service keep
49
+ * their process and in-flight work.
48
50
  *
49
- * Fails loud: if the endpoint is *still* reachable after the quiesce window, a
50
- * live server is holding it and clearing the socket under it would orphan two
51
- * servers on one path, so we throw rather than pretend the reset worked. The
52
- * daemon auto-restarts on the next browser command.
51
+ * Once the endpoint is quiet, a stale socket left by a hard-crashed browser
52
+ * service is removed. If the daemon event loop itself is wedged and cannot
53
+ * consume SIGHUP, the endpoint remains reachable and this fails loud with the
54
+ * deliberate operator-owned whole-daemon restart command; the client never
55
+ * escalates into that process-wide action itself.
53
56
  */
54
- export declare function resetBrowserDaemon(): Promise<BrowserDaemonResetResult>;
57
+ export declare function stopBrowserService(): Promise<BrowserServiceStopResult>;
55
58
  /**
56
59
  * Remove a leftover browser socket FILE, but only when nothing is listening on
57
60
  * it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
58
- * window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
61
+ * window (PHNX-3289 review). Between {@link stopBrowserService}'s quiesce loop
59
62
  * deciding the endpoint was unreachable and this unlink, a concurrent
60
63
  * `browser start` could bind a NEW listener on the same path; an unconditional
61
64
  * unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
@@ -65,7 +68,7 @@ export declare function resetBrowserDaemon(): Promise<BrowserDaemonResetResult>;
65
68
  */
66
69
  export declare function clearDeadSocketFile(endpoint: string, socketPath: string): Promise<boolean>;
67
70
  /**
68
- * One long-lived connection to the existing browser daemon. Requests are
71
+ * One long-lived connection to the existing browser service. Requests are
69
72
  * serialized so the daemon's newline-delimited responses always map to the
70
73
  * caller that produced them, while the process and socket stay warm between
71
74
  * actions.
@@ -85,6 +88,8 @@ export declare class BrowserIPCConnection {
85
88
  }
86
89
  export declare class BrowserIPCServer {
87
90
  private server;
91
+ /** Filesystem identity of the POSIX binding this server created. */
92
+ private socketIdentity;
88
93
  private service;
89
94
  /** Live client connections, so {@link stop} can end them rather than wait. */
90
95
  private connections;
@@ -128,21 +133,21 @@ export declare class BrowserIPCServer {
128
133
  private handleRequest;
129
134
  }
130
135
  /**
131
- * Decide whether a running daemon is stale and must be restarted.
136
+ * Decide whether a running daemon is stale enough to name an operator refresh.
132
137
  *
133
- * FORWARD ONLY: restart when the daemon is *older* than this CLI so a newer
134
- * install loads current code. An older CLI rides a newer daemon instead of
135
- * evicting it — two installs sharing one daemon dir (keyed off $HOME) must
136
- * not flap the daemon indefinitely.
138
+ * FORWARD ONLY: recommend a refresh when the daemon is *older* than this CLI.
139
+ * An older CLI rides a newer daemon without noise. A client never performs the
140
+ * refresh itself — two installs sharing one daemon dir (keyed off $HOME) must
141
+ * not flap the daemon or interrupt one another's hosted services.
137
142
  *
138
143
  * `undefined`/`'unknown'` means the daemon is too old to answer the `version`
139
- * action reliably — don't churn it on that ambiguous signal.
144
+ * action reliably — don't recommend an action on that ambiguous signal.
140
145
  *
141
146
  * When numeric compare cannot order two distinct strings (e.g. two
142
- * `0.0.0-dev.*` builds), treat the mismatch as a restart so a concrete
143
- * code change still loads.
147
+ * `0.0.0-dev.*` builds), surface the mismatch without deciding either client
148
+ * owns the shared process.
144
149
  */
145
- export declare function shouldRestartStaleDaemon(daemonVersion: string | undefined, clientVersion: string): boolean;
150
+ export declare function shouldRecommendDaemonRefresh(daemonVersion: string | undefined, clientVersion: string): boolean;
146
151
  export declare function sendIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
147
152
  /**
148
153
  * Fill actor / launchId / sessionId from the calling process when the request