@phnx-labs/agents-cli 1.22.103 → 1.22.105

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 (68) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +2 -2
  4. package/dist/commands/computer.d.ts +100 -79
  5. package/dist/commands/computer.js +290 -830
  6. package/dist/commands/run-device-picker.d.ts +58 -0
  7. package/dist/commands/run-device-picker.js +222 -0
  8. package/dist/commands/setup-computer.js +20 -1
  9. package/dist/commands/setup-secrets.d.ts +2 -2
  10. package/dist/commands/setup-secrets.js +1 -1
  11. package/dist/commands/view.js +4 -6
  12. package/dist/lib/account-catalog.d.ts +22 -1
  13. package/dist/lib/account-catalog.js +72 -38
  14. package/dist/lib/accounting/usage.js +18 -14
  15. package/dist/lib/agent-spec/agents.d.ts +1 -0
  16. package/dist/lib/agent-spec/agents.js +1 -1
  17. package/dist/lib/browser/drivers/ssh.js +1 -1
  18. package/dist/lib/computer/context.d.ts +83 -0
  19. package/dist/lib/computer/context.js +91 -0
  20. package/dist/lib/computer/policy.d.ts +46 -0
  21. package/dist/lib/computer/policy.js +160 -0
  22. package/dist/lib/computer/record.d.ts +38 -0
  23. package/dist/lib/computer/record.js +86 -0
  24. package/dist/lib/computer/sessions-list.js +6 -6
  25. package/dist/lib/computer-client.d.ts +150 -0
  26. package/dist/lib/computer-client.js +222 -0
  27. package/dist/lib/exec.js +35 -1
  28. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  29. package/dist/lib/harness/adapters/claude.js +69 -0
  30. package/dist/lib/helper-download.d.ts +1 -1
  31. package/dist/lib/helper-download.js +1 -1
  32. package/dist/lib/helper-versions.d.ts +9 -4
  33. package/dist/lib/helper-versions.js +8 -8
  34. package/dist/lib/installations/shims.js +8 -4
  35. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  36. package/dist/lib/menubar/download-menubar.js +2 -1
  37. package/dist/lib/secrets-client.d.ts +3 -3
  38. package/dist/lib/secrets-client.js +15 -38
  39. package/dist/lib/session/db.js +1 -1
  40. package/dist/lib/sha256-asset.d.ts +2 -1
  41. package/dist/lib/sha256-asset.js +2 -1
  42. package/dist/lib/ssh-tunnel.d.ts +61 -0
  43. package/dist/lib/ssh-tunnel.js +105 -0
  44. package/dist/lib/summarizer/summarize.d.ts +2 -2
  45. package/dist/lib/summarizer/summarize.js +10 -3
  46. package/package.json +2 -3
  47. package/dist/commands/computer-actions.d.ts +0 -55
  48. package/dist/commands/computer-actions.js +0 -594
  49. package/dist/computer.d.ts +0 -2
  50. package/dist/computer.js +0 -7
  51. package/dist/lib/computer/actions.d.ts +0 -36
  52. package/dist/lib/computer/actions.js +0 -162
  53. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  54. package/dist/lib/computer/computer-rpc.js +0 -447
  55. package/dist/lib/computer/des.d.ts +0 -1
  56. package/dist/lib/computer/des.js +0 -114
  57. package/dist/lib/computer/dispatch.d.ts +0 -10
  58. package/dist/lib/computer/dispatch.js +0 -133
  59. package/dist/lib/computer/download.d.ts +0 -54
  60. package/dist/lib/computer/download.js +0 -83
  61. package/dist/lib/computer/loop.d.ts +0 -62
  62. package/dist/lib/computer/loop.js +0 -98
  63. package/dist/lib/computer/model.d.ts +0 -44
  64. package/dist/lib/computer/model.js +0 -157
  65. package/dist/lib/computer/rfb-client.d.ts +0 -53
  66. package/dist/lib/computer/rfb-client.js +0 -562
  67. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  68. package/dist/lib/computer/ssh-tunnel.js +0 -584
