@phnx-labs/agents-cli 1.22.101 → 1.22.102
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.
- package/CHANGELOG.md +10 -0
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/menubar.js +4 -1
- package/dist/commands/ssh.js +12 -4
- package/dist/commands/view.d.ts +0 -2
- package/dist/commands/view.js +9 -28
- package/dist/lib/account-catalog.d.ts +24 -0
- package/dist/lib/account-catalog.js +45 -15
- package/dist/lib/accounting/usage.d.ts +7 -0
- package/dist/lib/accounting/usage.js +25 -0
- package/dist/lib/auth-health.d.ts +17 -4
- package/dist/lib/auth-health.js +60 -22
- package/dist/lib/auto-pull-worker.js +38 -1
- package/dist/lib/devices/harness-inventory.d.ts +4 -0
- package/dist/lib/devices/harness-inventory.js +3 -1
- package/dist/lib/feed/pr-status.d.ts +18 -0
- package/dist/lib/feed/pr-status.js +43 -5
- package/dist/lib/feed/watch.d.ts +4 -1
- package/dist/lib/feed/watch.js +57 -25
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +49 -0
- package/dist/lib/session/active.d.ts +1 -1
- package/dist/lib/session/active.js +7 -0
- package/dist/lib/session/state.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.102
|
|
4
|
+
|
|
5
|
+
- **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`.
|
|
6
|
+
|
|
7
|
+
- **`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`.
|
|
8
|
+
|
|
9
|
+
- **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`.
|
|
10
|
+
|
|
11
|
+
- **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`.
|
|
12
|
+
|
|
3
13
|
## 1.22.101
|
|
4
14
|
|
|
5
15
|
- **`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`.
|
|
@@ -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
|
|
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]')
|
package/dist/commands/menubar.js
CHANGED
|
@@ -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
|
-
|
|
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`.
|
package/dist/commands/ssh.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/commands/view.js
CHANGED
|
@@ -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 {
|
|
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]
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`—
|
|
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").
|
|
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
|
package/dist/lib/auth-health.js
CHANGED
|
@@ -32,14 +32,18 @@ export const LIVE_PROBE_AGENTS = new Set(['claude', 'kimi', 'droid']);
|
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
33
|
// Pure classifiers / render (unit-tested; no network, no fs)
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
|
-
/** Map an HTTP status from a live probe to a verdict. */
|
|
35
|
+
/** Map an HTTP status from a live probe to a verdict. Probe 429 never yields `rate_limited` (PHNX-4051). */
|
|
36
36
|
export function classifyHttpStatus(status) {
|
|
37
37
|
if (status >= 200 && status < 300)
|
|
38
38
|
return 'live';
|
|
39
39
|
if (status === 401 || status === 403)
|
|
40
40
|
return 'revoked';
|
|
41
|
+
// 429 is probe throttling, not an account throttle — the account's real
|
|
42
|
+
// throttle state comes from the usage snapshot (deriveUsageStatusFromSnapshot).
|
|
43
|
+
// Treating it as `rate_limited` let a burst-throttled endpoint mark every
|
|
44
|
+
// account LIMITED. See mergeAuthHealthEntries for the keep-or-unverified path.
|
|
41
45
|
if (status === 429)
|
|
42
|
-
return '
|
|
46
|
+
return 'error';
|
|
43
47
|
return 'error';
|
|
44
48
|
}
|
|
45
49
|
/** Turn a raw provider probe (from usage.ts) into a verdict. */
|
|
@@ -62,6 +66,8 @@ export function probeDetail(probe) {
|
|
|
62
66
|
if (probe.reason === 'usage_scope') {
|
|
63
67
|
return probe.error ?? USAGE_HEADLESS_SCOPE_MARKER;
|
|
64
68
|
}
|
|
69
|
+
if (probe.status === 429)
|
|
70
|
+
return 'probe throttled (HTTP 429)';
|
|
65
71
|
if (probe.status != null && (probe.status < 200 || probe.status >= 300))
|
|
66
72
|
return `HTTP ${probe.status}`;
|
|
67
73
|
if (probe.error)
|
|
@@ -345,10 +351,22 @@ export function readFleetAuthRows(host) {
|
|
|
345
351
|
* known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
|
|
346
352
|
* exactly the "cry wolf" the verdict model avoids for `expired`. This is the
|
|
347
353
|
* behaviour promised by the `error` doc on AuthVerdict ("keep the last known
|
|
348
|
-
* one").
|
|
354
|
+
* one"). A probe-throttled 429 (detail `probe throttled (HTTP 429)`) keeps the
|
|
355
|
+
* previous real verdict when one exists within {@link AUTH_PROBE_MAX_AGE_MS},
|
|
356
|
+
* otherwise becomes `unverified` (PHNX-4051). Pure, so it's unit-tested directly. */
|
|
349
357
|
export function mergeAuthHealthEntries(current, incoming) {
|
|
350
358
|
const merged = { ...current };
|
|
351
359
|
for (const [key, health] of Object.entries(incoming)) {
|
|
360
|
+
const isProbeThrottled = health.detail === 'probe throttled (HTTP 429)';
|
|
361
|
+
if (isProbeThrottled) {
|
|
362
|
+
const prev = merged[key];
|
|
363
|
+
const fresh = !!prev && prev.verdict !== 'error' && prev.verdict !== 'unconfigured'
|
|
364
|
+
&& (health.checkedAt - prev.checkedAt < AUTH_PROBE_MAX_AGE_MS);
|
|
365
|
+
if (fresh)
|
|
366
|
+
continue; // keep previous real verdict
|
|
367
|
+
merged[key] = { ...health, verdict: 'unverified' };
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
352
370
|
if (health.verdict === 'error' && merged[key])
|
|
353
371
|
continue; // keep last known
|
|
354
372
|
merged[key] = health;
|
|
@@ -470,13 +488,22 @@ export async function probeAuthHealth(agent, home, opts) {
|
|
|
470
488
|
* per-install probe (also the `unconfigured` case dropped downstream). Pure: no
|
|
471
489
|
* fs, no network, so the dedup decision is unit-tested directly.
|
|
472
490
|
*/
|
|
491
|
+
/** Small fixed delay between live probes so one box no longer fires 16 requests in 4s (PHNX-4051). */
|
|
492
|
+
export const AUTH_PROBE_SPACING_MS = 150;
|
|
473
493
|
export function groupFleetAuthInstalls(installs, isMergeable = () => true) {
|
|
474
494
|
const groups = new Map();
|
|
475
495
|
for (const inst of installs) {
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
496
|
+
// Prefer stable accountId (identity) — a version home and its slot share the
|
|
497
|
+
// same id, so they collapse to ONE probe per identity (PHNX-4051). Fallback
|
|
498
|
+
// to the display label when no id is known. The `id:`/`acct:`/`ver:` tokens
|
|
499
|
+
// keep the three branches disjoint.
|
|
500
|
+
const mergeKey = inst.accountId
|
|
501
|
+
? `id:${inst.accountId}`
|
|
502
|
+
: inst.account
|
|
503
|
+
? `acct:${inst.account}`
|
|
504
|
+
: null;
|
|
505
|
+
const key = mergeKey && isMergeable(inst)
|
|
506
|
+
? `${inst.agent} ${mergeKey}`
|
|
480
507
|
: `${inst.agent} ver:${inst.version}`;
|
|
481
508
|
const existing = groups.get(key);
|
|
482
509
|
if (existing)
|
|
@@ -514,26 +541,37 @@ export async function probeLocalFleetAuth(opts) {
|
|
|
514
541
|
// A slot already knows its account; a version home is joined by identity.
|
|
515
542
|
inst.accountId ??= findNativeAccountByIdentity(meta, inst.agent, inst.info)?.id;
|
|
516
543
|
}
|
|
517
|
-
// Probe once per (agent,
|
|
544
|
+
// Probe once per (agent, identity) — but only for the network-probing agents
|
|
518
545
|
// that can actually 429; best-effort agents stay per-install (see
|
|
519
|
-
// groupFleetAuthInstalls). Groups
|
|
520
|
-
//
|
|
521
|
-
|
|
546
|
+
// groupFleetAuthInstalls). Groups are probed SEQUENTIALLY with a small fixed
|
|
547
|
+
// delay between them (PHNX-4051) so one box no longer fires 16 requests in
|
|
548
|
+
// 4s; they target distinct identities, so no same-account concurrency remains.
|
|
549
|
+
const groups = groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent));
|
|
550
|
+
const perGroup = [];
|
|
551
|
+
for (let idx = 0; idx < groups.length; idx++) {
|
|
552
|
+
const group = groups[idx];
|
|
522
553
|
const rep = group.probe;
|
|
523
554
|
const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive, signal: opts?.signal });
|
|
524
555
|
health.account = authAccountLabel(rep.info);
|
|
525
556
|
health.accountId = rep.accountId;
|
|
526
|
-
if (health.verdict === 'unconfigured')
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
557
|
+
if (health.verdict === 'unconfigured') {
|
|
558
|
+
perGroup.push([]);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
perGroup.push(group.members.map((inst) => ({
|
|
562
|
+
agent: inst.agent,
|
|
563
|
+
version: inst.version,
|
|
564
|
+
account: health.account,
|
|
565
|
+
accountId: inst.accountId ?? health.accountId,
|
|
566
|
+
// A distinct object per row so a later mutation of one can't bleed across.
|
|
567
|
+
health: { ...health },
|
|
568
|
+
})));
|
|
569
|
+
}
|
|
570
|
+
// Space probes; not after the last group and not if the tick is being aborted.
|
|
571
|
+
if (idx < groups.length - 1 && !opts?.signal?.aborted) {
|
|
572
|
+
await new Promise((resolve) => setTimeout(resolve, AUTH_PROBE_SPACING_MS));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
537
575
|
return perGroup.flat();
|
|
538
576
|
}
|
|
539
577
|
/**
|
|
@@ -51,6 +51,13 @@ function tryAcquireLock(alias) {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
function touchLock(alias) {
|
|
55
|
+
const now = new Date();
|
|
56
|
+
try {
|
|
57
|
+
fs.utimesSync(lockFilePath(alias), now, now);
|
|
58
|
+
}
|
|
59
|
+
catch { /* lock gone; nothing to extend */ }
|
|
60
|
+
}
|
|
54
61
|
function releaseLock(alias) {
|
|
55
62
|
try {
|
|
56
63
|
fs.unlinkSync(lockFilePath(alias));
|
|
@@ -118,6 +125,36 @@ async function processTarget(target) {
|
|
|
118
125
|
releaseLock(target.alias);
|
|
119
126
|
}
|
|
120
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* macOS only: fetch the floor AGI Menu release into the verified cache when the
|
|
130
|
+
* installed helper is behind it and no bundle ships with this install, so the
|
|
131
|
+
* next foreground invocation's network-free self-heal has a source to install
|
|
132
|
+
* from (`sourceAppPath` candidate 4). Same lock discipline as the repo targets.
|
|
133
|
+
*/
|
|
134
|
+
async function prefetchMenubarHelperTarget() {
|
|
135
|
+
if (process.platform !== 'darwin')
|
|
136
|
+
return;
|
|
137
|
+
const alias = 'menubar-helper';
|
|
138
|
+
if (!tryAcquireLock(alias))
|
|
139
|
+
return;
|
|
140
|
+
// The zip fetch may run up to 15 min (helper-download.ts) while the lock
|
|
141
|
+
// reads as stale after SYNC_LOCK_TTL_MS, and the download lands through one
|
|
142
|
+
// shared partial file — so keep the lock's mtime fresh for as long as this
|
|
143
|
+
// worker is actually fetching, or a second worker races onto the same bytes.
|
|
144
|
+
const heartbeat = setInterval(() => touchLock(alias), SYNC_LOCK_TTL_MS / 2);
|
|
145
|
+
heartbeat.unref();
|
|
146
|
+
try {
|
|
147
|
+
const { prefetchMenubarHelper } = await import('./menubar/install-menubar.js');
|
|
148
|
+
await prefetchMenubarHelper();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* network / verification failures are non-fatal; the next cycle retries */
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearInterval(heartbeat);
|
|
155
|
+
releaseLock(alias);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
121
158
|
async function main() {
|
|
122
159
|
const targets = [];
|
|
123
160
|
const systemDir = getSystemAgentsDir();
|
|
@@ -133,7 +170,7 @@ async function main() {
|
|
|
133
170
|
targets.push({ alias: extra.alias, dir: extra.dir, mode: 'notify' });
|
|
134
171
|
}
|
|
135
172
|
}
|
|
136
|
-
await Promise.all(targets.map(processTarget));
|
|
173
|
+
await Promise.all([...targets.map(processTarget), prefetchMenubarHelperTarget()]);
|
|
137
174
|
// Stamp the cycle so the next foreground CLI invocation can skip the ~7ms
|
|
138
175
|
// detached spawn for SYNC_LOCK_TTL_MS (RUSH-2324). Written even when every
|
|
139
176
|
// target was lock-skipped or the target list was empty — "nothing to do"
|
|
@@ -36,6 +36,10 @@ export interface HarnessRow {
|
|
|
36
36
|
reason?: string;
|
|
37
37
|
verdict: AccountVerdict;
|
|
38
38
|
fix: string | null;
|
|
39
|
+
/** The live usage snapshot backing `quota`, or null when none was collected. */
|
|
40
|
+
snapshot: UsageSnapshot | null;
|
|
41
|
+
/** Raw error string from the usage fetch, if any (headless scope, expired, etc.). */
|
|
42
|
+
usageError: string | null;
|
|
39
43
|
}
|
|
40
44
|
/** One host's rows, or the reason it produced none. */
|
|
41
45
|
export interface HostHarnessResult {
|
|
@@ -123,7 +123,7 @@ export function applyUsageHonesty(verdict, usage) {
|
|
|
123
123
|
}
|
|
124
124
|
return { verdict, usage: stripped };
|
|
125
125
|
}
|
|
126
|
-
if (usage.status === 'rate_limited' && (verdict === 'live' || verdict === 'unverified')) {
|
|
126
|
+
if ((usage.status === 'rate_limited' || usage.status === 'out_of_credits') && (verdict === 'live' || verdict === 'unverified')) {
|
|
127
127
|
return { verdict: 'rate_limited', usage };
|
|
128
128
|
}
|
|
129
129
|
return { verdict, usage };
|
|
@@ -204,6 +204,8 @@ export async function collectLocalHarnessInventory(opts) {
|
|
|
204
204
|
reason,
|
|
205
205
|
verdict,
|
|
206
206
|
fix: fixFor({ agent, verdict, name: saved?.name, version, provisioning }),
|
|
207
|
+
snapshot,
|
|
208
|
+
usageError: usage?.error ?? null,
|
|
207
209
|
};
|
|
208
210
|
});
|
|
209
211
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ActiveSession } from '../session/active.js';
|
|
2
|
+
import type { DetectedPr } from '../session/state.js';
|
|
3
|
+
import type { GhExec } from '../github/pr-mergeable.js';
|
|
2
4
|
import type { PullRequestAttentionSignal } from './attention.js';
|
|
3
5
|
export declare const PR_STATUS_TTL_MS = 45000;
|
|
4
6
|
export declare const PR_STATUS_FIELDS = "number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup";
|
|
@@ -9,6 +11,22 @@ export interface PullRequestStatus extends PullRequestAttentionSignal {
|
|
|
9
11
|
export declare function readPullRequestStatus(session: ActiveSession, options?: {
|
|
10
12
|
nowMs?: number;
|
|
11
13
|
ttlMs?: number;
|
|
14
|
+
gh?: GhExec;
|
|
12
15
|
}): Promise<PullRequestStatus | undefined>;
|
|
13
16
|
export declare function projectPullRequestBoard(sessions: ActiveSession[]): Promise<PullRequestStatus[]>;
|
|
14
17
|
export declare function resetPullRequestStatusCache(): void;
|
|
18
|
+
/**
|
|
19
|
+
* The check rollup folded to one verdict; undefined when the PR has no checks.
|
|
20
|
+
* `statusCheckRollup` mixes Checks-API runs (`conclusion` + `status`) with
|
|
21
|
+
* legacy Status-API contexts (`state` only), so both shapes are read.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checksVerdict(rollup?: unknown[]): DetectedPr['checks'];
|
|
24
|
+
/**
|
|
25
|
+
* The row with its `pr` carrying the fetched status, so a consumer that only
|
|
26
|
+
* sees the agent row (the menu bar, the extension) can color the chip without
|
|
27
|
+
* its own `gh` call. A row without a PR, or a status that never resolved, is
|
|
28
|
+
* returned as is.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withPullRequestStatus<T extends {
|
|
31
|
+
pr?: DetectedPr;
|
|
32
|
+
}>(row: T, status?: PullRequestStatus): T;
|
|
@@ -24,12 +24,10 @@ export async function readPullRequestStatus(session, options = {}) {
|
|
|
24
24
|
const hit = cache.get(key);
|
|
25
25
|
if (hit && hit.expiresAt > now)
|
|
26
26
|
return hit.value;
|
|
27
|
+
const cwd = session.cwd;
|
|
28
|
+
const gh = options.gh ?? (async (args) => (await execFileAsync('gh', args, { cwd, timeout: 15_000, maxBuffer: 1024 * 1024 })).stdout);
|
|
27
29
|
try {
|
|
28
|
-
const
|
|
29
|
-
cwd: session.cwd,
|
|
30
|
-
timeout: 15_000,
|
|
31
|
-
maxBuffer: 1024 * 1024,
|
|
32
|
-
});
|
|
30
|
+
const stdout = await gh(['pr', 'view', String(ref), '--json', PR_STATUS_FIELDS]);
|
|
33
31
|
const raw = JSON.parse(stdout);
|
|
34
32
|
const value = { ...raw, url: session.pr?.url, needsHuman: needsHuman(raw) };
|
|
35
33
|
cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS), value });
|
|
@@ -45,3 +43,43 @@ export async function projectPullRequestBoard(sessions) {
|
|
|
45
43
|
return rows.filter((row) => row !== undefined);
|
|
46
44
|
}
|
|
47
45
|
export function resetPullRequestStatusCache() { cache.clear(); }
|
|
46
|
+
/**
|
|
47
|
+
* The check rollup folded to one verdict; undefined when the PR has no checks.
|
|
48
|
+
* `statusCheckRollup` mixes Checks-API runs (`conclusion` + `status`) with
|
|
49
|
+
* legacy Status-API contexts (`state` only), so both shapes are read.
|
|
50
|
+
*/
|
|
51
|
+
export function checksVerdict(rollup) {
|
|
52
|
+
if (!rollup || rollup.length === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
let pending = false;
|
|
55
|
+
for (const check of rollup) {
|
|
56
|
+
const row = check;
|
|
57
|
+
const verdict = (row.conclusion || row.state || '').toUpperCase();
|
|
58
|
+
if (['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(verdict))
|
|
59
|
+
return 'failing';
|
|
60
|
+
if (verdict === '' ? (row.status ?? '').toUpperCase() !== 'COMPLETED' : ['PENDING', 'EXPECTED', 'STALE'].includes(verdict))
|
|
61
|
+
pending = true;
|
|
62
|
+
}
|
|
63
|
+
return pending ? 'pending' : 'passing';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The row with its `pr` carrying the fetched status, so a consumer that only
|
|
67
|
+
* sees the agent row (the menu bar, the extension) can color the chip without
|
|
68
|
+
* its own `gh` call. A row without a PR, or a status that never resolved, is
|
|
69
|
+
* returned as is.
|
|
70
|
+
*/
|
|
71
|
+
export function withPullRequestStatus(row, status) {
|
|
72
|
+
if (!row.pr || !status)
|
|
73
|
+
return row;
|
|
74
|
+
const pr = {
|
|
75
|
+
...row.pr,
|
|
76
|
+
...(status.state !== undefined ? { state: status.state } : {}),
|
|
77
|
+
...(status.isDraft !== undefined ? { isDraft: status.isDraft } : {}),
|
|
78
|
+
...(status.reviewDecision !== undefined ? { reviewDecision: status.reviewDecision } : {}),
|
|
79
|
+
...(status.mergeable !== undefined ? { mergeable: status.mergeable } : {}),
|
|
80
|
+
};
|
|
81
|
+
const checks = checksVerdict(status.statusCheckRollup);
|
|
82
|
+
if (checks)
|
|
83
|
+
pr.checks = checks;
|
|
84
|
+
return { ...row, pr };
|
|
85
|
+
}
|
package/dist/lib/feed/watch.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus, type WatchLocalOptions } from '../session/watch.js';
|
|
2
2
|
import { type AttentionItem } from './attention.js';
|
|
3
3
|
import { type ActivityEvent } from './activity.js';
|
|
4
|
+
import type { GhExec } from '../github/pr-mergeable.js';
|
|
4
5
|
export declare const FEED_WATCH_VERSION: 1;
|
|
5
6
|
type Base = {
|
|
6
7
|
v: 1;
|
|
@@ -47,7 +48,7 @@ export declare class FeedWatchState {
|
|
|
47
48
|
constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
|
|
48
49
|
emit(event: FeedWatchPayload): FeedWatchEnvelope;
|
|
49
50
|
}
|
|
50
|
-
export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState): Promise<FeedWatchEnvelope[]>;
|
|
51
|
+
export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState, gh?: GhExec): Promise<FeedWatchEnvelope[]>;
|
|
51
52
|
export interface WatchLocalFeedOptions {
|
|
52
53
|
scope: string;
|
|
53
54
|
signal: AbortSignal;
|
|
@@ -58,6 +59,8 @@ export interface WatchLocalFeedOptions {
|
|
|
58
59
|
reconcileMs?: number;
|
|
59
60
|
/** Session-watch inputs, forwarded verbatim to {@link watchLocalSessions}. */
|
|
60
61
|
sessions?: Pick<WatchLocalOptions, 'readCache' | 'readPrevious' | 'journalPath' | 'journalPollMs' | 'heartbeatMs'>;
|
|
62
|
+
/** The `gh` runner behind PR status; a test passes a recorded table. */
|
|
63
|
+
gh?: GhExec;
|
|
61
64
|
}
|
|
62
65
|
export declare function watchLocalFeed(options: WatchLocalFeedOptions): Promise<void>;
|
|
63
66
|
export declare function watchFleetFeed(options: {
|
package/dist/lib/feed/watch.js
CHANGED
|
@@ -11,7 +11,7 @@ import { watchLocalSessions } from '../session/watch.js';
|
|
|
11
11
|
import { readBlock, readResolution, blockIdForSession } from './feed.js';
|
|
12
12
|
import { reconcileAttention } from './attention.js';
|
|
13
13
|
import { ActivityStream } from './activity-stream.js';
|
|
14
|
-
import { PR_STATUS_TTL_MS, readPullRequestStatus } from './pr-status.js';
|
|
14
|
+
import { PR_STATUS_TTL_MS, readPullRequestStatus, withPullRequestStatus } from './pr-status.js';
|
|
15
15
|
export const FEED_WATCH_VERSION = 1;
|
|
16
16
|
export class FeedWatchState {
|
|
17
17
|
streamId;
|
|
@@ -21,36 +21,51 @@ export class FeedWatchState {
|
|
|
21
21
|
return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Live rows only: durable Previous rows share the operator stream for Sessions
|
|
26
|
+
* history, but they are not live work and must never synthesize Needs-you
|
|
27
|
+
* attention or a PR lookup.
|
|
28
|
+
*/
|
|
29
|
+
function isLive(agent) {
|
|
30
|
+
return Boolean(agent.sessionId) && !agent.previous && agent.context !== 'recent';
|
|
31
|
+
}
|
|
32
|
+
// ActiveSession.host names the terminal app; the feed contract's host is the
|
|
33
|
+
// device scope. Normalize only the reconciler input so lifecycle/PR keys are
|
|
34
|
+
// routable across the fleet while the projected agent row stays compatible.
|
|
35
|
+
function reconcilerSession(agent) {
|
|
36
|
+
return { ...agent, context: agent.context, host: agent.sourceDevice, viewingIn: undefined };
|
|
37
|
+
}
|
|
38
|
+
/** One `gh pr view` (cached 45 s) feeds both the attention verdict and the row's `pr` status. */
|
|
39
|
+
async function pullRequestFor(agent, gh) {
|
|
40
|
+
if (!isLive(agent) || !agent.pr)
|
|
41
|
+
return undefined;
|
|
42
|
+
return readPullRequestStatus(reconcilerSession(agent), { gh });
|
|
43
|
+
}
|
|
44
|
+
function attentionFor(agent, pullRequest) {
|
|
45
|
+
if (!isLive(agent) || !agent.sessionId)
|
|
28
46
|
return undefined;
|
|
29
47
|
const blockId = blockIdForSession(agent.sessionId);
|
|
30
|
-
// ActiveSession.host names the terminal app; the feed contract's host is the
|
|
31
|
-
// device scope. Normalize only the reconciler input so lifecycle/PR keys are
|
|
32
|
-
// routable across the fleet while the projected agent row stays compatible.
|
|
33
|
-
const session = {
|
|
34
|
-
...agent,
|
|
35
|
-
context: agent.context,
|
|
36
|
-
host: agent.sourceDevice,
|
|
37
|
-
viewingIn: undefined,
|
|
38
|
-
};
|
|
39
48
|
return reconcileAttention({
|
|
40
|
-
block: readBlock(blockId), session,
|
|
49
|
+
block: readBlock(blockId), session: reconcilerSession(agent),
|
|
41
50
|
resolution: readResolution(blockId),
|
|
42
|
-
pullRequest
|
|
51
|
+
pullRequest, nowMs: Date.now(),
|
|
43
52
|
});
|
|
44
53
|
}
|
|
45
|
-
|
|
54
|
+
/** The row as the feed projects it: the session row with its PR status attached. */
|
|
55
|
+
async function projectAgent(agent, gh) {
|
|
56
|
+
const pullRequest = await pullRequestFor(agent, gh);
|
|
57
|
+
return { agent: withPullRequestStatus(agent, pullRequest), attention: attentionFor(agent, pullRequest) };
|
|
58
|
+
}
|
|
59
|
+
export async function projectSessionEnvelope(event, state, gh) {
|
|
46
60
|
if (event.type === 'reset') {
|
|
47
|
-
const
|
|
48
|
-
|
|
61
|
+
const projected = await Promise.all(event.rows.map((row) => projectAgent(row, gh)));
|
|
62
|
+
const attention = projected.map((p) => p.attention).filter((item) => item !== undefined);
|
|
63
|
+
return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: projected.map((p) => p.agent), attention })];
|
|
49
64
|
}
|
|
50
65
|
if (event.type === 'upsert') {
|
|
51
|
-
const attention = await
|
|
66
|
+
const { agent, attention } = await projectAgent(event.row, gh);
|
|
52
67
|
return [
|
|
53
|
-
state.emit({ type: 'agent.upsert', scope: event.scope, rowKey: event.rowKey, agent
|
|
68
|
+
state.emit({ type: 'agent.upsert', scope: event.scope, rowKey: event.rowKey, agent }),
|
|
54
69
|
attention
|
|
55
70
|
? state.emit({ type: 'attention.upsert', scope: event.scope, rowKey: event.rowKey, attention })
|
|
56
71
|
: state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey }),
|
|
@@ -99,10 +114,18 @@ export async function watchLocalFeed(options) {
|
|
|
99
114
|
let activityCursor = Date.now();
|
|
100
115
|
const agents = new Map();
|
|
101
116
|
const attention = new Map();
|
|
117
|
+
// The PR status last projected onto each row, so a merge or a check verdict
|
|
118
|
+
// that lands between session events still reaches the row's consumers.
|
|
119
|
+
const prStatus = new Map();
|
|
102
120
|
let pending = Promise.resolve();
|
|
103
121
|
const reconcileRows = async () => {
|
|
104
|
-
for (const [rowKey,
|
|
105
|
-
const item = await
|
|
122
|
+
for (const [rowKey, raw] of agents) {
|
|
123
|
+
const { agent, attention: item } = await projectAgent(raw, options.gh);
|
|
124
|
+
const nextPr = agent.pr ? JSON.stringify(agent.pr) : '';
|
|
125
|
+
if (agent.pr && prStatus.get(rowKey) !== nextPr) {
|
|
126
|
+
prStatus.set(rowKey, nextPr);
|
|
127
|
+
options.emit(state.emit({ type: 'agent.upsert', scope: options.scope, rowKey, agent }));
|
|
128
|
+
}
|
|
106
129
|
const next = item ? JSON.stringify(item) : '';
|
|
107
130
|
if (attention.get(rowKey) === next)
|
|
108
131
|
continue;
|
|
@@ -150,19 +173,28 @@ export async function watchLocalFeed(options) {
|
|
|
150
173
|
else if (event.type === 'remove') {
|
|
151
174
|
agents.delete(event.rowKey);
|
|
152
175
|
attention.delete(event.rowKey);
|
|
176
|
+
prStatus.delete(event.rowKey);
|
|
153
177
|
}
|
|
154
|
-
pending = pending.then(() => projectSessionEnvelope(event, state)).then((events) => {
|
|
178
|
+
pending = pending.then(() => projectSessionEnvelope(event, state, options.gh)).then((events) => {
|
|
155
179
|
for (const projected of events) {
|
|
156
180
|
if (projected.type === 'reset') {
|
|
157
181
|
attention.clear();
|
|
158
|
-
|
|
182
|
+
prStatus.clear();
|
|
183
|
+
for (const row of projected.agents) {
|
|
159
184
|
attention.set(row.rowKey, '');
|
|
185
|
+
if (row.pr)
|
|
186
|
+
prStatus.set(row.rowKey, JSON.stringify(row.pr));
|
|
187
|
+
}
|
|
160
188
|
for (const item of projected.attention) {
|
|
161
189
|
const row = projected.agents.find((agent) => agent.sessionId === item.sessionId);
|
|
162
190
|
if (row)
|
|
163
191
|
attention.set(row.rowKey, JSON.stringify(item));
|
|
164
192
|
}
|
|
165
193
|
}
|
|
194
|
+
else if (projected.type === 'agent.upsert') {
|
|
195
|
+
if (projected.agent.pr)
|
|
196
|
+
prStatus.set(projected.rowKey, JSON.stringify(projected.agent.pr));
|
|
197
|
+
}
|
|
166
198
|
else if (projected.type === 'attention.upsert')
|
|
167
199
|
attention.set(projected.rowKey, JSON.stringify(projected.attention));
|
|
168
200
|
else if (projected.type === 'attention.remove')
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* helper release now, off this table entirely.
|
|
37
37
|
*/
|
|
38
38
|
export const HELPER_RELEASES = {
|
|
39
|
-
menubar: { tagPrefix: 'menubar', floor: '1.
|
|
39
|
+
menubar: { tagPrefix: 'menubar', floor: '1.2.3' },
|
|
40
40
|
'computer-mac': { tagPrefix: 'computer-mac', floor: '1.0.0' },
|
|
41
41
|
// The Windows helper is a bare .exe, not an .app bundle, so it does not share
|
|
42
42
|
// helper-download.ts's zip/codesign/notarize machinery -- but it has the same
|
|
@@ -68,6 +68,8 @@ export declare function resolveInstalledMenubarExecutable(): string | null;
|
|
|
68
68
|
export declare function menubarDisabledByUser(): boolean;
|
|
69
69
|
/** True if the launchd plist for the menu-bar service is installed. */
|
|
70
70
|
export declare function menubarServiceInstalled(): boolean;
|
|
71
|
+
/** Where a downloaded copy of the floor release sits once fetched and verified. */
|
|
72
|
+
export declare function cachedFloorBundlePath(): string;
|
|
71
73
|
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
72
74
|
export declare function codesignVerifies(appPath: string): boolean;
|
|
73
75
|
/**
|
|
@@ -203,6 +205,33 @@ export declare function isMenubarStale(opts: {
|
|
|
203
205
|
available: MenubarStamp;
|
|
204
206
|
execExists: boolean;
|
|
205
207
|
}): boolean;
|
|
208
|
+
/**
|
|
209
|
+
* Pure decision (no I/O): should the detached background worker download the
|
|
210
|
+
* floor release into the cache so the next startup self-heal has a source?
|
|
211
|
+
*
|
|
212
|
+
* The self-heal (`installMenubarLaunchAgentOnUpgrade`) is deliberately
|
|
213
|
+
* network-free, and the npm tarball ships no bundle, so without this step an
|
|
214
|
+
* `npm i -g` Mac keeps whatever helper it has — a crashing 1.1.2, a dead 0.1.0 —
|
|
215
|
+
* until someone runs `agents menubar setup` by hand. The worker fetches only
|
|
216
|
+
* when the fetch would change something: nothing else can act as a source, the
|
|
217
|
+
* user has not opted out, and either no service exists yet (the auto-enable the
|
|
218
|
+
* bootstrap promises) or the installed helper is older than the floor.
|
|
219
|
+
*/
|
|
220
|
+
export declare function menubarHelperPrefetchNeeded(opts: {
|
|
221
|
+
darwin: boolean;
|
|
222
|
+
disabledByUser: boolean;
|
|
223
|
+
/** `sourceAppPath()` found a bundle (shipped or already cached). */
|
|
224
|
+
hasSource: boolean;
|
|
225
|
+
serviceInstalled: boolean;
|
|
226
|
+
stale: boolean;
|
|
227
|
+
}): boolean;
|
|
228
|
+
/**
|
|
229
|
+
* Background half of the release-path self-heal: download + verify the floor
|
|
230
|
+
* release into the cache when `menubarHelperPrefetchNeeded` says so. Returns
|
|
231
|
+
* the cached path, or null when nothing was fetched. Run from the detached
|
|
232
|
+
* auto-pull worker; the foreground CLI never waits on it.
|
|
233
|
+
*/
|
|
234
|
+
export declare function prefetchMenubarHelper(): Promise<string | null>;
|
|
206
235
|
/**
|
|
207
236
|
* Pure decision (no I/O): did THIS heal actually replace bundle content a live
|
|
208
237
|
* process could be running stale, as opposed to a plist-only repoint?
|
|
@@ -171,6 +171,13 @@ export function menubarServiceInstalled() {
|
|
|
171
171
|
* binary: `import.meta.url` is a virtual `/$bunfs/` path, so the sibling
|
|
172
172
|
* candidates above can't see the on-disk bundle; recover it via the
|
|
173
173
|
* `agents` launcher symlink.
|
|
174
|
+
* 4. the verified download cache for the FLOOR release — the npm tarball ships
|
|
175
|
+
* no bundle (PHNX-4036), so on an `npm i -g` machine this is the only
|
|
176
|
+
* source there is. It is filled by `agents menubar setup` or by the
|
|
177
|
+
* detached background worker (`prefetchMenubarHelper`), never here: this
|
|
178
|
+
* resolver stays network-free so the startup self-heal stays cheap. Last
|
|
179
|
+
* so a bundle that ships with the build always wins; keyed by the floor
|
|
180
|
+
* version so an older cached release is never picked up.
|
|
174
181
|
*/
|
|
175
182
|
function sourceAppPath() {
|
|
176
183
|
const candidates = [];
|
|
@@ -196,8 +203,15 @@ function sourceAppPath() {
|
|
|
196
203
|
if (fs.existsSync(p))
|
|
197
204
|
return p;
|
|
198
205
|
}
|
|
206
|
+
const cached = cachedFloorBundlePath();
|
|
207
|
+
if (fs.existsSync(cached))
|
|
208
|
+
return cached;
|
|
199
209
|
return null;
|
|
200
210
|
}
|
|
211
|
+
/** Where a downloaded copy of the floor release sits once fetched and verified. */
|
|
212
|
+
export function cachedFloorBundlePath() {
|
|
213
|
+
return path.join(menubarHelperCacheDir(helperFloor('menubar')), APP_BUNDLE_NAME);
|
|
214
|
+
}
|
|
201
215
|
/** Resolve the compiled CLI entry (dist/index.js) so the helper can exec node directly. */
|
|
202
216
|
function resolveCliEntry() {
|
|
203
217
|
try {
|
|
@@ -640,6 +654,41 @@ function menubarSetupStale() {
|
|
|
640
654
|
execExists: fs.existsSync(installedExecutablePath()),
|
|
641
655
|
});
|
|
642
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Pure decision (no I/O): should the detached background worker download the
|
|
659
|
+
* floor release into the cache so the next startup self-heal has a source?
|
|
660
|
+
*
|
|
661
|
+
* The self-heal (`installMenubarLaunchAgentOnUpgrade`) is deliberately
|
|
662
|
+
* network-free, and the npm tarball ships no bundle, so without this step an
|
|
663
|
+
* `npm i -g` Mac keeps whatever helper it has — a crashing 1.1.2, a dead 0.1.0 —
|
|
664
|
+
* until someone runs `agents menubar setup` by hand. The worker fetches only
|
|
665
|
+
* when the fetch would change something: nothing else can act as a source, the
|
|
666
|
+
* user has not opted out, and either no service exists yet (the auto-enable the
|
|
667
|
+
* bootstrap promises) or the installed helper is older than the floor.
|
|
668
|
+
*/
|
|
669
|
+
export function menubarHelperPrefetchNeeded(opts) {
|
|
670
|
+
if (!opts.darwin || opts.disabledByUser || opts.hasSource)
|
|
671
|
+
return false;
|
|
672
|
+
return !opts.serviceInstalled || opts.stale;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Background half of the release-path self-heal: download + verify the floor
|
|
676
|
+
* release into the cache when `menubarHelperPrefetchNeeded` says so. Returns
|
|
677
|
+
* the cached path, or null when nothing was fetched. Run from the detached
|
|
678
|
+
* auto-pull worker; the foreground CLI never waits on it.
|
|
679
|
+
*/
|
|
680
|
+
export async function prefetchMenubarHelper() {
|
|
681
|
+
const needed = menubarHelperPrefetchNeeded({
|
|
682
|
+
darwin: onDarwin(),
|
|
683
|
+
disabledByUser: menubarDisabledByUser(),
|
|
684
|
+
hasSource: Boolean(sourceAppPath()),
|
|
685
|
+
serviceInstalled: menubarServiceInstalled(),
|
|
686
|
+
stale: menubarSetupStale(),
|
|
687
|
+
});
|
|
688
|
+
if (!needed)
|
|
689
|
+
return null;
|
|
690
|
+
return downloadMenubarHelperApp(helperFloor('menubar'));
|
|
691
|
+
}
|
|
643
692
|
/**
|
|
644
693
|
* Pure decision (no I/O): did THIS heal actually replace bundle content a live
|
|
645
694
|
* process could be running stale, as opposed to a plist-only repoint?
|
|
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
|
|
|
55
55
|
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
|
|
56
56
|
type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
57
57
|
/** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
|
|
58
|
-
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
|
|
58
|
+
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
|
|
59
59
|
export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
|
|
60
60
|
export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
|
|
61
61
|
export declare function isRunningLiveSession(s: ActiveSession): boolean;
|
|
@@ -174,6 +174,13 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
174
174
|
s.durationMs = m.durationMs;
|
|
175
175
|
if (s.subAgentCount == null && m.subAgentCount != null)
|
|
176
176
|
s.subAgentCount = m.subAgentCount;
|
|
177
|
+
// A pane kept open for days has a start time but no activity signal of its
|
|
178
|
+
// own; the index's last transcript write is what "idle 14d" is read from.
|
|
179
|
+
if (s.lastActivityMs == null && m.lastActivity) {
|
|
180
|
+
const ts = Date.parse(m.lastActivity);
|
|
181
|
+
if (!Number.isNaN(ts))
|
|
182
|
+
s.lastActivityMs = ts;
|
|
183
|
+
}
|
|
177
184
|
}
|
|
178
185
|
}
|
|
179
186
|
function loadBackfillMetaFor(sessions) {
|
|
@@ -56,6 +56,13 @@ export interface StructuredQuestion {
|
|
|
56
56
|
export interface DetectedPr {
|
|
57
57
|
url: string;
|
|
58
58
|
number?: number;
|
|
59
|
+
/** `gh pr view` status, attached by the feed watch when it has fetched it. */
|
|
60
|
+
state?: string;
|
|
61
|
+
isDraft?: boolean;
|
|
62
|
+
reviewDecision?: string;
|
|
63
|
+
mergeable?: string;
|
|
64
|
+
/** The check rollup folded to one verdict. */
|
|
65
|
+
checks?: 'passing' | 'failing' | 'pending';
|
|
59
66
|
}
|
|
60
67
|
export interface DetectedWorktree {
|
|
61
68
|
/** Absolute worktree path (the session cwd). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.102",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|