@phnx-labs/agents-cli 1.22.101 → 1.22.103

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 (36) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/bootstrap.js +15 -0
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/menubar.js +4 -1
  5. package/dist/commands/ssh.js +12 -4
  6. package/dist/commands/view.d.ts +0 -2
  7. package/dist/commands/view.js +9 -28
  8. package/dist/lib/account-catalog.d.ts +24 -0
  9. package/dist/lib/account-catalog.js +45 -15
  10. package/dist/lib/accounting/usage.d.ts +7 -0
  11. package/dist/lib/accounting/usage.js +25 -0
  12. package/dist/lib/auth-health.d.ts +17 -4
  13. package/dist/lib/auth-health.js +60 -22
  14. package/dist/lib/auto-pull-worker.js +38 -1
  15. package/dist/lib/devices/harness-inventory.d.ts +4 -0
  16. package/dist/lib/devices/harness-inventory.js +3 -1
  17. package/dist/lib/feed/pr-status.d.ts +18 -0
  18. package/dist/lib/feed/pr-status.js +43 -5
  19. package/dist/lib/feed/watch.d.ts +4 -1
  20. package/dist/lib/feed/watch.js +57 -25
  21. package/dist/lib/helper-versions.js +1 -1
  22. package/dist/lib/menubar/install-menubar.d.ts +85 -0
  23. package/dist/lib/menubar/install-menubar.js +171 -6
  24. package/dist/lib/menubar/resolve-version.d.ts +82 -0
  25. package/dist/lib/menubar/resolve-version.js +133 -0
  26. package/dist/lib/profiles.js +13 -2
  27. package/dist/lib/secrets-client.d.ts +20 -0
  28. package/dist/lib/secrets-client.js +42 -19
  29. package/dist/lib/self-heal/checks/menubar-helper.d.ts +2 -0
  30. package/dist/lib/self-heal/checks/menubar-helper.js +21 -0
  31. package/dist/lib/self-heal/registry.js +3 -0
  32. package/dist/lib/self-heal/types.d.ts +1 -1
  33. package/dist/lib/session/active.d.ts +1 -1
  34. package/dist/lib/session/active.js +7 -0
  35. package/dist/lib/session/state.d.ts +7 -0
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.103
4
+
5
+ - **`agents run claude` no longer dies with `secrets request failed: spawnSync sh ETIMEDOUT` on a loaded machine (PHNX-4078).** Every synchronous secrets request spawns a fresh `secrets __serve` process, so its bound has to cover a cold Node boot of the standalone, not just the tens-of-milliseconds operation. The bound was 3 s; on a desktop at load average ~100 each spawn measured 0.4–2.6 s and sometimes more, so the account listing on the launch path timed out and the run aborted before Claude started (also why AGI EXT tabs opened to a bare shell there). The sync bound is now 30 s — enough to boot under load, still well under the standalone's own 60 s self-deadline — and a timeout names the cause ("the standalone secrets CLI did not answer within 30s … check with secrets --version") instead of the raw `spawnSync` errno. Source: `cli/src/lib/secrets-client.ts`, `cli/src/lib/secrets-client.test.ts`, `cli/docs/secrets-client.md`.
6
+
7
+ ## 1.22.102
8
+
9
+ - **AGI Menu updates itself on npm-installed Macs (PHNX-4036).** The npm tarball ships no helper bundle, and the startup self-heal is network-free by design, so an `npm i -g` Mac kept whatever helper it had — the crashing 1.1.2, a dead 0.1.0 — until someone ran `agents menubar setup` by hand. The detached background sync now downloads and verifies the floor release into the helper cache whenever the installed helper is behind it (or no service exists yet and the user has not opted out), and the self-heal treats that cached bundle as a source, so the next `agents` invocation installs it with no foreground network. `agents menubar status` names the pending download instead of "missing (cannot enable)". Source: `cli/src/lib/menubar/install-menubar.ts`, `cli/src/lib/auto-pull-worker.ts`.
10
+
11
+ - **`agents view` STATE is honest and WHERE reads like English (PHNX-3940, PHNX-4051).** A probe 429 no longer marks the account LIMITED — the probe keeps the previous verdict within 20 min, otherwise `unverified` with `probe throttled (HTTP 429)`; the account's real throttle is the usage snapshot (`deriveUsageStatusFromSnapshot` / `applyUsageHonesty`). Fleet merge no longer lets a remote `rate_limited` override a fresh local `available` read (`stale:false`) — remote `revoked`/`expired` still win and rows stay in `--json`. The daemon probes each account identity once per tick (version home + slot collapse by `accountId`) and spaces probes ~150 ms so one box no longer fires 16 requests in 4 s. WHERE now reads `this box`, `on N boxes`, or `on N of M boxes` instead of `+N`; a gray footer legend explains STATE values and `*` stale. Source: `cli/src/lib/auth-health.ts`, `cli/src/lib/account-catalog.ts`, `cli/src/commands/view.ts`, `cli/src/commands/accounts.ts`, `cli/docs/credential-management.md`.
12
+
13
+ - **Feed rows carry their PR status.** `agents feed watch --json` now attaches the `gh pr view` status it already fetches for attention to each agent row's `pr` (`state`, `isDraft`, `reviewDecision`, `mergeable`, and a one-word `checks` verdict), and re-emits the row when that status changes, so AGI Menu and the extension can color a PR chip merged / open / failing without their own `gh` calls. Source: `cli/src/lib/feed/watch.ts`, `cli/src/lib/feed/pr-status.ts`.
14
+
15
+ - **Restore per-window usage bars in `agents view <agent>` account rows (PHNX-3940).** Account rows now render every blocking window side by side (`S: ███░░ 58% (3d) W: ██░░░ 41% (5d)`) via the canonical `formatUsageSummary` / `pickCompactUsageWindows` path instead of a single max-percent bar. The overview caps at 2 windows and single-harness views show all blocking windows; provider rows with no windows keep their current rendering. Source: `cli/src/lib/account-catalog.ts`, `cli/src/lib/devices/harness-inventory.ts`, `cli/src/commands/view.ts`.
16
+
3
17
  ## 1.22.101
