@phnx-labs/agents-cli 1.22.63 → 1.22.65

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 (40) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/commands/accounts.d.ts +8 -7
  3. package/dist/commands/accounts.js +40 -13
  4. package/dist/commands/exec.js +40 -0
  5. package/dist/commands/feed.js +2 -0
  6. package/dist/commands/resume.d.ts +8 -0
  7. package/dist/commands/resume.js +66 -4
  8. package/dist/lib/accounting/account-pool-collect.js +7 -2
  9. package/dist/lib/accounting/account-pool.d.ts +12 -0
  10. package/dist/lib/accounting/account-pool.js +1 -0
  11. package/dist/lib/accounting/rotate.d.ts +57 -8
  12. package/dist/lib/accounting/rotate.js +57 -10
  13. package/dist/lib/accounting/usage.d.ts +12 -0
  14. package/dist/lib/accounting/usage.js +119 -15
  15. package/dist/lib/agent-spec/agents.js +21 -6
  16. package/dist/lib/claude-account-token.d.ts +26 -0
  17. package/dist/lib/claude-account-token.js +65 -11
  18. package/dist/lib/daemon-ticks.js +6 -2
  19. package/dist/lib/event-families.js +1 -1
  20. package/dist/lib/exec.d.ts +66 -1
  21. package/dist/lib/exec.js +104 -8
  22. package/dist/lib/feed/events.d.ts +1 -1
  23. package/dist/lib/feed/events.js +5 -0
  24. package/dist/lib/feed-broadcast.d.ts +2 -0
  25. package/dist/lib/feed-broadcast.js +31 -4
  26. package/dist/lib/harness/adapters/claude.js +33 -27
  27. package/dist/lib/hosts/passthrough.d.ts +44 -0
  28. package/dist/lib/hosts/passthrough.js +66 -8
  29. package/dist/lib/identity/client.d.ts +5 -4
  30. package/dist/lib/identity/client.js +6 -5
  31. package/dist/lib/session/active.d.ts +10 -1
  32. package/dist/lib/session/active.js +8 -0
  33. package/dist/lib/session/digest.js +8 -2
  34. package/dist/lib/session/parse.d.ts +4 -0
  35. package/dist/lib/session/parse.js +16 -11
  36. package/dist/lib/session/state.d.ts +5 -0
  37. package/dist/lib/session/state.js +6 -0
  38. package/dist/lib/usage-refresh.d.ts +100 -1
  39. package/dist/lib/usage-refresh.js +195 -4
  40. package/package.json +1 -1
@@ -98,6 +98,26 @@ export function isLaunchableSignedIn(signedIn, presence) {
98
98
  return true;
99
99
  return presence.perVersion;
100
100
  }
101
+ /**
102
+ * Whether a SPECIFIC installed version is launchable-signed-in on THIS device,
103
+ * plus the account email when it is. Mirrors EXACTLY the per-version gate
104
+ * {@link collectRunCandidates} applies (getVersionHomePath -> getAccountInfo ->
105
+ * {@link isLaunchableSignedIn} over {@link credentialPresence}), so the
106
+ * pre-launch `run.launch` event can report the same signed-in verdict the
107
+ * balanced router computes for that version.
108
+ *
109
+ * The point is to make a launch into a logged-out version VISIBLE at spawn time:
110
+ * `--device auto` only guarantees SOME account is ready on the device, not that
111
+ * the specific version launched is signed in there (the yosemite-m3 2.1.219
112
+ * incident — 2.1.219 was logged out, the router correctly excluded it, yet it
113
+ * launched). Non-fatal by construction: callers wrap it best-effort.
114
+ */
115
+ export async function isVersionLaunchableHere(agent, version) {
116
+ const home = getVersionHomePath(agent, version);
117
+ const info = await getAccountInfo(agent, home);
118
+ const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
119
+ return { launchable, email: launchable ? info.email : null };
120
+ }
101
121
  function isAvailableEligible(candidate) {
102
122
  return isRotationEligible(candidate);
103
123
  }