@@ -0,0 +1,58 @@
1
+ import type { AgentId } from '../lib/types.js';
2
+ import { type Headroom } from '../lib/devices/health.js';
3
+ /** One row of the menu, already resolved from disk. Pure data so ordering is testable. */
4
+ export interface RunDeviceRow {
5
+ name: string;
6
+ platform?: string;
7
+ isLocal: boolean;
8
+ online: 'online' | 'offline' | 'unknown';
9
+ role?: string;
10
+ description?: string;
11
+ loadPercent?: number;
12
+ memPercent?: number;
13
+ headroom: Headroom;
14
+ statsFetchedAt?: number;
15
+ lastSeenAt?: string;
16
+ hasAccount?: boolean;
17
+ }
18
+ export interface RunDeviceChoice {
19
+ name: string;
20
+ value: string;
21
+ disabled?: boolean | string;
22
+ }
23
+ /**
24
+ * Pure. Ordering: this machine first; then online rows by headroom (idle,
25
+ * light, busy, loaded, unknown) then loadPercent ascending then name; then
26
+ * unknown-state rows; then offline rows, disabled with the last-seen time.
27
+ *
28
+ * `accountLabel` renders the ✓/– account mark on rows whose `hasAccount` the
29
+ * catalog answered; without it the mark is omitted (hasAccount stays on the
30
+ * row for the caller either way).
31
+ */
32
+ export declare function buildRunDeviceChoices(rows: RunDeviceRow[], accountLabel?: string): RunDeviceChoice[];
33
+ /**
34
+ * Reads devices.yaml + the cached fleet stats + configured roles + (optionally)
35
+ * whether `accountLabel` exists on each box. ZERO SSH, zero network, never
36
+ * re-probes. Returns the rows and the age of the newest stats row (undefined
37
+ * when no cache).
38
+ */
39
+ export declare function readRunDeviceRows(opts: {
40
+ agent: AgentId;
41
+ accountLabel?: string;
42
+ }): {
43
+ rows: RunDeviceRow[];
44
+ snapshotAgeMs?: number;
45
+ };
46
+ /**
47
+ * Interactive menu. Off a TTY: fail loud with the non-interactive forms,
48
+ * exactly like the account picker. Prompt message: `Select a device for this
49
+ * run (fleet state as of <age>):` where <age> is e.g. '2 min ago', or
50
+ * `Select a device for this run (no cached fleet state):`. Returns the device
51
+ * name, or null when the user cancels (Esc/Ctrl-C) — a cancel launches
52
+ * nothing. Throws when the registry has no devices at all (the message names
53
+ * `agents devices add`).
54
+ */
55
+ export declare function pickRunDevice(opts: {
56
+ agent: AgentId;
57
+ accountLabel?: string;
58
+ }): Promise<string | null>;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Device picker for `agents run <agent>@` (PHNX-4083).
3
+ *
4
+ * The menu behind the trailing-`@` form: every registered fleet device,
5
+ * rendered from files the daemon already keeps on disk — the SSH device
6
+ * registry, the cached fleet stats (`.fleet-stats.json`), the fleet-synced
7
+ * device docs (roles + descriptions), and the fleet-synced account catalog.
8
+ *
9
+ * This module NEVER probes: no SSH, no network, no re-fetch of stale rows.
10
+ * `loadFleetStats` re-probes rows older than 3 minutes over SSH, which is the
11
+ * multi-second hang a mid-run menu cannot afford; the whole point here is that
12
+ * the picker renders the last cached fleet state, honestly aged ("fleet state
13
+ * as of 2 min ago"), instead of re-deriving it live.
14
+ *
15
+ * PR 2 wires this into `agents run`; this module ships the data + menu only.
16
+ */
17
+ import { select } from '@inquirer/prompts';
18
+ import { headroom } from '../lib/devices/health.js';
19
+ import { loadDevicesSync } from '../lib/devices/registry.js';
20
+ import { readStatsCache } from '../lib/devices/stats-cache.js';
21
+ import { deviceOnlineState } from '../lib/devices/reachability.js';
22
+ import { isSelfHost } from '../lib/devices/self-host.js';
23
+ import { normalizeHost } from '../lib/machine-id.js';
24
+ import { localMachineId } from '../lib/origin-machine.js';
25
+ import { listConfiguredDeviceRoles, readDeviceConfigValues } from '../lib/device-config.js';
26
+ import { listNativeAccounts } from '../lib/account-registry.js';
27
+ import { readSharedAccountVerdicts } from '../lib/account-catalog.js';
28
+ import { readMeta } from '../lib/state.js';
29
+ import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
30
+ const HEADROOM_ORDER = {
31
+ idle: 0,
32
+ light: 1,
33
+ busy: 2,
34
+ loaded: 3,
35
+ unknown: 4,
36
+ };
37
+ function formatPercentCell(value, suffix) {
38
+ return value === undefined ? '—' : `${Math.round(value)}% ${suffix}`;
39
+ }
40
+ /** 'HH:MM' (local) from an ISO timestamp; the raw string when it does not parse. */
41
+ function formatHourMinute(iso) {
42
+ const d = new Date(iso);
43
+ if (Number.isNaN(d.getTime()))
44
+ return iso;
45
+ const hh = String(d.getHours()).padStart(2, '0');
46
+ const mm = String(d.getMinutes()).padStart(2, '0');
47
+ return `${hh}:${mm}`;
48
+ }
49
+ /**
50
+ * Pure. Ordering: this machine first; then online rows by headroom (idle,
51
+ * light, busy, loaded, unknown) then loadPercent ascending then name; then
52
+ * unknown-state rows; then offline rows, disabled with the last-seen time.
53
+ *
54
+ * `accountLabel` renders the ✓/– account mark on rows whose `hasAccount` the
55
+ * catalog answered; without it the mark is omitted (hasAccount stays on the
56
+ * row for the caller either way).
57
+ */
58
+ export function buildRunDeviceChoices(rows, accountLabel) {
59
+ const byHeadroomLoadName = (a, b) => HEADROOM_ORDER[a.headroom] - HEADROOM_ORDER[b.headroom]
60
+ || (a.loadPercent ?? Number.POSITIVE_INFINITY) - (b.loadPercent ?? Number.POSITIVE_INFINITY)
61
+ || a.name.localeCompare(b.name);
62
+ const local = rows.filter((row) => row.isLocal);
63
+ const online = rows.filter((row) => !row.isLocal && row.online === 'online').sort(byHeadroomLoadName);
64
+ const unknownState = rows.filter((row) => !row.isLocal && row.online === 'unknown').sort(byHeadroomLoadName);
65
+ const offline = rows
66
+ .filter((row) => !row.isLocal && row.online === 'offline')
67
+ .sort((a, b) => a.name.localeCompare(b.name));
68
+ const ordered = [...local, ...online, ...unknownState, ...offline];
69
+ const nameWidth = Math.max(0, ...ordered.map((row) => row.name.length));
70
+ const platformWidth = Math.max(0, ...ordered.map((row) => row.platform?.length ?? 0));
71
+ const statusWidth = Math.max(0, ...ordered.map((row) => (row.isLocal ? 'this machine' : row.online).length));
72
+ const headroomWidth = Math.max(0, ...ordered.map((row) => row.headroom.length));
73
+ const loadWidth = Math.max(0, ...ordered.map((row) => formatPercentCell(row.loadPercent, 'load').length));
74
+ const memWidth = Math.max(0, ...ordered.map((row) => formatPercentCell(row.memPercent, 'mem').length));
75
+ return ordered.map((row) => {
76
+ const account = accountLabel !== undefined && row.hasAccount !== undefined
77
+ ? `${row.hasAccount ? '✓' : '–'} ${accountLabel}`
78
+ : undefined;
79
+ const name = [
80
+ row.name.padEnd(nameWidth),
81
+ (row.platform ?? '').padEnd(platformWidth),
82
+ (row.isLocal ? 'this machine' : row.online).padEnd(statusWidth),
83
+ row.headroom.padEnd(headroomWidth),
84
+ formatPercentCell(row.loadPercent, 'load').padEnd(loadWidth),
85
+ formatPercentCell(row.memPercent, 'mem').padEnd(memWidth),
86
+ row.role,
87
+ account,
88
+ ].filter((segment) => segment !== undefined && segment !== '').join(' · ');
89
+ return {
90
+ name: name.trimEnd(),
91
+ value: row.name,
92
+ ...(row.online === 'offline'
93
+ ? { disabled: row.lastSeenAt ? `offline since ${formatHourMinute(row.lastSeenAt)}` : 'offline' }
94
+ : {}),
95
+ };
96
+ });
97
+ }
98
+ /**
99
+ * The catalog's per-device answer for one account label, straight off the
100
+ * fleet-synced shared state (the same rows `agents view` renders): the daemon
101
+ * on each box publishes an auth verdict per registered account, and absence of
102
+ * a verdict row on a publishing box means the account is not provisioned
103
+ * there. A box that publishes no account rows at all (an older release) cannot
104
+ * be answered for — its devices stay `undefined`, never guessed.
105
+ */
106
+ function resolveAccountDevices(agent, accountLabel) {
107
+ const account = listNativeAccounts(readMeta()).find((row) => row.agent === agent && (row.name === accountLabel || row.id === accountLabel));
108
+ if (!account)
109
+ return undefined; // the catalog has no such account — it cannot answer for any device
110
+ const shared = readSharedAccountVerdicts();
111
+ const publishing = new Set();
112
+ for (const rows of shared.values()) {
113
+ for (const row of rows)
114
+ publishing.add(normalizeHost(row.device));
115
+ }
116
+ const byDevice = new Map();
117
+ for (const row of shared.get(`${agent}:${account.id}`) ?? []) {
118
+ byDevice.set(normalizeHost(row.device), row.verdict !== 'missing');
119
+ }
120
+ return { byDevice, publishing };
121
+ }
122
+ /**
123
+ * Reads devices.yaml + the cached fleet stats + configured roles + (optionally)
124
+ * whether `accountLabel` exists on each box. ZERO SSH, zero network, never
125
+ * re-probes. Returns the rows and the age of the newest stats row (undefined
126
+ * when no cache).
127
+ */
128
+ export function readRunDeviceRows(opts) {
129
+ const registry = loadDevicesSync();
130
+ const statsCache = readStatsCache();
131
+ const names = Object.keys(registry);
132
+ const roles = listConfiguredDeviceRoles(names);
133
+ const local = normalizeHost(localMachineId());
134
+ const accountDevices = opts.accountLabel
135
+ ? resolveAccountDevices(opts.agent, opts.accountLabel)
136
+ : undefined;
137
+ let newestFetchedAt;
138
+ for (const stats of Object.values(statsCache)) {
139
+ if (newestFetchedAt === undefined || stats.fetchedAt > newestFetchedAt)
140
+ newestFetchedAt = stats.fetchedAt;
141
+ }
142
+ const snapshotAgeMs = newestFetchedAt === undefined ? undefined : Math.max(0, Date.now() - newestFetchedAt);
143
+ const rows = names.map((name) => {
144
+ const profile = registry[name];
145
+ const stats = statsCache[name];
146
+ const online = deviceOnlineState(profile, stats);
147
+ const config = readDeviceConfigValues(name);
148
+ const description = typeof config.description === 'string' ? config.description : undefined;
149
+ const host = normalizeHost(name);
150
+ return {
151
+ name,
152
+ platform: profile.platform === 'unknown' ? undefined : profile.platform,
153
+ isLocal: isSelfHost(name) || host === local,
154
+ online,
155
+ role: roles[name],
156
+ description,
157
+ loadPercent: stats?.loadPercent,
158
+ memPercent: stats?.memPercent,
159
+ headroom: headroom(stats),
160
+ statsFetchedAt: stats?.fetchedAt,
161
+ lastSeenAt: online === 'offline'
162
+ ? profile.reachability?.checkedAt ?? profile.tailscale?.lastSeen
163
+ : undefined,
164
+ hasAccount: accountDevices
165
+ ? accountDevices.publishing.has(host)
166
+ ? accountDevices.byDevice.get(host) ?? false
167
+ : undefined
168
+ : undefined,
169
+ };
170
+ });
171
+ return { rows, snapshotAgeMs };
172
+ }
173
+ /** Human age for the prompt's "fleet state as of …" note: 'just now', '2 min ago', '3 h ago', … */
174
+ function formatSnapshotAge(ageMs) {
175
+ if (ageMs < 45_000)
176
+ return 'just now';
177
+ if (ageMs < 90_000)
178
+ return '1 min ago';
179
+ if (ageMs < 3_600_000)
180
+ return `${Math.round(ageMs / 60_000)} min ago`;
181
+ if (ageMs < 86_400_000)
182
+ return `${Math.round(ageMs / 3_600_000)} h ago`;
183
+ return `${Math.round(ageMs / 86_400_000)} d ago`;
184
+ }
185
+ /**
186
+ * Interactive menu. Off a TTY: fail loud with the non-interactive forms,
187
+ * exactly like the account picker. Prompt message: `Select a device for this
188
+ * run (fleet state as of <age>):` where <age> is e.g. '2 min ago', or
189
+ * `Select a device for this run (no cached fleet state):`. Returns the device
190
+ * name, or null when the user cancels (Esc/Ctrl-C) — a cancel launches
191
+ * nothing. Throws when the registry has no devices at all (the message names
192
+ * `agents devices add`).
193
+ */
194
+ export async function pickRunDevice(opts) {
195
+ // An empty registry is wrong on every terminal, so it is judged before the
196
+ // TTY gate: "register a device" is the useful answer, not "need a TTY".
197
+ const { rows, snapshotAgeMs } = readRunDeviceRows(opts);
198
+ if (rows.length === 0) {
199
+ throw new Error('No devices are registered. Add one with: agents devices add <name>');
200
+ }
201
+ if (!isInteractiveTerminal()) {
202
+ requireInteractiveSelection('Selecting a device', [
203
+ `agents run ${opts.agent} --device <name>`,
204
+ 'agents devices',
205
+ ]);
206
+ }
207
+ const choices = buildRunDeviceChoices(rows, opts.accountLabel);
208
+ try {
209
+ return await select({
210
+ message: snapshotAgeMs !== undefined
211
+ ? `Select a device for this run (fleet state as of ${formatSnapshotAge(snapshotAgeMs)}):`
212
+ : 'Select a device for this run (no cached fleet state):',
213
+ choices,
214
+ loop: false,
215
+ });
216
+ }
217
+ catch (err) {
218
+ if (isPromptCancelled(err))
219
+ return null;
220
+ throw err;
221
+ }
222
+ }
@@ -9,9 +9,10 @@
9
9
  * Idempotent: re-running re-installs the current helper and re-checks trust.
10
10
  */
11
11
  import os from 'node:os';
12
- import { execFileSync } from 'node:child_process';
12
+ import { execFileSync, spawnSync } from 'node:child_process';
13
13
  import chalk from 'chalk';
14
14
  import { installComputerHelperMacLocal, activateComputerHelperMacLocal, probeComputerTrust, } from './computer.js';
15
+ import { resolveComputerBin, ComputerClientError } from '../lib/computer-client.js';
15
16
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
17
  const ACCESSIBILITY_PANE = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility';
17
18
  const SCREEN_PANE = 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture';
@@ -36,6 +37,24 @@ export async function runComputerWizard() {
36
37
  console.log(chalk.dim('On Windows, provision a remote host from a Mac/Linux box instead: `agents computer setup --device <device>`.'));
37
38
  return false;
38
39
  }
40
+ try {
41
+ resolveComputerBin();
42
+ }
43
+ catch (error) {
44
+ if (!(error instanceof ComputerClientError) || error.code !== 'COMPUTER_BIN_MISSING')
45
+ throw error;
46
+ console.log('Installing @phnx-labs/computer-cli@0.1.2…');
47
+ const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.2'], { stdio: 'inherit' });
48
+ if (installed.status !== 0)
49
+ return false;
50
+ try {
51
+ resolveComputerBin();
52
+ }
53
+ catch {
54
+ console.error('Computer CLI installation did not produce an executable on PATH.');
55
+ return false;
56
+ }
57
+ }
39
58
  // 1. Download + verify + install the signed, notarized helper.
40
59
  console.log(chalk.bold('Installing the Agents Computer helper...'));
41
60
  try {
@@ -8,8 +8,8 @@
8
8
  * `npm i -g`. Users do not set extra env vars.
9
9
  */
10
10
  import type { Command } from 'commander';
11
- export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.2";
12
- export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.2";
11
+ export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.4";
12
+ export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.4";
13
13
  export declare function setupSecretsPrefsPath(): string;
14
14
  /** True when the standalone `secrets` executable resolves ($SECRETS_BIN or PATH). */
15
15
  export declare function isSecretsCliInstalled(): boolean;
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process';
14
14
  import { getHistoryDir } from '../lib/state.js';
15
15
  import { resolveSecretsBin, invocation, SecretsClientError, _resetSecretsClientForTest } from '../lib/secrets-client.js';
16
16
  import { installCli, resolveCliManifest } from '../lib/cli-resources.js';
17
- export const SECRETS_CLI_PACKAGE = '@phnx-labs/secrets-cli@0.1.2';
17
+ export const SECRETS_CLI_PACKAGE = '@phnx-labs/secrets-cli@0.1.4';
18
18
  export const INSTALL_HINT = `agents clis install secrets # or: npm i -g ${SECRETS_CLI_PACKAGE}`;
19
19
  export function setupSecretsPrefsPath() {
20
20
  return path.join(getHistoryDir(), 'setup', 'secrets.json');
@@ -31,7 +31,7 @@ import { isCapable } from '../lib/capabilities.js';
31
31
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins/plugins.js';
32
32
  import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts, readMeta } from '../lib/state.js';
33
33
  import { findNativeAccountByIdentity } from '../lib/account-registry.js';
34
- import { accountListJson, loadAccountCatalog, renderAccountRows, secretsUnavailableNote } from '../lib/account-catalog.js';
34
+ import { ACCOUNT_LISTING_LEGEND, accountListJson, loadAccountCatalog, renderAccountRows, secretsUnavailableNote } from '../lib/account-catalog.js';
35
35
  import { addSupported } from '../lib/accounts/add.js';
36
36
  import { readInstallation } from '../lib/installations/store.js';
37
37
  import { isAutoUpdateEnabledForAgent } from '../lib/installations/update-policy.js';
@@ -557,12 +557,10 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
557
557
  }
558
558
  if (filterAgentId) {
559
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'));
565
560
  }
561
+ // Overview renders the same account rows with footer:false, so both paths
562
+ // need the legend the shared renderer would otherwise have printed.
563
+ console.log(chalk.gray(` ${ACCOUNT_LISTING_LEGEND}\n`));
566
564
  }