4
18
 
5
19
  - **`agents sync status` names what drifted, and a merged system-layer skill or subagent reaches the next `agents run` on every box (PHNX-4056).** The status readout said `11 drifted` per install and nothing more, although `AgentVersionStatus.resources` already carried the rows; both human renderers (`agents sync status`, the drift summary `agents sync` prints) now list each drifted or missing resource as `drifted skills/artifacts` under its install line, with the differ's detail when it has one. And when `~/.agents/.system/skills` or `subagents` changed since the last launch of an (agent, version), `agents run` rewrites those resources into the version home through the registered writers before spawning, filtered by the active resource profile like the full sync, with the same stat-tier sentinel shape as the rules run-sync: on 2026-09-10 a merged skill change left 31 of 33 installed copies stale until the 4-hour umbrella autosync, because the shim's `--launch` path mirrors project resources only. Source: `cli/src/lib/system-run-sync.ts`, `cli/src/lib/sync-status.ts`, `cli/src/commands/status.ts`, `cli/src/lib/drift-sync.ts`, `cli/src/lib/exec.ts`, `cli/src/commands/exec.ts`.
package/dist/bootstrap.js CHANGED
@@ -385,6 +385,21 @@ async function installResolvedPackage(metadata) {
385
385
  // The macOS Keychain helper this used to force-refresh on upgrade moved with
386
386
  // the standalone `secrets` engine (PHNX-3989) — it downloads and verifies its
387
387
  // own helper release now, off this CLI's upgrade path entirely.
388
+ //
389
+ // The menu-bar helper still rides this path: an installed release build moves
390
+ // to the newest published one (verified download, atomic swap at the same
391
+ // path and identity so the Accessibility grant survives, restart). The new
392
+ // package is on disk, so the import resolves the fresh module. Best-effort;
393
+ // the daemon's self-heal tick repeats it every six hours.
394
+ if (process.platform === 'darwin') {
395
+ try {
396
+ const { updateMenubarHelperIfNewer } = await import('./lib/menubar/install-menubar.js');
397
+ await updateMenubarHelperIfNewer({ force: true });
398
+ }
399
+ catch {
400
+ // Non-fatal.
401
+ }
402
+ }
388
403
  }
389
404
  /** Present an interactive upgrade prompt (TTY) or a one-line hint (non-TTY). */
