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