@@ -111,6 +131,27 @@ function isAvailableEligible(candidate) {
111
131
  * launched into it while the account was actually at its weekly cap.
112
132
  */
113
133
  export const USAGE_DECISION_MAX_AGE_MS = 5 * 60 * 1000;
134
+ /**
135
+ * How old a usage snapshot may be before routing REFUSES to run at all
136
+ * (NO_VERIFIED_USAGE), as opposed to merely declining to *weight* by its number.
137
+ *
138
+ * These are two different risks and now two different bars. Weighting on a
139
+ * slightly-old number is cheap to get wrong (a floored weight, {@link
140
+ * USAGE_DECISION_MAX_AGE_MS} = 5 min); refusing to launch at all is expensive to
141
+ * get wrong — it fails the user's `agents run` outright. The daemon's usage
142
+ * refresher paces proactive fetches under a fixed per-provider budget
143
+ * (`usage-refresh.ts`, PROVIDER_HOURLY_BUDGET), so on a multi-account fleet an
144
+ * IDLE account is deliberately refreshed on a stretched round-robin cadence
145
+ * (bounded at N × spacing — ~16 min at 8 accounts, ~32 min at 16) rather than
146
+ * every 5 min, which would 429 the endpoint and park it for up to an hour. A
147
+ * budget-paced idle reading of 10–30 min is NOT the failure this refusal exists
148
+ * to catch. That failure is the `yosemite-s1` case: a box whose refresh is
149
+ * genuinely BROKEN, holding readings 26 h – 2.7 d old. 40 min sits comfortably
150
+ * above the worst-case budget cadence and still an order of magnitude below the
151
+ * multi-hour staleness of a broken box — and actively-used accounts refresh for
152
+ * free via the statusline ingest, so a *busy* account is never even this old.
153
+ */
154
+ export const USAGE_STALE_REFUSAL_MAX_AGE_MS = 40 * 60 * 1000;
114
155
  /**
115
156
  * Whether this candidate's usage number is recent enough to route on. A missing
116
157
  * snapshot is unverified by definition — there is no number to trust.
@@ -132,24 +173,30 @@ export function isUsageVerified(candidate, nowMs = Date.now()) {
132
173
  return nowMs - capturedAt.getTime() <= USAGE_DECISION_MAX_AGE_MS;
133
174
  }
134
175
  /**
135
- * Whether this candidate carries a STALE-but-present usage number: a snapshot
136
- * with windows whose capture time is older than {@link USAGE_DECISION_MAX_AGE_MS}.
176
+ * Whether this candidate carries a GENUINELY-STALE usage number: a snapshot with
177
+ * windows whose capture time is older than {@link USAGE_STALE_REFUSAL_MAX_AGE_MS}.
137
178
  *
138
179
  * This is the misleading case the initial route must refuse — the number reads
139
180
  * "48% used" with the same confidence whether captured a minute or three days
140
- * ago, and a box whose refresh is failing stays wrong indefinitely. It is
141
- * deliberately NARROWER than "not verified": a BLIND candidate with no snapshot
142
- * (or a plan-only meterless one with no windows) carries no number to be misled
143
- * by — a worker box whose usage endpoint 403s (RUSH-2392), or a meterless Grok
144
- * login — so it is not "stale", and an entirely-blind pool still draws a pick
145
- * (PHNX-3392) rather than fail loud with NO_VERIFIED_USAGE.
181
+ * ago, and a box whose refresh is failing stays wrong indefinitely. Two things
182
+ * make it NARROWER than "not verified":
183
+ * 1. It uses the wider REFUSAL bar, not the 5-min weighting bar. A merely
184
+ * budget-paced idle account (10–30 min old) is not-verified — so it weights
185
+ * at the floor, conservatively — but it is NOT "stale" and must not, on its
186
+ * own, drive the whole provider to a NO_VERIFIED_USAGE refusal. Only a
187
+ * genuinely broken refresh (hours old) trips this.
188
+ * 2. A BLIND candidate with no snapshot (or a plan-only meterless one with no
189
+ * windows) carries no number to be misled by — a worker box whose usage
190
+ * endpoint 403s (RUSH-2392), or a meterless Grok login — so it is not
191
+ * "stale", and an entirely-blind pool still draws a pick (PHNX-3392) rather
192
+ * than fail loud with NO_VERIFIED_USAGE.
146
193
  */
147
194
  export function hasStaleUsage(candidate, nowMs = Date.now()) {
148
195
  const snapshot = candidate.usageSnapshot;
149
196
  const capturedAt = snapshot?.capturedAt;
150
197
  if (!capturedAt || !snapshot?.windows.length)
151
198
  return false;
152
- return nowMs - capturedAt.getTime() > USAGE_DECISION_MAX_AGE_MS;
199
+ return nowMs - capturedAt.getTime() > USAGE_STALE_REFUSAL_MAX_AGE_MS;
153
200
  }
154
201
  function hasUsageAvailable(candidate) {
155
202
  const snapshot = candidate.usageSnapshot;
@@ -609,7 +656,7 @@ export function formatNoVerifiedUsageError(agent, strategy, candidates, nowMs =
609
656
  const staleness = age === null ? 'no usage snapshot' : `usage ${age}m old`;
610
657
  return `${c.version} (${staleness})`;
611
658
  }).join(', ');
612
- const maxAgeMin = Math.round(USAGE_DECISION_MAX_AGE_MS / 60_000);
659
+ const maxAgeMin = Math.round(USAGE_STALE_REFUSAL_MAX_AGE_MS / 60_000);
613
660
  return `agents: NO_VERIFIED_USAGE — no signed-in ${agent} account has usage newer than ${maxAgeMin}m under strategy '${strategy}', so routing refuses to guess on a stale number: ${detail}. Refresh usage (agents view ${agent}) or pin the default with --strategy pinned.`;
614
661
  }