390
405
  async function promptUpgrade(latestVersion) {
@@ -554,7 +554,7 @@ export function registerAccountsCommand(program) {
554
554
  agents accounts list claude
555
555
  agents accounts list --json
556
556
  agents accounts list --fleet`,
557
- notes: 'One row per account per harness. STATE is the daemon verdict. WHERE is `this box` when only this device reports (peers on an older release publish no slot verdicts), else live-device coverage. USAGE is the bar + percent. FIX is the exact repair command — empty when there is none. Reserved credential stores are not listed here; `agents secrets` is the place those show.',
557
+ notes: 'One row per account per harness. STATE is the usage snapshot (`deriveUsageStatusFromSnapshot`/`applyUsageHonesty`, never a probe 429 — a throttled probe keeps the previous verdict within 20 min, otherwise `unverified` with `probe throttled (HTTP 429)`; `LIMITED` only when the local usage snapshot is `rate_limited`/`out_of_credits`, remote `rate_limited` only when no local snapshot). WHERE is `this box` when only this device reports; otherwise `on N boxes` when every provisioned device is usable (`live`/`rate_limited`/`unverified`), `on N of M boxes` when some are not, `—` when nothing is provisioned; per-device accounts list present devices. USAGE is the bar + percent (`*` stale). FIX is the exact repair command — empty when there is none. Reserved credential stores are not listed here; `agents secrets` is the place those show.',
558
558
  });
559
559
  registerMintCommand(accounts, undefined, { hidden: true });
560
560
  const addCmd = accounts.command('add <target> [name]')
@@ -30,7 +30,10 @@ function printStatus(s, opts = {}) {
30
30
  console.log(` helper installed ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
31
31
  console.log(` helper available ${chalk.gray(s.currentVersion)}`);
32
32
  console.log(` CLI version ${chalk.gray(s.cliVersion)}`);
33
- console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
33
+ const pendingSource = s.disabledByUser
34
+ ? chalk.gray('none (disabled by user — `agents menubar enable` fetches it)')
35
+ : chalk.yellow(`not fetched yet — the background sync downloads ${s.currentVersion}; \`agents menubar setup\` fetches it now`);
36
+ console.log(` bundle source ${s.source ? chalk.gray(s.source) : pendingSource}`);
34
37
  console.log(` disabled by user ${yn(s.disabledByUser)}`);
35
38
  // Two copies of the INSTALLED bundle is the duplicate the user sees as two
36
39
  // agents marks in the menu bar. It used to read as a healthy `running: yes`.
@@ -53,6 +53,7 @@ import { stringWidth, stripAnsi, terminalWidth, truncateToWidth } from '../lib/s
53
53
  import { sshExecAsync } from '../lib/ssh-exec.js';
54
54
  import { ALL_AGENT_IDS } from '../lib/agents.js';
55
55
  import { collectLocalHarnessInventory, groupByAccount, renderAccountsMatrix, renderHarnessMatrix, } from '../lib/devices/harness-inventory.js';
56
+ import { usageErrorForDisplay } from '../lib/accounting/usage.js';
56
57
  import { crabboxList, crabboxFind, crabboxSshArgv } from '../lib/crabbox/cli.js';
57
58
  import { boxAddress, boxStatus, fmtIdleShort, fmtExpiresShort, registerLeaseCommand } from './lease.js';
58
59
  import { registerSnapshotCommand } from './snapshot.js';
@@ -934,16 +935,23 @@ export async function collectFleetHarnesses(opts) {
934
935
  async function runDevicesHarnesses(opts) {
935
936
  if (opts.local) {
936
937
  const rows = await collectLocalHarnessInventory({ agents: opts.agents, refresh: opts.refresh });
937
- if (opts.json)
938
- console.log(JSON.stringify({ host: machineId(), rows }));
938
+ if (opts.json) {
939
+ const sanitized = rows.map((row) => ({ ...row, usageError: usageErrorForDisplay(row.usageError) }));
940
+ console.log(JSON.stringify({ host: machineId(), rows: sanitized }));
941
+ }
939
942
  else
940
943
  for (const line of renderHarnessMatrix([{ host: machineId(), rows }]))
941
944
  console.log(line);
942
945
  return;
943
946
  }
944
947
  const results = await collectFleetHarnesses(opts);
945
- if (opts.json)
946
- console.log(JSON.stringify(results, null, 2));
948
+ if (opts.json) {
949
+ const sanitized = results.map((result) => ({
950
+ ...result,
951
+ rows: result.rows.map((row) => ({ ...row, usageError: usageErrorForDisplay(row.usageError) })),
952
+ }));
953
+ console.log(JSON.stringify(sanitized, null, 2));
954
+ }
947
955
  else
948
956
  for (const line of renderHarnessMatrix(results))
949
957
  console.log(line);
@@ -2,14 +2,12 @@ import type { Command } from 'commander';
2
2
  import { accountDisplayLabel } from '../lib/agents.js';
3
3
  import type { AccountInfo } from '../lib/agents.js';
4
4
  import type { AgentId } from '../lib/types.js';
5
- import type { FormatUsageSummaryOpts, UsageInfo } from '../lib/accounting/usage.js';
6
5
  import { type ConfiguredDeviceRole } from '../lib/device-config.js';
7
6
  import { type NativeAccountCatalogRow } from '../lib/account-catalog.js';
8
7
  import type { ResourceSection, ViewJsonAgent } from '../lib/view-types.js';
9
8
  export type { ResourceItemJson, ResourceSection, SyncState, VersionResourcesJson, ViewJsonAgent, ViewJsonVersion } from '../lib/view-types.js';
10
9
  import { type ProfileSummary } from '../lib/profiles.js';
11
10
  import { type ByokUsageResult } from '../lib/byok-usage.js';
12
- export declare function viewUsageSummaryOptions(agentId: AgentId, signedIn: boolean, usageInfo: UsageInfo | undefined, maxWindows: number | undefined, version?: string): FormatUsageSummaryOpts;
13
11
  /** Shared account identity formatter, re-exported for the view-specific tests. */
14
12
  export declare const accountColumnLabel: typeof accountDisplayLabel;
15
13
  /**
@@ -17,7 +17,8 @@ import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanag
17
17
  import { ambientClaudeToken, loginHint } from '../lib/signin-badge.js';
18
18
  import { machineId } from '../lib/machine-id.js';
19
19
  import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
20
- import { agentReportsUsage, classifyUsageErrorKind, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageBenignState, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, isUsageHeadlessScopeError, usageErrorForDisplay, } from '../lib/accounting/usage.js';
20
+ import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, usageErrorForDisplay, viewUsageSummaryOptions, } from '../lib/accounting/usage.js';
21
+ import { OVERVIEW_MAX_USAGE_WINDOWS } from '../lib/account-catalog.js';
21
22
  import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../lib/device-config.js';
22
23
  import { readManifest } from '../lib/manifest.js';
23
24
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, setGlobalDefault, getVersionHomePath, getVersionDir, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/installations/versions.js';
@@ -47,25 +48,6 @@ import { renderHarnessDetail } from './harness.js';
47
48
  import { confirm } from '@inquirer/prompts';
48
49
  import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js';
49
50
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
50
- export function viewUsageSummaryOptions(agentId, signedIn, usageInfo, maxWindows, version) {
51
- const headless = isUsageHeadlessScopeError(usageInfo?.error);
52
- const benignState = usageInfo ? getUsageBenignState(usageInfo) : null;
53
- return {
54
- unavailable: agentReportsUsage(agentId) && signedIn && !usageInfo?.snapshot && !headless && !benignState,
55
- unverified: !headless && !!usageInfo?.snapshot && !!usageInfo.error,
56
- headless,
57
- maxWindows,
58
- expectedWindows: agentId === 'claude'
59
- ? [{ key: 'session', shortLabel: 'S' }, { key: 'week', shortLabel: 'W' }]
60
- : undefined,
61
- errorKind: classifyUsageErrorKind(usageInfo?.error),
62
- errorDetail: usageInfo?.error ?? null,
63
- benignState,
64
- noRecentUsageLabel: agentId === 'grok'
65
- ? `run grok${version ? `@${version}` : ''} once to refresh usage`
66
- : null,
67
- };
68
- }
69
51
  /** Shared account identity formatter, re-exported for the view-specific tests. */
70
52
  export const accountColumnLabel = accountDisplayLabel;
71
53
  /**
@@ -107,13 +89,6 @@ export function compareAccountOrderedVersions(a, b, globalDefault) {
107
89
  }
108
90
  return compareVersions(b.version, a.version);
109
91
  }
110
- /**
111
- * Overview (`agents view` with no agent filter) caps compact usage windows so
112
- * multi-meter agents (Antigravity's four model quotas, Droid's three buckets)
113
- * cannot force every row to pad past the terminal width and wrap. Single-agent
114
- * views leave the cap unset and show every blocking window.
115
- */
116
- const OVERVIEW_MAX_USAGE_WINDOWS = 2;
117
92
  /**
118
93
  * Join fixed view columns with a consistent two-space gutter. Empty trailing
119
94
  * columns are dropped so a row without an auth chip does not grow a dangling
@@ -561,6 +536,7 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
561
536
  harnessHeadings: false,
562
537
  providers,
563
538
  harness: agentId,
539
+ maxUsageWindows: usageWindowCap,
564
540
  localDevice: machineId(),
565
541
  }));
566
542
  }
@@ -580,7 +556,12 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
580
556
  console.log();
581
557
  }
582
558
  if (filterAgentId) {
583
- console.log(chalk.gray(` Add an account: agents accounts add ${filterAgentId} [name]\n`));
559
+ console.log(chalk.gray(` Add an account: agents accounts add ${filterAgentId} [name]`));
560
+ console.log(chalk.gray(' STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage\n'));
561
+ }
562
+ else {
563
+ // Overview also renders account tables (footer:false) — explain STATE there too.
564
+ console.log(chalk.gray(' STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage\n'));
584
565
  }
585
566
  }
586
567
  if (versionManaged.length > 0 && viewOpts?.versions) {
@@ -4,6 +4,7 @@ import type { AgentId, Meta } from './types.js';
4
4
  import { type AuthVerdict } from './auth-health.js';
5
5
  import { applyUsageHonesty, type QuotaSummary } from './devices/harness-inventory.js';
6
6
  import { type AccountProvisioning, type AccountVerdict } from './signin-badge.js';
7
+ import type { UsageSnapshot } from './accounting/usage.js';
7
8
  export { applyUsageHonesty };
8
9
  /**
9
10
  * Strict "is this home actually connected?" — a live CREDENTIAL in that exact
@@ -78,6 +79,17 @@ export interface NativeAccountCatalogRow {
78
79
  checkedAt: string | null;
79
80
  devices: AccountDeviceVerdict[];
80
81
  usage: QuotaSummary | null;
82
+ /**
83
+ * The live usage snapshot backing `usage`, or null when none was collected.
84
+ * Carried alongside the collapsed `QuotaSummary` so the per-window bars
85
+ * (S: session 5h, W: week 7d, etc.) can render via the canonical
86
+ * `formatUsageSummary` / `pickCompactUsageWindows` path instead of the
87
+ * single max-percent bar. Added non-breaking: JSON clients keep `usage`
88
+ * and may read this when present.
89
+ */
90
+ usageSnapshot?: UsageSnapshot | null;
91
+ /** Raw usage fetch error, for headless/unverified labeling alongside the bars. */
92
+ usageError?: string | null;
81
93
  fix: string | null;
82
94
  }
83
95
  export interface AccountDeviceVerdict {
@@ -141,6 +153,9 @@ export interface AccountListEntryJson {
141
153
  verdict: AccountDeviceVerdict['verdict'];
142
154
  }>;
143
155
  usage: QuotaSummary | null;
156
+ /** The live usage snapshot backing `usage`, when available — additive, not breaking. */
157
+ usageSnapshot?: UsageSnapshot | null;
158
+ usageError?: string | null;
144
159
  fix: string | null;
145
160
  }
146
161
  export interface AccountListJson {
@@ -186,6 +201,8 @@ export declare function loadAccountCatalog(): Promise<AccountCatalog>;
186
201
  */
187
202
  export declare function secretsUnavailableNote(catalog: Pick<AccountCatalog, 'secretsUnavailable'>): string | null;
188
203
  export declare function accountListJson(native: NativeAccountCatalogRow[], providers?: ProviderAccountCatalogRow[], harness?: AgentId): AccountListJson;
204
+ export declare function whereText(row: NativeAccountCatalogRow, localDevice: string): string;
205
+ export declare const OVERVIEW_MAX_USAGE_WINDOWS = 2;
189
206
  /** Shared text renderer used by both `accounts list` and account-first `view`. */
190
207
  export declare function renderAccountRows(rows: NativeAccountCatalogRow[], opts: {
191
208
  heading?: boolean;
@@ -194,6 +211,12 @@ export declare function renderAccountRows(rows: NativeAccountCatalogRow[], opts:
194
211
  providers?: ProviderAccountCatalogRow[];
195
212
  /** When set, emit only this harness's group — never a sibling or empty group. */
196
213
  harness?: AgentId;
214
+ /**
215
+ * Cap for compact usage windows — overview (no harness filter) passes 2,
216
+ * single-harness view leaves it undefined to show all blocking windows.
217
+ * Mirrors `OVERVIEW_MAX_USAGE_WINDOWS` in view.ts.
218
+ */
219
+ maxUsageWindows?: number;
197
220
  /**
198
221
  * Device name treated as "this box" in WHERE when it is the only reporter.
199
222
  * The command layer passes `machineId()`; this renderer never resolves it.
@@ -227,3 +250,4 @@ export declare function resolveLocalAccountObservation(slot: LocalSlotObservatio
227
250
  authMode?: AccountDeviceVerdict['authMode'];
228
251
  checkedAt?: string;
229
252
  };
253
+ export declare function aggregateAccountVerdict(provisioning: AccountProvisioning, devices: AccountDeviceVerdict[], localQuota?: QuotaSummary | null): AccountVerdict;
@@ -14,7 +14,7 @@ import { machineId } from './machine-id.js';
14
14
  import { isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
15
15
  import { applyUsageHonesty, collectLocalHarnessInventory, } from './devices/harness-inventory.js';
16
16
  import { fixFor, } from './signin-badge.js';
17
- import { renderBar } from './accounting/usage.js';
17
+ import { formatUsageSummary, renderBar, usageErrorForDisplay, viewUsageSummaryOptions, } from './accounting/usage.js';
18
18
  import { padToWidth, stringWidth } from './text/width.js';
19
19
  export { applyUsageHonesty };
20
20
  import chalk from 'chalk';
@@ -184,6 +184,8 @@ export function buildNativeCatalog(rows, meta, globalDefault = getGlobalDefault)
184
184
  checkedAt: null,
185
185
  devices: [],
186
186
  usage: null,
187
+ usageSnapshot: null,
188
+ usageError: null,
187
189
  fix: fixFor({
188
190
  agent: group.agent,
189
191
  verdict: signedIn ? 'unverified' : 'missing',
@@ -275,11 +277,15 @@ export async function loadAccountCatalog() {
275
277
  ? (shared.get(`${row.agent}:${row.id}`) ?? [])
276
278
  : (shared.get(`label:${row.agent}:${row.identityLabel}`) ?? []);
277
279
  row.devices = mergeDeviceVerdicts(fromFleet, local);
278
- row.verdict = aggregateAccountVerdict(row.provisioning, row.devices);
280
+ const localQuota = localHome ? (inventoryByHome.get(`${row.agent}:${localHome.label}`)?.quota ?? null) : null;
281
+ row.verdict = aggregateAccountVerdict(row.provisioning, row.devices, localQuota);
279
282
  row.checkedAt = newestCheckedAt(row.devices);
280
- const honest = applyUsageHonesty(row.verdict, localHome ? (inventoryByHome.get(`${row.agent}:${localHome.label}`)?.quota ?? null) : null);
283
+ const honest = applyUsageHonesty(row.verdict, localQuota);
281
284
  row.verdict = honest.verdict;
282
285
  row.usage = honest.usage;
286
+ const inv = localHome ? inventoryByHome.get(`${row.agent}:${localHome.label}`) : undefined;
287
+ row.usageSnapshot = inv?.snapshot ?? null;
288
+ row.usageError = inv?.usageError ?? null;
283
289
  row.fix = fixFor({
284
290
  agent: row.agent,
285
291
  verdict: row.verdict,
@@ -353,6 +359,8 @@ export function accountListJson(native, providers = [], harness) {
353
359
  checkedAt: row.checkedAt,
354
360
  devices: row.devices.map(({ device, authMode, verdict }) => ({ device, authMode, verdict })),
355
361
  usage: row.usage,
362
+ ...(row.usageSnapshot ? { usageSnapshot: row.usageSnapshot } : {}),
363
+ ...((() => { const v = usageErrorForDisplay(row.usageError); return v ? { usageError: v } : {}; })()),
356
364
  fix: row.fix,
357
365
  })),
358
366
  ...providers.flatMap((row) => providerJsonEntries(row, harness)),
@@ -364,24 +372,35 @@ function verdictText(verdict) {
364
372
  return 'LIMITED';
365
373
  return verdict.toUpperCase();
366
374
  }
367
- function whereText(row, localDevice) {
375
+ export function whereText(row, localDevice) {
368
376
  const names = [...new Set(row.devices.map((device) => device.device))];
369
377
  // Peers on an older release do not publish slot verdicts, so the only
370
- // observation is this box — say so rather than a coverage count of +1.
378
+ // observation is this box — say so rather than a coverage count.
371
379
  if (names.length === 1 && names[0] === localDevice)
372
380
  return 'this box';
373
- const live = row.devices.filter((device) => device.verdict === 'live').length;
374
- if (live > 0)
375
- return `+${live}`;
381
+ // Per-device accounts are provisioned per box; show which boxes are present
382
+ // rather than a live/total fraction — the fraction would hide that each box
383
+ // is its own account.
376
384
  if (row.provisioning === 'per-device') {
377
385
  const present = row.devices
378
386
  .filter((device) => device.verdict !== 'missing')
379
387
  .map((device) => device.device);
380
388
  return present.join(', ') || 'this box';
381
389
  }
382
- return '—';
390
+ const provisioned = row.devices.filter((device) => device.verdict !== 'missing').length;
391
+ if (provisioned === 0)
392
+ return '—';
393
+ const usable = row.devices.filter((device) => device.verdict === 'live' || device.verdict === 'rate_limited' || device.verdict === 'unverified').length;
394
+ if (usable === provisioned)
395
+ return `on ${usable} ${usable === 1 ? 'box' : 'boxes'}`;
396
+ return `on ${usable} of ${provisioned} boxes`;
383
397
  }
384
- function usageText(row) {
398
+ export const OVERVIEW_MAX_USAGE_WINDOWS = 2;
399
+ function usageText(row, maxWindows) {
400
+ if (row.usageSnapshot) {
401
+ const usageInfo = { snapshot: row.usageSnapshot, error: row.usageError ?? null };
402
+ return formatUsageSummary(null, usageInfo.snapshot, 3, viewUsageSummaryOptions(row.agent, row.state === 'connected', usageInfo, maxWindows));
403
+ }
385
404
  if (row.usage?.status === 'rate_limited' && (row.usage.usedPercent === null || row.usage.usedPercent === undefined)) {
386
405
  return 'limited';
387
406
  }
@@ -392,14 +411,14 @@ function usageText(row) {
392
411
  const percent = `${row.usage.usedPercent}%${row.usage.stale ? '*' : ''}`;
393
412
  return `${renderBar(row.usage.usedPercent, LISTING_USAGE_BAR_LEN)} ${percent}`;
394
413
  }
395
- function nativeLine(row, localDevice) {
414
+ function nativeLine(row, localDevice, maxWindows) {
396
415
  return {
397
416
  name: row.name ?? 'unnamed',
398
417
  identityLabel: row.identityLabel,
399
418
  verdict: row.verdict,
400
419
  where: whereText(row, localDevice),
401
420
  fix: row.fix,
402
- usage: usageText(row),
421
+ usage: usageText(row, maxWindows),
403
422
  isDefault: row.isDefault,
404
423
  };
405
424
  }
@@ -465,10 +484,11 @@ export function renderAccountRows(rows, opts) {
465
484
  harnesses.add(agent);
466
485
  }
467
486
  }
487
+ const maxWindows = opts.maxUsageWindows ?? (opts.harness ? undefined : OVERVIEW_MAX_USAGE_WINDOWS);
468
488
  const grouped = new Map();
469
489
  for (const harness of [...harnesses].sort((a, b) => a.localeCompare(b))) {
470
490
  const lines = [
471
- ...visibleNative.filter((row) => row.agent === harness).map((row) => nativeLine(row, localDevice)),
491
+ ...visibleNative.filter((row) => row.agent === harness).map((row) => nativeLine(row, localDevice, maxWindows)),
472
492
  ...visibleProviders.filter((row) => row.harnesses.includes(harness)).map((row) => providerLine(row, harness)),
473
493
  ];
474
494
  if (lines.length === 0)
@@ -499,6 +519,7 @@ export function renderAccountRows(rows, opts) {
499
519
  const count = visibleNative.filter((row) => !!row.fix).length
500
520
  + visibleProviders.filter((row) => !!row.fix).length;
501
521
  out.push(chalk.gray(`${count} accounts need you · add: agents accounts add <harness>`));
522
+ out.push(chalk.gray('STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage'));
502
523
  }
503
524
  return out.join('\n').trimEnd();
504
525
  }
@@ -574,13 +595,22 @@ function mergeDeviceVerdicts(fleet, local) {
574
595
  byDevice.set(local.device, local);
575
596
  return [...byDevice.values()].sort((a, b) => a.device.localeCompare(b.device));
576
597
  }
577
- function aggregateAccountVerdict(provisioning, devices) {
598
+ export function aggregateAccountVerdict(provisioning, devices, localQuota) {
578
599
  const verdicts = devices.map((row) => row.verdict);
579
600
  if (verdicts.includes('revoked'))
580
601
  return 'revoked';
581
602
  if (verdicts.includes('expired'))
582
603
  return 'expired';
583
- if (verdicts.includes('rate_limited'))
604
+ // LIMITED comes from the usage snapshot only (PHNX-3940/4051): an account's
605
+ // quota is one shared identity and `deriveUsageStatusFromSnapshot` already
606
+ // applies the freshness gate (expired windows -> staleWindows), so a
607
+ // `last_seen` snapshot whose blocking windows are below 100% is trustworthy
608
+ // for "not throttled". A remote `rate_limited` (probe 429) never sets STATE;
609
+ // only the local `QuotaSummary.status` (`rate_limited`/`out_of_credits` via
610
+ // `applyUsageHonesty`) does. When this box has no usage snapshot at all
611
+ // (no windows, `usedPercent === null`), the remote `rate_limited` may stand.
612
+ const hasLocalSnapshot = !!localQuota && localQuota.usedPercent !== null && localQuota.usedPercent !== undefined;
613
+ if (!hasLocalSnapshot && verdicts.includes('rate_limited'))
584
614
  return 'rate_limited';
585
615
  if (verdicts.includes('live'))
586
616
  return 'live';
@@ -447,6 +447,13 @@ export interface FormatUsageSummaryOpts {
447
447
  /** Provider-specific replacement for the generic no-local-event marker. */
448
448
  noRecentUsageLabel?: string | null;
449
449
  }
450
+ /**
451
+ * Shared builder for {@link formatUsageSummary} options in `agents view` and
452
+ * account-catalog rows. One builder, no copy — captures `headless`,
453
+ * `unverified`, `expectedWindows`, `errorKind`, `benignState`, and the grok
454
+ * `noRecentUsageLabel` consistently.
455
+ */
456
+ export declare function viewUsageSummaryOptions(agentId: AgentId, signedIn: boolean, usageInfo: UsageInfo | undefined, maxWindows: number | undefined, version?: string): FormatUsageSummaryOpts;
450
457
  /** Format a one-line usage summary with compact bars for inline display. */
451
458
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number, opts?: FormatUsageSummaryOpts): string;
452
459
  /**
@@ -559,6 +559,31 @@ export function pickCompactUsageWindows(windows, maxWindows) {
559
559
  }
560
560
  return chosen;
561
561
  }
562
+ /**
563
+ * Shared builder for {@link formatUsageSummary} options in `agents view` and
564
+ * account-catalog rows. One builder, no copy — captures `headless`,
565
+ * `unverified`, `expectedWindows`, `errorKind`, `benignState`, and the grok
566
+ * `noRecentUsageLabel` consistently.
567
+ */
568
+ export function viewUsageSummaryOptions(agentId, signedIn, usageInfo, maxWindows, version) {
569
+ const headless = isUsageHeadlessScopeError(usageInfo?.error);
570
+ const benignState = usageInfo ? getUsageBenignState(usageInfo) : null;
571
+ return {
572
+ unavailable: agentReportsUsage(agentId) && signedIn && !usageInfo?.snapshot && !headless && !benignState,
573
+ unverified: !headless && !!usageInfo?.snapshot && !!usageInfo.error,
574
+ headless,
575
+ maxWindows,
576
+ expectedWindows: agentId === 'claude'
577
+ ? [{ key: 'session', shortLabel: 'S' }, { key: 'week', shortLabel: 'W' }]
578
+ : undefined,
579
+ errorKind: classifyUsageErrorKind(usageInfo?.error),
580
+ errorDetail: usageInfo?.error ?? null,
581
+ benignState,
582
+ noRecentUsageLabel: agentId === 'grok'
583
+ ? `run grok${version ? `@${version}` : ''} once to refresh usage`
584
+ : null,
585
+ };
586
+ }
562
587
  /** Human label for a classified usage error, for the no-bars branch of {@link formatUsageSummary}. */
563
588
  function formatUsageErrorKindLabel(kind, detail) {
564
589
  switch (kind) {
@@ -7,11 +7,18 @@ import { type ProviderProbe } from './accounting/usage.js';
7
7
  * setup-token `user:profile` scope denials (those are
8
8
  * `unverified` via `reason: 'usage_scope'` — RUSH-2392).
9
9
  * - `expired` — locally-detected expiry; not network-verified (no refresh on the read path).
10
- * - `rate_limited`— token works but is throttled right now (429).
10
+ * - `rate_limited`— the account is throttled per its usage snapshot
11
+ * ({@link deriveUsageStatusFromSnapshot} / {@link applyUsageHonesty}),
12
+ * not per a probe HTTP 429. A 429 from the usage endpoint is
13
+ * probe throttling and never produces `rate_limited`: it keeps
14
+ * the previous real verdict when one exists within
15
+ * {@link AUTH_PROBE_MAX_AGE_MS}, otherwise `unverified` with
16
+ * detail `probe throttled (HTTP 429)` (PHNX-4051).
11
17
  * - `unverified` — credential present, not locally expired, but this agent has
12
18
  * no in-repo probe endpoint (codex/grok), OR the probe
13
19
  * endpoint cannot prove live for a known non-revocation
14
- * reason (Claude setup-token usage-scope gap — RUSH-2392).
20
+ * reason (Claude setup-token usage-scope gap — RUSH-2392),
21
+ * OR a throttled probe with no fresh previous verdict (PHNX-4051).
15
22
  * - `unconfigured`— no usable credential on disk.
16
23
  * - `error` — network/other failure; verdict indeterminate (keep the last known one).
17
24
  */
@@ -31,7 +38,7 @@ export interface AuthHealth {
31
38
  export declare const AUTH_PROBE_MAX_AGE_MS: number;
32
39
  /** Agents with a live network probe wired up today. The rest are best-effort. */
33
40
  export declare const LIVE_PROBE_AGENTS: ReadonlySet<AgentId>;
34
- /** Map an HTTP status from a live probe to a verdict. */
41
+ /** Map an HTTP status from a live probe to a verdict. Probe 429 never yields `rate_limited` (PHNX-4051). */
35
42
  export declare function classifyHttpStatus(status: number): AuthVerdict;
36
43
  /** Turn a raw provider probe (from usage.ts) into a verdict. */
37
44
  export declare function verdictFromProbe(probe: ProviderProbe): AuthVerdict;
@@ -202,7 +209,9 @@ export declare function readFleetAuthRows(host: string): AuthProbeRow[];
202
209
  * known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
203
210
  * exactly the "cry wolf" the verdict model avoids for `expired`. This is the
204
211
  * behaviour promised by the `error` doc on AuthVerdict ("keep the last known
205
- * one"). Pure, so it's unit-tested directly. */
212
+ * one"). A probe-throttled 429 (detail `probe throttled (HTTP 429)`) keeps the
213
+ * previous real verdict when one exists within {@link AUTH_PROBE_MAX_AGE_MS},
214
+ * otherwise becomes `unverified` (PHNX-4051). Pure, so it's unit-tested directly. */
206
215
  export declare function mergeAuthHealthEntries(current: Record<string, AuthHealth>, incoming: Record<string, AuthHealth>): Record<string, AuthHealth>;
207
216
  /**
208
217
  * Merge one or more entries into the cache (best-effort write).
@@ -247,6 +256,8 @@ export interface FleetAuthInstall {
247
256
  version: string;
248
257
  /** Human account label from {@link authAccountLabel}, or undefined when none resolves. */
249
258
  account: string | undefined;
259
+ /** Stable account id when known (slot or resolved via registry) — preferred dedup key. */
260
+ accountId?: string | undefined;
250
261
  }
251
262
  /**
252
263
  * A set of installs that share one provider account and MUST be probed once.
@@ -278,6 +289,8 @@ export interface FleetAuthProbeGroup<T extends FleetAuthInstall> {
278
289
  * per-install probe (also the `unconfigured` case dropped downstream). Pure: no
279
290
  * fs, no network, so the dedup decision is unit-tested directly.
280
291
  */
292
+ /** Small fixed delay between live probes so one box no longer fires 16 requests in 4s (PHNX-4051). */
293
+ export declare const AUTH_PROBE_SPACING_MS = 150;
281
294
  export declare function groupFleetAuthInstalls<T extends FleetAuthInstall>(installs: readonly T[], isMergeable?: (install: T) => boolean): FleetAuthProbeGroup<T>[];
282
295
  /**
283
296
  * Enumerate every installed (agent, version) on THIS host and return one row per