567
565
  if (versionManaged.length > 0 && viewOpts?.versions) {
568
566
  // Calculate column widths across all agents for alignment
@@ -201,8 +201,29 @@ export declare function loadAccountCatalog(): Promise<AccountCatalog>;
201
201
  */
202
202
  export declare function secretsUnavailableNote(catalog: Pick<AccountCatalog, 'secretsUnavailable'>): string | null;
203
203
  export declare function accountListJson(native: NativeAccountCatalogRow[], providers?: ProviderAccountCatalogRow[], harness?: AgentId): AccountListJson;
204
- export declare function whereText(row: NativeAccountCatalogRow, localDevice: string): string;
204
+ /**
205
+ * A state worth an operator's attention, rendered at the END of the row.
206
+ * `live`, `ready`, `unverified` and `per-device` are the ordinary cases: they
207
+ * were on every row of the old table and pushed it past the terminal width
208
+ * without telling anyone anything. They stay in `accounts list --fleet`,
209
+ * `accounts view <name>` and `--json`.
210
+ */
211
+ export declare function verdictNote(verdict: AccountVerdict): string | null;
212
+ /**
213
+ * Device coverage, but only when it SPLITS the fleet: some boxes can use the
214
+ * account and some cannot. Full coverage is the ordinary case, and "none of
215
+ * them" is already exactly what the row's state note says, so both render
216
+ * nothing — `accounts list --fleet` is the per-device table.
217
+ */
218
+ export declare function coverageNote(row: NativeAccountCatalogRow, localDevice: string): string | null;
205
219
  export declare const OVERVIEW_MAX_USAGE_WINDOWS = 2;
220
+ /**
221
+ * The one line under an account listing. It explains the two marks a row can
222
+ * carry and names where the columns the listing no longer prints — identity,
223
+ * per-device state — still live. Shared with `agents view` so the two surfaces
224
+ * cannot drift.
225
+ */
226
+ export declare const ACCOUNT_LISTING_LEGEND = "* stale usage \u00B7 a healthy account carries no state \u00B7 identity + per-box state: agents accounts list --fleet";
206
227
  /** Shared text renderer used by both `accounts list` and account-first `view`. */
207
228
  export declare function renderAccountRows(rows: NativeAccountCatalogRow[], opts: {
208
229
  heading?: boolean;
@@ -367,35 +367,55 @@ export function accountListJson(native, providers = [], harness) {
367
367
  ],
368
368
  };
369
369
  }
370
- function verdictText(verdict) {
370
+ /**
371
+ * A state worth an operator's attention, rendered at the END of the row.
372
+ * `live`, `ready`, `unverified` and `per-device` are the ordinary cases: they
373
+ * were on every row of the old table and pushed it past the terminal width
374
+ * without telling anyone anything. They stay in `accounts list --fleet`,
375
+ * `accounts view <name>` and `--json`.
376
+ */
377
+ export function verdictNote(verdict) {
371
378
  if (verdict === 'rate_limited')
372
- return 'LIMITED';
373
- return verdict.toUpperCase();
379
+ return chalk.yellow('rate-limited');
380
+ if (verdict === 'expired' || verdict === 'revoked' || verdict === 'missing') {
381
+ return chalk.red(verdict);
382
+ }
383
+ return null;
374
384
  }
375
- export function whereText(row, localDevice) {
385
+ /**
386
+ * Device coverage, but only when it SPLITS the fleet: some boxes can use the
387
+ * account and some cannot. Full coverage is the ordinary case, and "none of
388
+ * them" is already exactly what the row's state note says, so both render
389
+ * nothing — `accounts list --fleet` is the per-device table.
390
+ */
391
+ export function coverageNote(row, localDevice) {
376
392
  const names = [...new Set(row.devices.map((device) => device.device))];
377
393
  // Peers on an older release do not publish slot verdicts, so the only
378
- // observation is this box — say so rather than a coverage count.
394
+ // observation is this box — that is a gap in what we can see, not a gap in
395
+ // provisioning, so it is not an alarm.
379
396
  if (names.length === 1 && names[0] === localDevice)
380
- return 'this box';
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
397
+ return null;
398
+ // Per-device accounts are provisioned per box; name the boxes it is NOT on
399
+ // rather than a usable/total fraction — the fraction would hide that each box
383
400
  // is its own account.
384
401
  if (row.provisioning === 'per-device') {
385
- const present = row.devices
386
- .filter((device) => device.verdict !== 'missing')
387
- .map((device) => device.device);
388
- return present.join(', ') || 'this box';
402
+ const absent = row.devices.filter((device) => device.verdict === 'missing').map((device) => device.device);
403
+ return absent.length > 0 && absent.length < row.devices.length ? `not on ${absent.join(', ')}` : null;
389
404
  }
390
405
  const provisioned = row.devices.filter((device) => device.verdict !== 'missing').length;
391
- if (provisioned === 0)
392
- return '—';
393
406
  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`;
407
+ if (usable === 0 || usable === provisioned)
408
+ return null;
409
+ return `usable on ${usable} of ${provisioned} boxes`;
397
410
  }
398
411
  export const OVERVIEW_MAX_USAGE_WINDOWS = 2;
412
+ /**
413
+ * The one line under an account listing. It explains the two marks a row can
414
+ * carry and names where the columns the listing no longer prints — identity,
415
+ * per-device state — still live. Shared with `agents view` so the two surfaces
416
+ * cannot drift.
417
+ */
418
+ export const ACCOUNT_LISTING_LEGEND = '* stale usage · a healthy account carries no state · identity + per-box state: agents accounts list --fleet';
399
419
  function usageText(row, maxWindows) {
400
420
  if (row.usageSnapshot) {
401
421
  const usageInfo = { snapshot: row.usageSnapshot, error: row.usageError ?? null };
@@ -411,13 +431,35 @@ function usageText(row, maxWindows) {
411
431
  const percent = `${row.usage.usedPercent}%${row.usage.stale ? '*' : ''}`;
412
432
  return `${renderBar(row.usage.usedPercent, LISTING_USAGE_BAR_LEN)} ${percent}`;
413
433
  }
434
+ /**
435
+ * True only when the USAGE cell is certain to print a throttle marker, so the
436
+ * `rate-limited` note would say the same thing twice. Mirrors `usageText`
437
+ * branch for branch: with a snapshot, `formatUsageSummary` appends
438
+ * 'out of credits' or 'session-limited (…)' exactly for these `unavailable`
439
+ * reasons; without one, `usageText` prints 'limited' or 'no credits'. A verdict
440
+ * thrown by a maxed WINDOW is deliberately NOT here: the overview caps the cell
441
+ * at `OVERVIEW_MAX_USAGE_WINDOWS`, so the window that tripped it can be hidden
442
+ * behind the `+N` count with no color at all, and the note is the only signal.
443
+ */
444
+ function usageCellNamesThrottle(row) {
445
+ if (row.usageSnapshot) {
446
+ const unavailable = row.usageSnapshot.unavailable;
447
+ return unavailable?.reason === 'out_of_credits'
448
+ || (unavailable?.reason === 'session_limit' && !!unavailable.resetsAt);
449
+ }
450
+ return row.usage?.status === 'out_of_credits'
451
+ || (row.usage?.status === 'rate_limited' && (row.usage.usedPercent === null || row.usage.usedPercent === undefined));
452
+ }
414
453
  function nativeLine(row, localDevice, maxWindows) {
415
454
  return {
416
- name: row.name ?? 'unnamed',
417
- identityLabel: row.identityLabel,
418
- verdict: row.verdict,
419
- where: whereText(row, localDevice),
420
- fix: row.fix,
455
+ // A discovered login nobody has named is still identified by what it is:
456
+ // the row no longer carries an IDENTITY column, so the identity IS the name.
457
+ name: row.name ?? row.identityLabel,
458
+ notes: [
459
+ usageCellNamesThrottle(row) && row.verdict === 'rate_limited' ? null : verdictNote(row.verdict),
460
+ coverageNote(row, localDevice),
461
+ row.fix ? chalk.gray(`fix: ${row.fix}`) : null,
462
+ ].filter((note) => !!note),
421
463
  usage: usageText(row, maxWindows),
422
464
  isDefault: row.isDefault,
423
465
  };
@@ -425,32 +467,27 @@ function nativeLine(row, localDevice, maxWindows) {
425
467
  function providerLine(row, harness) {
426
468
  return {
427
469
  name: row.name,
428
- identityLabel: row.identityLabel,
429
- verdict: row.verdict,
430
- where: '—',
431
- fix: row.fix,
470
+ notes: [
471
+ verdictNote(row.verdict),
472
+ row.fix ? chalk.gray(`fix: ${row.fix}`) : null,
473
+ ].filter((note) => !!note),
432
474
  usage: '',
433
475
  isDefault: harness ? row.defaultFor.includes(harness) : false,
434
476
  };
435
477
  }
436
- function formatListingLine(line, nameW, identityW, stateW, whereW, usageW) {
478
+ function formatListingLine(line, nameW, usageW) {
437
479
  const marker = line.isDefault ? '*' : ' ';
438
- const fix = line.fix ? `fix: ${line.fix}` : '';
439
480
  return (` ${chalk.green(marker)} ${chalk.cyan(line.name.padEnd(nameW))} `
440
- + `${line.identityLabel.padEnd(identityW)} `
441
- + `${verdictText(line.verdict).padEnd(stateW)} `
442
- + `${line.where.padEnd(whereW)} `
443
481
  + `${padToWidth(line.usage, usageW)} `
444
- + `${fix ? chalk.gray(fix) : ''}`).trimEnd();
482
+ + line.notes.join(chalk.gray(' · '))).trimEnd();
445
483
  }
446
484
  function pushGroup(out, title, lines, widths, harnessHeadings) {
447
485
  if (lines.length === 0)
448
486
  return;
449
487
  if (harnessHeadings)
450
488
  out.push(chalk.bold(title));
451
- out.push(chalk.gray(` ${'ACCOUNT'.padEnd(widths.nameW + 2)}${'IDENTITY'.padEnd(widths.identityW + 2)}${'STATE'.padEnd(widths.stateW + 2)}${'WHERE'.padEnd(widths.whereW + 2)}${'USAGE'.padEnd(widths.usageW + 2)}FIX`));
452
489
  for (const line of lines) {
453
- out.push(formatListingLine(line, widths.nameW, widths.identityW, widths.stateW, widths.whereW, widths.usageW));
490
+ out.push(formatListingLine(line, widths.nameW, widths.usageW));
454
491
  }
455
492
  out.push('');
456
493
  }
@@ -501,9 +538,6 @@ export function renderAccountRows(rows, opts) {
501
538
  const allLines = [...grouped.values()].flat().concat(orphans);
502
539
  const widths = {
503
540
  nameW: Math.max(7, ...allLines.map((line) => line.name.length)),
504
- identityW: Math.max(8, ...allLines.map((line) => line.identityLabel.length)),
505
- stateW: Math.max(5, ...allLines.map((line) => verdictText(line.verdict).length)),
506
- whereW: Math.max(5, ...allLines.map((line) => line.where.length)),
507
541
  usageW: Math.max(5, ...allLines.map((line) => stringWidth(line.usage))),
508
542
  };
509
543
  for (const [harness, lines] of grouped) {
@@ -519,7 +553,7 @@ export function renderAccountRows(rows, opts) {
519
553
  const count = visibleNative.filter((row) => !!row.fix).length
520
554
  + visibleProviders.filter((row) => !!row.fix).length;
521
555
  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'));
556
+ out.push(chalk.gray(ACCOUNT_LISTING_LEGEND));
523
557
  }
524
558
  return out.join('\n').trimEnd();
525
559
  }
@@ -624,12 +624,13 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
624
624
  parts.push(chalk.gray(plan.padEnd(planWidth)));
625
625
  }
626
626
  if (snapshot) {
627
- if (snapshot.unavailable?.reason === 'out_of_credits') {
628
- parts.push(chalk.red('out of credits'));
629
- }
630
- else if (snapshot.unavailable?.reason === 'session_limit' && snapshot.unavailable.resetsAt) {
631
- parts.push(chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`));
632
- }
627
+ // A blocking marker renders AFTER the bars: leading it pushed every gauge
628
+ // out of its column, so one throttled account misaligned the whole table.
629
+ const blocked = snapshot.unavailable?.reason === 'out_of_credits'
630
+ ? chalk.red('out of credits')
631
+ : snapshot.unavailable?.reason === 'session_limit' && snapshot.unavailable.resetsAt
632
+ ? chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`)
633
+ : null;
633
634
  // Compact rows show BLOCKING windows — the same set
634
635
  // deriveUsageStatusFromSnapshot uses for the rate-limited badge — so an
635
636
  // account throttled by its month window (Droid meters on 5h/week/month)
@@ -698,6 +699,8 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
698
699
  else if (opts?.unverified) {
699
700
  parts.push(chalk.yellow('unverified'));
700
701
  }
702
+ if (blocked)
703
+ parts.push(blocked);
701
704
  }
702
705
  else if (opts?.headless) {
703
706
  // No bars at all: still name the scope gap so "usage pending" is not
@@ -2357,7 +2360,7 @@ function formatAgeShort(diffMs) {
2357
2360
  * Staleness suffix for a last-known window the freshness gate dropped. A window
2358
2361
  * whose reset/period boundary passed while the sample itself is still inside its
2359
2362
  * `windowMinutes` rolled OVER — the number describes a period that is done, so
2360
- * name it ("period ended 1h ago", e.g. Grok's weekly billing period). A window
2363
+ * name it ("period ended 1h", e.g. Grok's weekly billing period). A window
2361
2364
  * that aged past its own `windowMinutes` (Claude's 5h session read never
2362
2365
  * refreshed in time) is a stale sample of a still-rolling window, so report the
2363
2366
  * capture age ("6h old"). Falls back to the reset age, then a bare "stale".
@@ -2368,25 +2371,26 @@ function formatStaleWindowSuffix(window, capturedAt, now) {
2368
2371
  window.windowMinutes !== null &&
2369
2372
  capturedAt.getTime() + window.windowMinutes * 60 * 1000 <= now.getTime();
2370
2373
  if (resetPassed && !captureExpired) {
2371
- return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
2374
+ return `period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())}`;
2372
2375
  }
2373
2376
  if (capturedAt)
2374
2377
  return `${formatAgeShort(now.getTime() - capturedAt.getTime())} old`;
2375
2378
  if (resetPassed)
2376
- return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
2379
+ return `period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())}`;
2377
2380
  return 'stale';
2378
2381
  }
2379
2382
  /**
2380
- * Render a dropped-but-last-known window as "S: ▍░░░░ 30% · 6h old": the gauge
2381
- * and percentage exactly as a live bar, then a dim staleness suffix so the
2382
- * number is always visible and unmistakably not current. VIEW-ONLY — these
2383
- * windows are never in `snapshot.windows`, so routing never sees them.
2383
+ * Render a dropped-but-last-known window as "S: ▍░░░░ 30%* (6h old)": the gauge
2384
+ * and percentage exactly as a live bar, then the `*` stale marker the listing
2385
+ * legend explains and a dim age, so the number is always visible and
2386
+ * unmistakably not current. VIEW-ONLY — these windows are never in
2387
+ * `snapshot.windows`, so routing never sees them.
2384
2388
  */
2385
2389
  function renderStaleUsageWindow(window, capturedAt, shortLabel, now) {
2386
2390
  const bar = renderCompactUsageBar(window.usedPercent);
2387
2391
  const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
2388
2392
  const suffix = formatStaleWindowSuffix(window, capturedAt, now);
2389
- return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct} ${chalk.dim(`· ${suffix}`)}`;
2393
+ return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct}${chalk.dim('*')} ${chalk.dim(`(${suffix})`)}`;
2390
2394
  }
2391
2395
  /** Format a reset timestamp as a human-readable relative or absolute time. */
2392
2396
  function formatResetAt(date) {
@@ -24,6 +24,7 @@ export declare const GEMINI_HOOKS_MIN_VERSION = "0.26.0";
24
24
  * section, even though the user had nothing to import.
25
25
  */
26
26
  interface NativeBinaryResolutionOptions {
27
+ accept?: (candidate: string) => boolean;
27
28
  shimsDir?: string;
28
29
  historyDir?: string;
29
30
  }