615
662
  /**
@@ -172,6 +172,18 @@ export interface UsageSnapshot {
172
172
  sourceLabel: string;
173
173
  capturedAt: Date | null;
174
174
  windows: UsageWindow[];
175
+ /**
176
+ * Last-known windows the freshness gate DROPPED from `windows` — expired by
177
+ * `resetsAt`/`windowMinutes`, or from a rolled-over billing period. VIEW-ONLY:
178
+ * `agents view` renders these with a staleness age ("30% · 6h old") so the
179
+ * user always sees the last number instead of a bare "unavailable". Routing
180
+ * MUST NEVER read this field — `isUsageVerified`/`hasStaleUsage`/
181
+ * `hasUsageAvailable`/`deriveUsageStatusFromSnapshot` consult only `windows`,
182
+ * so a stale number rendered here can never make a stale account read as
183
+ * verified or eligible (the RUSH-2858 property). Never serialized to the
184
+ * on-disk cache or `--json` (both project `windows` explicitly).
185
+ */
186
+ staleWindows?: UsageWindow[];
175
187
  plan?: string | null;
176
188
  /** Action that makes an event-fed source emit a current reading. */
177
189
  refreshHint?: string | null;
@@ -437,8 +437,22 @@ export async function getUsageInfoForIdentity(input, opts) {
437
437
  // stale snapshot here is safe, and an absent one reports
438
438
  // {@link USAGE_NOT_COLLECTED_MARKER}.
439
439
  if (readOnly) {
440
- if (cached)
440
+ // A row carries a CONFIRMED reading when it has a fresh window, a
441
+ // subscription plan (meterless-healthy, e.g. Grok's tier), or a live refusal
442
+ // (out_of_credits / session_limit). Those report `usageError: null`.
443
+ if (cached && (cached.windows.length > 0 || cached.plan || cached.unavailable)) {
441
444
  return { snapshot: cached, error: null };
445
+ }
446
+ // A row whose ONLY content is last-known stale readings — the all-expired
447
+ // Claude case, its windows moved to `staleWindows` (view-only) so the
448
+ // TERMINAL view still renders the number with its age — must NOT read as a
449
+ // healthy account. `staleWindows` is deliberately excluded from `--json`
450
+ // (which projects only `windows`), so keep `usageError` non-null: a consumer
451
+ // polling `agents view --json` must still see the staleness signal, the exact
452
+ // RUSH-2858 weeks-stale case this marker exists for. The snapshot is still
453
+ // returned, so the human view is unaffected.
454
+ if (cached)
455
+ return { snapshot: cached, error: USAGE_NOT_COLLECTED_MARKER };
442
456
  return { snapshot: null, error: USAGE_NOT_COLLECTED_MARKER };
443
457
  }
444
458
  // Explicit refresh: block on the shared device collector.
@@ -590,14 +604,25 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
590
604
  // multi-meter agent cannot force the whole table to wrap.
591
605
  const selected = pickCompactUsageWindows(snapshot.windows, opts?.maxWindows);
592
606
  const hidden = Math.max(0, snapshot.windows.filter((w) => w.key !== 'sonnet_week').length - selected.length);
607
+ // Last-known windows the freshness gate dropped (see UsageSnapshot.staleWindows).
608
+ // Rendered with an age suffix so a stale reading stays visible instead of a
609
+ // bare "unavailable"; never in `snapshot.windows`, so routing never sees them.
610
+ const now = new Date();
611
+ const staleWindows = snapshot.staleWindows ?? [];
612
+ const staleByKey = new Map(staleWindows.map((w) => [w.key, w]));
593
613
  const expected = opts?.expectedWindows;
594
614
  const windowsToRender = expected
595
- ? expected.map(({ key, shortLabel }) => ({ window: selected.find((item) => item.key === key), shortLabel }))
596
- : selected.map((window) => ({ window, shortLabel: window.shortLabel }));
597
- const windowParts = windowsToRender.map(({ window, shortLabel }, index) => {
615
+ ? expected.map(({ key, shortLabel }) => ({ key, window: selected.find((item) => item.key === key), shortLabel }))
616
+ : selected.map((window) => ({ key: window.key, window, shortLabel: window.shortLabel }));
617
+ const windowParts = windowsToRender.map(({ key, window, shortLabel }, index) => {
598
618
  if (!window) {
599
- const missing = chalk.dim(`${shortLabel}: ${NO_DATA.repeat(COMPACT_BAR_LEN)} unavailable`);
600
- return index < windowsToRender.length - 1 ? padToWidth(missing, 20) : missing;
619
+ // A window we expected but have no fresh reading for: render the
620
+ // last-known value with its age if we still have it, else "unavailable".
621
+ const stale = staleByKey.get(key);
622
+ const rendered = stale
623
+ ? renderStaleUsageWindow(stale, snapshot.capturedAt, shortLabel, now)
624
+ : chalk.dim(`${shortLabel}: ${NO_DATA.repeat(COMPACT_BAR_LEN)} unavailable`);
625
+ return index < windowsToRender.length - 1 ? padToWidth(rendered, 20) : rendered;
601
626
  }
602
627
  const bar = renderCompactUsageBar(window.usedPercent);
603
628
  const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
@@ -611,6 +636,16 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
611
636
  if (windowParts.length > 0) {
612
637
  parts.push(windowParts.join(' '));
613
638
  }
639
+ else if (staleWindows.length > 0) {
640
+ // No fresh bars, but we have last-known readings (e.g. Grok's weekly bar
641
+ // from an ended billing period): show them with an age suffix rather than
642
+ // the "run once to refresh" hint, which hid a number we actually had.
643
+ const cap = opts?.maxWindows ?? staleWindows.length;
644
+ const rendered = staleWindows
645
+ .slice(0, cap)
646
+ .map((w) => renderStaleUsageWindow(w, snapshot.capturedAt, w.shortLabel, now));
647
+ parts.push(rendered.join(' '));
648
+ }
614
649
  else if (snapshot.refreshHint) {
615
650
  parts.push(chalk.dim(snapshot.refreshHint));
616
651
  }
@@ -1879,9 +1914,12 @@ function serializeClaudeUsageSnapshot(snapshot) {
1879
1914
  * have burned since. Zeroing-but-keeping it (the previous behavior) rendered a
1880
1915
  * weeks-frozen cache as "S: 0% (now)" with `deriveUsageStatusFromSnapshot` →
1881
1916
  * 'available', so a genuinely rate-limited account read as an idle dispatch
1882
- * candidate (RUSH-2858). Dropping mirrors the Grok collector, and an all-expired
1883
- * snapshot deserializes to null so `readClaudeUsageCache` deletes the entry and
1884
- * callers surface "usage unavailable" plus the recorded throttle reason.
1917
+ * candidate (RUSH-2858). Dropping keeps them out of `windows` (routing stays
1918
+ * blind), but they are preserved on `staleWindows` so the view can render the
1919
+ * last-known number with its age instead of a bare "unavailable" — a row that
1920
+ * carries only stale windows therefore survives (it is worth showing), and only
1921
+ * a row with NOTHING to show — no fresh window, no stale window, no plan, no
1922
+ * refusal — deserializes to null so `readClaudeUsageCache` deletes it.
1885
1923
  *
1886
1924
  * A row that carries a plan survives even with no fresh windows: the plan is a
1887
1925
  * truthful reading in its own right, and losing it is what made the cached view
@@ -1889,16 +1927,21 @@ function serializeClaudeUsageSnapshot(snapshot) {
1889
1927
  */
1890
1928
  function deserializeClaudeUsageSnapshot(snapshot, now) {
1891
1929
  const capturedAt = parseDateValue(snapshot.capturedAt);
1892
- const windows = snapshot.windows
1893
- .map((window) => ({
1930
+ const deserialized = snapshot.windows.map((window) => ({
1894
1931
  key: window.key,
1895
1932
  label: window.label,
1896
1933
  shortLabel: window.shortLabel,
1897
1934
  usedPercent: window.usedPercent,
1898
1935
  resetsAt: parseDateValue(window.resetsAt),
1899
1936
  windowMinutes: window.windowMinutes,
1900
- }))
1901
- .filter((window) => isCachedUsageWindowFresh(window, capturedAt, now));
1937
+ }));
1938
+ const windows = deserialized.filter((window) => isCachedUsageWindowFresh(window, capturedAt, now));
1939
+ // The dropped windows are still the LAST reading we saw for those meters —
1940
+ // routing must not trust them (they stay out of `windows`), but the view
1941
+ // renders them with an age suffix rather than a bare "unavailable" (see
1942
+ // UsageSnapshot.staleWindows). Skip any meter that already has a fresh row.
1943
+ const freshKeys = new Set(windows.map((window) => window.key));
1944
+ const staleWindows = deserialized.filter((window) => !freshKeys.has(window.key) && !isCachedUsageWindowFresh(window, capturedAt, now));
1902
1945
  const unavailable = deserializeUnavailable(snapshot.unavailable, now);
1903
1946
  // A windowless row is not automatically worthless. Grok's collector reports
1904
1947
  // the subscription tier and no meters at all, so treating "no fresh windows"
@@ -1910,7 +1953,11 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
1910
1953
  // and `deriveUsageStatusFromSnapshot` still returns null for zero windows, so
1911
1954
  // it can never read as a 0% bar or an "available" badge (the RUSH-2858
1912
1955
  // property that made expired windows drop in the first place).
1913
- if (windows.length === 0 && !unavailable && !snapshot.plan && !snapshot.refreshHint) {
1956
+ if (windows.length === 0 &&
1957
+ staleWindows.length === 0 &&
1958
+ !unavailable &&
1959
+ !snapshot.plan &&
1960
+ !snapshot.refreshHint) {
1914
1961
  return null;
1915
1962
  }
1916
1963
  return {
@@ -1918,6 +1965,7 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
1918
1965
  sourceLabel: CACHED_CLAUDE_USAGE_SOURCE_LABEL,
1919
1966
  capturedAt,
1920
1967
  windows,
1968
+ staleWindows: staleWindows.length > 0 ? staleWindows : undefined,
1921
1969
  plan: snapshot.plan ?? null,
1922
1970
  refreshHint: snapshot.refreshHint ?? null,
1923
1971
  unavailable,
@@ -2246,6 +2294,56 @@ function formatResetHint(date) {
2246
2294
  const days = Math.round(hours / 24);
2247
2295
  return `${days}d`;
2248
2296
  }
2297
+ /**
2298
+ * Compact elapsed-time label for a stale reading's age: "30m", "6h", "2d".
2299
+ * Coarse single-unit like {@link formatResetHint}, floored at "1m" so a
2300
+ * just-expired window never reads "0m".
2301
+ */
2302
+ function formatAgeShort(diffMs) {
2303
+ const mins = Math.max(1, Math.round(diffMs / 60000));
2304
+ if (mins < 60)
2305
+ return `${mins}m`;
2306
+ const hours = Math.round(mins / 60);
2307
+ if (hours < 24)
2308
+ return `${hours}h`;
2309
+ const days = Math.round(hours / 24);
2310
+ return `${days}d`;
2311
+ }
2312
+ /**
2313
+ * Staleness suffix for a last-known window the freshness gate dropped. A window
2314
+ * whose reset/period boundary passed while the sample itself is still inside its
2315
+ * `windowMinutes` rolled OVER — the number describes a period that is done, so
2316
+ * name it ("period ended 1h ago", e.g. Grok's weekly billing period). A window
2317
+ * that aged past its own `windowMinutes` (Claude's 5h session read never
2318
+ * refreshed in time) is a stale sample of a still-rolling window, so report the
2319
+ * capture age ("6h old"). Falls back to the reset age, then a bare "stale".
2320
+ */
2321
+ function formatStaleWindowSuffix(window, capturedAt, now) {
2322
+ const resetPassed = !!window.resetsAt && window.resetsAt.getTime() <= now.getTime();
2323
+ const captureExpired = !!capturedAt &&
2324
+ window.windowMinutes !== null &&
2325
+ capturedAt.getTime() + window.windowMinutes * 60 * 1000 <= now.getTime();
2326
+ if (resetPassed && !captureExpired) {
2327
+ return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
2328
+ }
2329
+ if (capturedAt)
2330
+ return `${formatAgeShort(now.getTime() - capturedAt.getTime())} old`;
2331
+ if (resetPassed)
2332
+ return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
2333
+ return 'stale';
2334
+ }
2335
+ /**
2336
+ * Render a dropped-but-last-known window as "S: ▍░░░░ 30% · 6h old": the gauge
2337
+ * and percentage exactly as a live bar, then a dim staleness suffix so the
2338
+ * number is always visible and unmistakably not current. VIEW-ONLY — these
2339
+ * windows are never in `snapshot.windows`, so routing never sees them.
2340
+ */
2341
+ function renderStaleUsageWindow(window, capturedAt, shortLabel, now) {
2342
+ const bar = renderCompactUsageBar(window.usedPercent);
2343
+ const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
2344
+ const suffix = formatStaleWindowSuffix(window, capturedAt, now);
2345
+ return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct} ${chalk.dim(`· ${suffix}`)}`;
2346
+ }
2249
2347
  /** Format a reset timestamp as a human-readable relative or absolute time. */
2250
2348
  function formatResetAt(date) {
2251
2349
  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -2308,8 +2406,13 @@ async function getGrokUsageInfo(options) {
2308
2406
  // (see readLatestGrokBilling).
2309
2407
  const now = new Date();
2310
2408
  const windows = match.windows.filter((window) => isCachedUsageWindowFresh(window, match.capturedAt, now));
2409
+ // A window from an ended billing period is the LAST reading we saw — routing
2410
+ // must not trust it (kept out of `windows`), but the view renders it with a
2411
+ // "period ended Xh ago" suffix instead of the bare refresh hint. Only when
2412
+ // there is nothing at all to show does the refresh hint stand alone.
2413
+ const staleWindows = match.windows.filter((window) => !isCachedUsageWindowFresh(window, match.capturedAt, now));
2311
2414
  const version = options?.cliVersion;
2312
- const refreshHint = windows.length === 0
2415
+ const refreshHint = windows.length === 0 && staleWindows.length === 0
2313
2416
  ? `run grok${version ? `@${version}` : ''} once to refresh usage`
2314
2417
  : null;
2315
2418
  return {
@@ -2318,6 +2421,7 @@ async function getGrokUsageInfo(options) {
2318
2421
  sourceLabel: 'last seen in Grok logs',
2319
2422
  capturedAt: match.capturedAt,
2320
2423
  windows,
2424
+ staleWindows: staleWindows.length > 0 ? staleWindows : undefined,
2321
2425
  plan: match.subscriptionTier,
2322
2426
  refreshHint,
2323
2427
  },
@@ -1513,15 +1513,30 @@ function credentialFileExistsUnder(agentId, home) {
1513
1513
  const alternatives = CREDENTIAL_FILE_SEGMENTS[agentId];
1514
1514
  if (!alternatives)
1515
1515
  return false;
1516
- for (const segments of alternatives) {
1516
+ const hasSegment = alternatives.some((segments) => {
1517
1517
  const p = path.join(home, ...segments);
1518
1518
  try {
1519
- if (fs.existsSync(p))
1520
- return true;
1519
+ return fs.existsSync(p);
1521
1520
  }
1522
- catch { /* unreadable */ }
1523
- }
1524
- return false;
1521
+ catch {
1522
+ return false;
1523
+ }
1524
+ });
1525
+ if (!hasSegment)
1526
+ return false;
1527
+ // Claude's `.claude.json` is account METADATA Claude Code writes on any
1528
+ // launch, not the credential itself — a version can carry stale identity
1529
+ // there (e.g. install-time carry-forward) with no usable credential behind
1530
+ // it: a failed OAuth refresh blanks `.credentials.json`, and a Linux worker
1531
+ // can be missing BOTH `.credentials.json` and the `.oauth_token` setup-token
1532
+ // file entirely (PHNX-3502). `credentialPresence` promises to mirror what a
1533
+ // real launch would find, so it must fail whenever `isClaudeCredentialFileBlank`
1534
+ // — the SAME floor `getAccountInfo` applies — would. On macOS that floor is a
1535
+ // no-op (Keychain-only, always "not blank"), so this reduces to the identity
1536
+ // check above, unchanged.
1537
+ if (agentId === 'claude')
1538
+ return !isClaudeCredentialFileBlank(home);
1539
+ return true;
1525
1540
  }
1526
1541
  /**
1527
1542
  * File-presence probe for an agent's credential, split by location: whether it
@@ -22,3 +22,29 @@ export declare function readClaudeAccountEmail(home?: string): string | null;
22
22
  * authenticate with the shareable setup-token, not the ACL-bound login item.
23
23
  */
24
24
  export declare function resolveClaudeSetupToken(home?: string): string | null;
25
+ /**
26
+ * Resolve a long-lived setup-token for an EXPLICIT account email, independent of
27
+ * any version home's `.claude.json`. This is what lets `agents accounts attach`
28
+ * provision a headless worker home that has never had an interactive login: the
29
+ * account's non-rotating setup-token is already fleet-synced in the file-based
30
+ * `auth` bundle, keyed by email ({@link claudeAccountTokenKey}), so we can write
31
+ * the home's `.oauth_token` from it without the circular
32
+ * "read the home's email to resolve the home's token" dependency that
33
+ * {@link resolveClaudeSetupToken} has. Same file-backed-only, fail-closed,
34
+ * fingerprint-stable read as the home-keyed path — it is the shared core.
35
+ *
36
+ * `cacheKey` scopes the process-local token cache; callers pass a version home
37
+ * so a home-keyed and email-keyed read of the same account share nothing stale.
38
+ */
39
+ export declare function resolveClaudeSetupTokenForEmail(email: string, cacheKey?: string): string | null;
40
+ /**
41
+ * Seed a keychain-less Linux worker's Claude version-home identity so an account's
42
+ * fleet-synced setup-token resolves for it. A worker home never had an interactive
43
+ * browser login, so its `.claude.json` carries no `oauthAccount.emailAddress` and
44
+ * the account reads "signed out" even though its non-rotating setup-token is present
45
+ * in the `auth` bundle. This writes ONLY the descriptive identity (the email), merged
46
+ * into both `.claude.json` locations Claude Code reads, preserving every other field.
47
+ * It never copies a rotating OAuth credential (`.credentials.json`) — the setup-token
48
+ * stays the credential of record.
49
+ */
50
+ export declare function seedClaudeWorkerHomeIdentity(versionHome: string, email: string): void;
@@ -76,12 +76,32 @@ export function readClaudeAccountEmail(home) {
76
76
  * authenticate with the shareable setup-token, not the ACL-bound login item.
77
77
  */
78
78
  export function resolveClaudeSetupToken(home) {
79
+ // Require a known account (email) up front: without it we cannot key a
80
+ // per-account token, and we must NOT fall back to a bare shared key that
81
+ // would misapply one account's setup-token to another.
82
+ const email = readClaudeAccountEmail(home);
83
+ if (!email)
84
+ return null;
85
+ return resolveClaudeSetupTokenForEmail(email, home ?? os.homedir());
86
+ }
87
+ /**
88
+ * Resolve a long-lived setup-token for an EXPLICIT account email, independent of
89
+ * any version home's `.claude.json`. This is what lets `agents accounts attach`
90
+ * provision a headless worker home that has never had an interactive login: the
91
+ * account's non-rotating setup-token is already fleet-synced in the file-based
92
+ * `auth` bundle, keyed by email ({@link claudeAccountTokenKey}), so we can write
93
+ * the home's `.oauth_token` from it without the circular
94
+ * "read the home's email to resolve the home's token" dependency that
95
+ * {@link resolveClaudeSetupToken} has. Same file-backed-only, fail-closed,
96
+ * fingerprint-stable read as the home-keyed path — it is the shared core.
97
+ *
98
+ * `cacheKey` scopes the process-local token cache; callers pass a version home
99
+ * so a home-keyed and email-keyed read of the same account share nothing stale.
100
+ */
101
+ export function resolveClaudeSetupTokenForEmail(email, cacheKey) {
79
102
  try {
80
- // Require a known account (email) up front: without it we cannot key a
81
- // per-account token, and we must NOT fall back to a bare shared key that
82
- // would misapply one account's setup-token to another.
83
- const email = readClaudeAccountEmail(home);
84
- if (!email)
103
+ const trimmed = email.trim();
104
+ if (!trimmed)
85
105
  return null;
86
106
  if (!bundleExists(AUTH_BUNDLE))
87
107
  return null;
@@ -92,25 +112,26 @@ export function resolveClaudeSetupToken(home) {
92
112
  // hint that the seeded setup-token was being ignored.
93
113
  throw new ReservedBundleWrongBackendError(AUTH_BUNDLE, backend);
94
114
  }
95
- const cacheKey = home ?? os.homedir();
96
- const item = secretsKeychainItem(AUTH_BUNDLE, claudeAccountTokenKey(email));
115
+ const key = claudeAccountTokenKey(trimmed);
116
+ const ck = cacheKey ?? `email:${trimmed}`;
117
+ const item = secretsKeychainItem(AUTH_BUNDLE, key);
97
118
  const credentialPath = fileStoreItemPath(item);
98
119
  for (let attempt = 0; attempt < 2; attempt++) {
99
120
  const before = credentialFingerprint(credentialPath);
100
- const cached = setupTokenCache.get(cacheKey);
121
+ const cached = setupTokenCache.get(ck);
101
122
  if (cached?.credentialPath === credentialPath && cached.fingerprint === before) {
102
123
  return cached.token;
103
124
  }
104
125
  if (before === 'missing') {
105
- setupTokenCache.set(cacheKey, { credentialPath, fingerprint: before, token: null });
126
+ setupTokenCache.set(ck, { credentialPath, fingerprint: before, token: null });
106
127
  return null;
107
128
  }
108
129
  const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
109
- const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
130
+ const v = (env[key] ?? '').trim();
110
131
  const token = v.length > 0 && isValidClaudeSetupToken(v) ? v : null;
111
132
  const after = credentialFingerprint(credentialPath);
112
133
  if (before === after) {
113
- setupTokenCache.set(cacheKey, { credentialPath, fingerprint: after, token });
134
+ setupTokenCache.set(ck, { credentialPath, fingerprint: after, token });
114
135
  return token;
115
136
  }
116
137
  }
@@ -124,3 +145,36 @@ export function resolveClaudeSetupToken(home) {
124
145
  return null;
125
146
  }
126
147
  }
148
+ /**
149
+ * Seed a keychain-less Linux worker's Claude version-home identity so an account's
150
+ * fleet-synced setup-token resolves for it. A worker home never had an interactive
151
+ * browser login, so its `.claude.json` carries no `oauthAccount.emailAddress` and
152
+ * the account reads "signed out" even though its non-rotating setup-token is present
153
+ * in the `auth` bundle. This writes ONLY the descriptive identity (the email), merged
154
+ * into both `.claude.json` locations Claude Code reads, preserving every other field.
155
+ * It never copies a rotating OAuth credential (`.credentials.json`) — the setup-token
156
+ * stays the credential of record.
157
+ */
158
+ export function seedClaudeWorkerHomeIdentity(versionHome, email) {
159
+ const trimmed = email.trim();
160
+ if (!trimmed)
161
+ return;
162
+ for (const p of [
163
+ path.join(versionHome, '.claude', '.claude.json'),
164
+ path.join(versionHome, '.claude.json'),
165
+ ]) {
166
+ let doc = {};
167
+ try {
168
+ doc = JSON.parse(fs.readFileSync(p, 'utf-8'));
169
+ }
170
+ catch {
171
+ // Missing or unreadable at this location — write a fresh minimal document.
172
+ }
173
+ const existing = (doc.oauthAccount && typeof doc.oauthAccount === 'object'
174
+ ? doc.oauthAccount
175
+ : {});
176
+ doc.oauthAccount = { ...existing, emailAddress: trimmed };
177
+ fs.mkdirSync(path.dirname(p), { recursive: true });
178
+ fs.writeFileSync(p, JSON.stringify(doc));
179
+ }
180
+ }
@@ -114,17 +114,21 @@ export async function runFleetCacheWarmTick() {
114
114
  */
115
115
  export async function runUsageRefreshTick() {
116
116
  const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
117
- const { writeClaudeUsageCache } = await import('./accounting/usage.js');
117
+ const { writeClaudeUsageCache, readClaudeUsageCache } = await import('./accounting/usage.js');
118
118
  const { usageRateLimitedUntil } = await import('./usage-backoff.js');
119
119
  const r = await runUsageRefresh({
120
120
  listAccounts: buildLocalUsageAccounts,
121
121
  writeUsageCache: writeClaudeUsageCache,
122
122
  backoffUntil: (agentId, usageKey) => usageRateLimitedUntil(agentId, Date.now(), usageKey),
123
+ // The free statusline ingest of a live `agents run` writes this same cache,
124
+ // so a recent capture means the account is already fresh at zero API cost —
125
+ // the refresher re-derives headroom from it and skips the API fetch.
126
+ readCachedSnapshot: (usageKey) => readClaudeUsageCache(usageKey),
123
127
  });
124
128
  const { listProfiles } = await import('./profiles.js');
125
129
  const { refreshDueByokUsage } = await import('./byok-usage.js');
126
130
  const byok = await refreshDueByokUsage(listProfiles());
127
- console.log(`usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped; BYOK ${byok.refreshed} refreshed, ${byok.skipped} not-due`);
131
+ console.log(`usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped, ${r.skippedBudget} over-budget, ${r.skippedFresh} statusline-fresh; BYOK ${byok.refreshed} refreshed, ${byok.skipped} not-due`);
128
132
  }
129
133
  /**
130
134
  * Active-sessions warm (RUSH-2062 / RUSH-2484): publish THIS host's live session
@@ -36,7 +36,7 @@ export function parseFamilyList(raw, flagName) {
36
36
  /** Command-churn event kinds. */
37
37
  export const COMMAND_EVENT_TYPES = ['command.start', 'command.end'];
38
38
  /** Run-dispatch outcome kinds (replaces the separate audit/log.jsonl product). */
39
- export const RUN_EVENT_TYPES = ['run.dispatched', 'agent.run.end'];
39
+ export const RUN_EVENT_TYPES = ['run.dispatched', 'run.launch', 'agent.run.end'];
40
40
  /**
41
41
  * Fold family include/exclude into a UnifiedQuery.
42
42
  * Precedence: family narrows sources/types; field filters (module, event, …)
@@ -1,4 +1,5 @@
1
- import type { AgentId, Mode } from './types.js';
1
+ import type { AgentId, Mode, RunStrategy } from './types.js';
2
+ import { type EventPayload } from './feed/events.js';
2
3
  import { type UsernsStatus } from './linux-userns.js';
3
4
  /**
4
5
  * Agent execution modes. Canonical name `skip` (dangerously skip permissions);
@@ -212,6 +213,31 @@ export interface ExecOptions {
212
213
  * session. Also forced off by AGENTS_NO_TMUX=1. No effect on headless runs.
213
214
  */
214
215
  raw?: boolean;
216
+ /**
217
+ * The run strategy that resolved this launch (pinned/available/balanced).
218
+ * Observability-only: threaded from the `agents run` command purely so the
219
+ * pre-launch `run.launch` event can record HOW the version was chosen. Never
220
+ * read by the spawn itself.
221
+ */
222
+ strategy?: RunStrategy;
223
+ /**
224
+ * How the launched version was resolved (e.g. 'pinned-default', 'rotated',
225
+ * 'explicit-pin'), when cheaply determinable. Observability-only, carried on
226
+ * `run.launch`. Optional — omitted when the caller can't attribute it.
227
+ */
228
+ resolvedVia?: string;
229
+ /**
230
+ * Precomputed launchable-signed-in verdict for the launched version, supplied
231
+ * by a caller that already computed it via the identical gate (a rotated pick
232
+ * carries `rotationResult.picked.signedIn`). When present, `run.launch` uses it
233
+ * instead of re-probing the version home — removing a double fs read on the hot
234
+ * path and any disagreement window. `undefined` means "not precomputed" (the
235
+ * pinned-default / shim paths), which makes `emitRunLaunch` fall back to
236
+ * {@link isVersionLaunchableHere}. Observability-only.
237
+ */
238
+ launchSignedIn?: boolean | null;
239
+ /** Precomputed account email companion to {@link launchSignedIn}. */
240
+ launchEmail?: string | null;
215
241
  }
216
242
  /**
217
243
  * Identity a custom-harness run stamps on env / pid-registry / sidecars.
@@ -550,6 +576,45 @@ export declare function writeTmuxEnvFile(env: NodeJS.ProcessEnv, filePath: strin
550
576
  * `[detached]` the pane-died hook otherwise leaves behind.
551
577
  */
552
578
  export declare function formatPaneTail(raw: string, maxLines?: number): string;
579
+ /**
580
+ * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
581
+ *
582
+ * Stderr is always piped so the caller can inspect it (e.g., for rate-limit
583
+ * detection) while also forwarding every chunk to process.stderr in real time --
584
+ * the user sees the same output they would with stdio: 'inherit'. Stdout keeps
585
+ * the original behavior: 'pipe' when downstream output is piped (so `agents
586
+ * run ... | ...` composes cleanly), otherwise 'inherit' so TTY output is
587
+ * unbuffered.
588
+ */
589
+ /** Inputs the pre-launch `run.launch` payload is built from. */
590
+ export interface RunLaunchInput {
591
+ agent: AgentId;
592
+ harnessName?: string;
593
+ /** The version being launched, or undefined when none could be resolved. */
594
+ version?: string;
595
+ strategy?: RunStrategy;
596
+ /**
597
+ * Whether the launched version is launchable-signed-in on THIS device
598
+ * ({@link isVersionLaunchableHere}). `null` when the verdict is unknown — a
599
+ * missing verdict must NOT be read as logged out.
600
+ */
601
+ signedIn: boolean | null;
602
+ /** Account email of the version home when signed in, else null. */
603
+ email: string | null;
604
+ /** How the version was resolved (explicit-pin / rotated / pinned-default). */
605
+ resolvedVia?: string;
606
+ }
607
+ /**
608
+ * Build the `run.launch` event payload. Pure and exported so the signedIn ->
609
+ * launchedLoggedOut mapping is unit-testable without spawning. Mirrors the
610
+ * buildRotationDecisionEvent / emitRotationDecision split in rotate.ts.
611
+ *
612
+ * `launchedLoggedOut` is the headline flag — true ONLY when `signedIn` was
613
+ * resolved to false (a launch into a logged-out version, the yosemite-m3 2.1.219
614
+ * incident). An UNKNOWN verdict (`signedIn === null`) is never treated as
615
+ * logged out.
616
+ */
617
+ export declare function buildRunLaunchPayload(input: RunLaunchInput): EventPayload;
553
618
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
554
619
  export declare const BUDGET_KILL_EXIT_CODE = 7;
555
620
  /**