@phnx-labs/agents-cli 1.22.64 → 1.22.66
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 +46 -0
- package/dist/commands/accounts.d.ts +7 -6
- package/dist/commands/accounts.js +7 -6
- package/dist/commands/apply.js +9 -2
- 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 +35 -8
- package/dist/lib/accounting/rotate.js +37 -10
- package/dist/lib/accounting/usage-sync.js +3 -3
- 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/daemon-ticks.js +6 -2
- package/dist/lib/devices/known-hosts.d.ts +15 -0
- package/dist/lib/devices/known-hosts.js +18 -0
- package/dist/lib/feed-broadcast.d.ts +2 -0
- package/dist/lib/feed-broadcast.js +22 -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/secrets/reserved-sync.js +3 -3
- package/dist/lib/usage-refresh.d.ts +100 -1
- package/dist/lib/usage-refresh.js +195 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.66
|
|
4
|
+
|
|
5
|
+
- **Fleet usage/auth sync now reaches FQDN-only-pinned workers (PHNX-3505).** The
|
|
6
|
+
usage-sync and reserved-auth-sync push/pull planners judged a peer "pinned" by its
|
|
7
|
+
bare device name, but a tailnet-discovered worker's host key is pinned under its
|
|
8
|
+
FQDN (`yosemite-m6.tail….ts.net`). So every worker not also pinned under its short
|
|
9
|
+
name was silently skipped — its `claude-usage.json` went days stale and `balanced`
|
|
10
|
+
routed into weekly-maxed accounts, and its auth token never propagated. The pin
|
|
11
|
+
check now resolves the same host string the ssh connection dials
|
|
12
|
+
(`isDevicePinned`), matching either form. Source:
|
|
13
|
+
`cli/src/lib/devices/known-hosts.ts`, `cli/src/lib/accounting/usage-sync.ts`,
|
|
14
|
+
`cli/src/lib/secrets/reserved-sync.ts`.
|
|
15
|
+
|
|
16
|
+
## 1.22.65
|
|
17
|
+
|
|
18
|
+
- **`agents view` shows the last-known usage number with its age instead of hiding it (PHNX-3453).** When the usage endpoint 429-backs-off an account so a window is not refreshed within its own lifetime (Claude's 5h session window) or a billing period rolls over (Grok's weekly credit window), the view now renders the last reading with a staleness suffix — `S: █▌░░░ 30% · 6h old`, `W: ██▏░░ 42% · stale (period ended 1h ago)` — instead of `S: ┄┄┄┄┄ unavailable` or a numberless `run grok once to refresh usage`. The dropped windows ride a new VIEW-ONLY `UsageSnapshot.staleWindows` field; routing still reads only `windows`, so `isUsageVerified`/`hasStaleUsage`/`deriveUsageStatusFromSnapshot` continue to treat a stale/expired number as unverified and never route on it. Source: `cli/src/lib/accounting/usage.ts`.
|
|
19
|
+
|
|
20
|
+
- **A worker's interactive Claude run no longer lands on the login screen (PHNX-3502).** `claudeAdapter.applyExecConfigEnv` deferred to the per-version native login for EVERY interactive run (`ctx.interactive || headedDevice`), even on a `worker`-role device that has no such login — only a headless run on a worker injected the `auth`-bundle setup-token. A keychain-less worker with no `.credentials.json`/`.oauth_token` therefore got no credential at all on `agents run claude --interactive --device <worker>`, landing on Claude Code's theme-picker/login screen. The condition now keys purely on device role: only a `personal`/`desktop` (headed) device defers to its native login; every worker run, interactive or headless, injects the setup-token. The `credentialPresence` routing gate that feeds `--strategy balanced` is also fixed to stop reading a false-healthy signal: its claude entry now requires the REAL credential (`.credentials.json`/`.oauth_token`, via the same floor `getAccountInfo` applies) rather than the bare presence of `.claude.json` (stale account metadata a version can carry with no usable credential behind it). Relatedly, a provider account folded into the run-candidate pool now derives `signedIn` from its record's `secretPresent` field instead of a hardcoded `true` — keeping that field honest rather than changing today's routing, since `localRegistryRecords` already filters out token-less accounts before the map. Source: `cli/src/lib/harness/adapters/claude.ts`, `cli/src/lib/agent-spec/agents.ts`, `cli/src/lib/accounting/account-pool.ts`, `cli/src/lib/accounting/account-pool-collect.ts`.
|
|
21
|
+
|
|
22
|
+
- **Include clickable ticket links in every feed broadcast (PHNX-3572).** Ticket-backed posts now put the canonical Linear URL in the shared message delivered to private owner destinations and configured Slack channel sinks; repeated URL lines are deduplicated. Source: `cli/src/lib/feed-broadcast.ts`, `cli/src/commands/feed.ts`.
|
|
23
|
+
|
|
24
|
+
- **`agents view <agent> --device <host>` no longer flashes and vanishes from a real terminal (PHNX-3583).** The `--device` passthrough set `interactive = process.stdout.isTTY` and forwarded read-only renders over `ssh -tt` — a forced PTY. On the render's clean exit the PTY teardown + local-terminal restore (`restoreLocalTerminal` / `TERMINAL_MODE_RESET`) wiped the just-drawn output, so the whole view flashed and disappeared, leaving the prior screen. The piped (non-TTY) path never had this problem. Pure read-only render commands (`view`, `inspect`, `insights`, `doctor`) now forward over a plain pipe instead of a PTY, and inject `FORCE_COLOR=1` plus `COLUMNS`/`LINES` into the remote env so the piped render still comes back colored and correctly wrapped (`terminalWidth()` already reads `$COLUMNS` for exactly this reason); `FORCE_COLOR` is withheld under `--json` so machine output stays clean. A render command's narrow interactive sub-path — `view --prune`'s confirm — keeps the PTY, and every genuinely interactive command (`run`, `secrets browse`, pickers) is untouched. Source: `cli/src/lib/hosts/passthrough.ts`, `cli/src/lib/hosts/passthrough.test.ts`.
|
|
25
|
+
|
|
26
|
+
- **Remote provisional Claude tabs reopen once instead of bouncing across the fleet (PHNX-3587).** When VS Code reloaded before an offloaded Claude launch had written its first transcript, `agents sessions resume` found the dispatcher's synthetic session row, hopped to the target, then let the target search the fleet and rediscover the same synthetic row. The repeated routing printed reconnect banners and eventually returned to the shell without Claude. An owner hop now performs one local lookup: it resumes a materialized transcript, or recreates the exact forced Claude session identity when the transcript has not materialized yet. Source: `cli/src/commands/resume.ts`.
|
|
27
|
+
|
|
28
|
+
- **Cap and smooth the daemon's usage-refresh traffic with a per-provider budget.** The
|
|
29
|
+
refresher enforced only a per-account hourly cap, so endpoint load scaled linearly
|
|
30
|
+
with account count: 8 Claude accounts × 12/hr = ~96 usage calls/hr from one box,
|
|
31
|
+
against Anthropic's ~100/hr `/api/oauth/usage` ceiling — which 429'd accounts into
|
|
32
|
+
Retry-After parks (measured on `zion`: 7 of 8 accounts parked, never refreshed in
|
|
33
|
+
their window, so `agents view` showed `S: unavailable` and balanced routing read
|
|
34
|
+
stale usage). A per-provider budget (`PROVIDER_HOURLY_BUDGET`, 30/hr across all of a
|
|
35
|
+
network provider's accounts) now bounds aggregate traffic at a fixed rate that no
|
|
36
|
+
longer grows with account count, paced smoothly (one fetch per ~2 min, round-robin,
|
|
37
|
+
stalest account first) so it never bursts-then-stalls. Actively-used accounts stay
|
|
38
|
+
fresh for free via the statusline ingest, which now also re-derives their headroom
|
|
39
|
+
and suppresses a redundant API refresh. Source: `cli/src/lib/usage-refresh.ts`,
|
|
40
|
+
`cli/src/lib/daemon-ticks.ts`.
|
|
41
|
+
- **Route-refuse only on genuinely stale usage, not merely budget-paced usage.** Because
|
|
42
|
+
proactive refreshes are now paced, an idle account is deliberately refreshed on a
|
|
43
|
+
stretched cadence. Routing keeps its tight 5-min bar for *weighting* an account, but
|
|
44
|
+
the fail-loud `NO_VERIFIED_USAGE` refusal now fires on a wider 40-min bar
|
|
45
|
+
(`USAGE_STALE_REFUSAL_MAX_AGE_MS`) — so a budget-paced fleet routes normally while a
|
|
46
|
+
genuinely broken refresh (hours-old readings) still refuses. Source:
|
|
47
|
+
`cli/src/lib/accounting/rotate.ts`.
|
|
48
|
+
|
|
3
49
|
## 1.22.64
|
|
4
50
|
|
|
5
51
|
- **`agents accounts attach` bootstraps a keychain-less Linux worker (PHNX-3502).**
|
|
@@ -30,12 +30,13 @@ export declare function classifyAttachTarget(target: string): AttachTarget;
|
|
|
30
30
|
/**
|
|
31
31
|
* Persist the attached setup-token to a per-version `.oauth_token` file so an
|
|
32
32
|
* INTERACTIVE Claude launch on a keychain-less Linux worker can authenticate from it.
|
|
33
|
-
* Headless runs inject the token via `buildExecEnv
|
|
34
|
-
*
|
|
35
|
-
* (`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* Headless runs inject the token via `buildExecEnv`. Since PHNX-3502 an interactive
|
|
34
|
+
* launch on a worker-role device ALSO injects it through `claudeAdapter.applyExecConfigEnv`
|
|
35
|
+
* (only a headed `personal`/`desktop` device defers to its per-version native login), and
|
|
36
|
+
* the shim's Linux fallback (`claudeAdapter.shimConfigEnvBash`) reads exactly this file —
|
|
37
|
+
* so writing it is what makes a freshly-attached setup-token visible to that shim fallback
|
|
38
|
+
* on a worker. macOS keeps the credential in the keychain, so `resolveClaudeSetupToken`
|
|
39
|
+
* returns null there and this is a no-op off Linux.
|
|
39
40
|
*/
|
|
40
41
|
export declare function writeClaudeInteractiveOauthToken(target: AttachTarget, targetAgent: AgentId, email?: string): void;
|
|
41
42
|
export declare function parseBundleKey(raw: string): {
|
|
@@ -84,12 +84,13 @@ export function classifyAttachTarget(target) {
|
|
|
84
84
|
/**
|
|
85
85
|
* Persist the attached setup-token to a per-version `.oauth_token` file so an
|
|
86
86
|
* INTERACTIVE Claude launch on a keychain-less Linux worker can authenticate from it.
|
|
87
|
-
* Headless runs inject the token via `buildExecEnv
|
|
88
|
-
*
|
|
89
|
-
* (`
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
87
|
+
* Headless runs inject the token via `buildExecEnv`. Since PHNX-3502 an interactive
|
|
88
|
+
* launch on a worker-role device ALSO injects it through `claudeAdapter.applyExecConfigEnv`
|
|
89
|
+
* (only a headed `personal`/`desktop` device defers to its per-version native login), and
|
|
90
|
+
* the shim's Linux fallback (`claudeAdapter.shimConfigEnvBash`) reads exactly this file —
|
|
91
|
+
* so writing it is what makes a freshly-attached setup-token visible to that shim fallback
|
|
92
|
+
* on a worker. macOS keeps the credential in the keychain, so `resolveClaudeSetupToken`
|
|
93
|
+
* returns null there and this is a no-op off Linux.
|
|
93
94
|
*/
|
|
94
95
|
export function writeClaudeInteractiveOauthToken(target, targetAgent, email) {
|
|
95
96
|
if (process.platform !== 'linux' || targetAgent !== 'claude' || target.kind !== 'installation')
|
package/dist/commands/apply.js
CHANGED
|
@@ -14,7 +14,7 @@ import chalk from 'chalk';
|
|
|
14
14
|
import { setHelpSections } from '../lib/help.js';
|
|
15
15
|
import { machineId } from '../lib/session/sync/config.js';
|
|
16
16
|
import { loadDevices } from '../lib/devices/registry.js';
|
|
17
|
-
import { isHostPinned, managedKnownHostsPath } from '../lib/devices/known-hosts.js';
|
|
17
|
+
import { isHostPinned, isDevicePinned, managedKnownHostsPath } from '../lib/devices/known-hosts.js';
|
|
18
18
|
import { ensureDevicesRegistered } from '../lib/devices/sync.js';
|
|
19
19
|
import { readFleetFile, resolveDesired, emptyTargetsMessage } from '../lib/fleet/manifest.js';
|
|
20
20
|
import { snapshotAuth } from '../lib/fleet/auth-sync.js';
|
|
@@ -233,7 +233,14 @@ async function runApply(opts) {
|
|
|
233
233
|
provisionSecrets: opts.provisionSecrets === true,
|
|
234
234
|
forceSecrets: opts.force === true,
|
|
235
235
|
// Portable secret values only ever go to a host whose key is already pinned.
|
|
236
|
-
|
|
236
|
+
// Resolve the device to its profile so the pin is checked against the FQDN the
|
|
237
|
+
// ssh connection dials (isDevicePinned), not the bare name — a tailnet worker
|
|
238
|
+
// pinned only under its FQDN would otherwise be refused as "not pinned"
|
|
239
|
+
// (PHNX-3505). A name with no profile falls back to the raw host-string check.
|
|
240
|
+
isHostPinned: (device) => {
|
|
241
|
+
const p = nameToProfile.get(device);
|
|
242
|
+
return p ? isDevicePinned(p) : isHostPinned(device, managedKnownHostsPath());
|
|
243
|
+
},
|
|
237
244
|
});
|
|
238
245
|
// --only filter.
|
|
239
246
|
if (opts.only) {
|
package/dist/commands/feed.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified, buildD
|
|
|
3
3
|
import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate, mergeActivityEvents, parseActivityPayload, } from '../lib/feed/activity.js';
|
|
4
4
|
import { projectKeyFromCwd } from '../lib/project-key.js';
|
|
5
5
|
import { postFeedStatus } from '../lib/feed-post.js';
|
|
6
|
+
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
6
7
|
import { parseFeedPostLevel, planFeedBroadcast, runFeedBroadcast, effectiveBroadcastConfig, withDesktopNotify, blockBroadcastContext, blockDeliveryFailure, } from '../lib/feed-broadcast.js';
|
|
7
8
|
import { getSessionById } from '../lib/session/db.js';
|
|
8
9
|
import { readMeta } from '../lib/state.js';
|
|
@@ -697,6 +698,7 @@ async function broadcastPostedEvent(event, level, meta, notify = false) {
|
|
|
697
698
|
text: event.detail ?? '',
|
|
698
699
|
level,
|
|
699
700
|
ticket,
|
|
701
|
+
ticketUrl: linearIssueUrl(ticket),
|
|
700
702
|
project: event.project,
|
|
701
703
|
agent: event.agent,
|
|
702
704
|
host: event.host,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const RESUME_SOURCE_ENV = "AGENTS_RESUME_SOURCE_JSON";
|
|
1
2
|
export interface StrictResumeOptions {
|
|
2
3
|
mode?: string;
|
|
3
4
|
interactive?: boolean;
|
|
@@ -23,6 +24,13 @@ export declare function buildResumeRunArgs(session: {
|
|
|
23
24
|
agent: string;
|
|
24
25
|
version?: string;
|
|
25
26
|
}, prompt: string | undefined, options: StrictResumeOptions): string[];
|
|
27
|
+
/** Recreate a remote Claude launch whose forced id never materialized a transcript. */
|
|
28
|
+
export declare function buildProvisionalRunArgs(session: {
|
|
29
|
+
id: string;
|
|
30
|
+
agent: string;
|
|
31
|
+
version?: string;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
}, prompt: string | undefined, options: StrictResumeOptions): string[];
|
|
26
34
|
/** True when the caller asked for the strict resume path (prompt and/or flags). */
|
|
27
35
|
export declare function wantsStrictResume(prompt: string | undefined, options: StrictResumeOptions): boolean;
|
|
28
36
|
/**
|
package/dist/commands/resume.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn } from 'child_process';
|
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import { resolveSessionMetadataValue } from './sessions.js';
|
|
9
9
|
import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
|
|
10
|
+
export const RESUME_SOURCE_ENV = 'AGENTS_RESUME_SOURCE_JSON';
|
|
10
11
|
/**
|
|
11
12
|
* The argv to re-run this resume on the machine that owns the session.
|
|
12
13
|
*
|
|
@@ -46,6 +47,38 @@ export function buildResumeRunArgs(session, prompt, options) {
|
|
|
46
47
|
args.push('--quiet');
|
|
47
48
|
return args;
|
|
48
49
|
}
|
|
50
|
+
/** Recreate a remote Claude launch whose forced id never materialized a transcript. */
|
|
51
|
+
export function buildProvisionalRunArgs(session, prompt, options) {
|
|
52
|
+
const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
|
|
53
|
+
const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--session-id', session.id];
|
|
54
|
+
if (options.mode)
|
|
55
|
+
args.push('--mode', options.mode);
|
|
56
|
+
if (options.interactive)
|
|
57
|
+
args.push('--interactive');
|
|
58
|
+
if (options.headless)
|
|
59
|
+
args.push('--headless');
|
|
60
|
+
const cwd = options.cwd ?? session.cwd;
|
|
61
|
+
if (cwd)
|
|
62
|
+
args.push('--cwd', cwd);
|
|
63
|
+
if (options.quiet)
|
|
64
|
+
args.push('--quiet');
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
function consumeResumeSource() {
|
|
68
|
+
const raw = process.env[RESUME_SOURCE_ENV];
|
|
69
|
+
delete process.env[RESUME_SOURCE_ENV];
|
|
70
|
+
if (!raw)
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(raw);
|
|
74
|
+
if (typeof value?.id === 'string' && typeof value?.agent === 'string')
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// The routing pin is authoritative; malformed optional context is ignored.
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
49
82
|
/** True when the caller asked for the strict resume path (prompt and/or flags). */
|
|
50
83
|
export function wantsStrictResume(prompt, options) {
|
|
51
84
|
return (prompt !== undefined ||
|
|
@@ -63,8 +96,17 @@ export function wantsStrictResume(prompt, options) {
|
|
|
63
96
|
export async function runStrictResume(sessionId, prompt, options) {
|
|
64
97
|
// Read (and clear) the routing pin before anything else, so it can never
|
|
65
98
|
// reach the agent's own children.
|
|
66
|
-
const
|
|
67
|
-
const
|
|
99
|
+
const routedHop = consumeResumePinned();
|
|
100
|
+
const pinnedHere = routedHop || !!options.here;
|
|
101
|
+
const routedSource = routedHop ? consumeResumeSource() : undefined;
|
|
102
|
+
if (routedSource && routedSource.id !== sessionId.trim()) {
|
|
103
|
+
console.error(chalk.red(`Resume routing metadata names session ${routedSource.id}, not requested session ${sessionId.trim()}.`));
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// An owner hop must inspect only the owner's index. Fleet fan-out here can
|
|
108
|
+
// rediscover the dispatcher's synthetic row and bounce the same id forever.
|
|
109
|
+
const outcome = await resolveSessionMetadataValue(sessionId.trim(), pinnedHere ? { local: true } : {});
|
|
68
110
|
if (outcome.kind === 'partial') {
|
|
69
111
|
// RUSH-2492: an unreachable peer is a warning, not a hard failure. The
|
|
70
112
|
// resolver already resolves an id found on the reachable fleet (SES-9a),
|
|
@@ -78,6 +120,19 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
78
120
|
return;
|
|
79
121
|
}
|
|
80
122
|
if (outcome.kind === 'not-found') {
|
|
123
|
+
if (routedHop && routedSource?.filePath === '' && routedSource.agent === 'claude') {
|
|
124
|
+
const args = buildProvisionalRunArgs(routedSource, prompt, options);
|
|
125
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
126
|
+
stdio: 'inherit',
|
|
127
|
+
env: process.env,
|
|
128
|
+
});
|
|
129
|
+
const exitCode = await new Promise((resolve) => {
|
|
130
|
+
child.once('error', () => resolve(127));
|
|
131
|
+
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
132
|
+
});
|
|
133
|
+
process.exitCode = exitCode;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
81
136
|
console.error(chalk.red(`No session matching "${sessionId}".`));
|
|
82
137
|
process.exitCode = 1;
|
|
83
138
|
return;
|
|
@@ -102,7 +157,14 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
102
157
|
// one re-discovers locally and marks the run AGENTS_FLEET_REMOTE, which
|
|
103
158
|
// a long-lived resumed session must not inherit.
|
|
104
159
|
const { runOnPeer } = await import('../lib/session/remote-list.js');
|
|
105
|
-
const rc = await runOnPeer(buildResumeRemoteArgs(outcome.session.id, prompt, options), owner, {
|
|
160
|
+
const rc = await runOnPeer(buildResumeRemoteArgs(outcome.session.id, prompt, options), owner, {
|
|
161
|
+
tty: !!process.stdout.isTTY,
|
|
162
|
+
env: {
|
|
163
|
+
[RESUME_PINNED_ENV]: '1',
|
|
164
|
+
[RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
|
|
165
|
+
},
|
|
166
|
+
sessionId: outcome.session.id,
|
|
167
|
+
});
|
|
106
168
|
if (rc === 'no-target') {
|
|
107
169
|
console.error(chalk.red(`Session ${outcome.session.shortId} lives on ${owner}, which isn't a reachable device right now.`));
|
|
108
170
|
console.error(chalk.gray(`Register/wake it (agents devices), or run there: agents ssh ${owner}`));
|
|
@@ -118,7 +180,7 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
118
180
|
// Avoid repeating the fleet lookup in the delegated local `run`
|
|
119
181
|
// process. The value is metadata-only and is not forwarded over SSH;
|
|
120
182
|
// the owner performs its own local SQLite lookup.
|
|
121
|
-
|
|
183
|
+
[RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
|
|
122
184
|
},
|
|
123
185
|
});
|
|
124
186
|
const exitCode = await new Promise((resolve) => {
|
|
@@ -13,7 +13,7 @@ import { registryPoolCandidates } from './account-pool.js';
|
|
|
13
13
|
function localRegistryRecords() {
|
|
14
14
|
return Object.values(readAccountRegistry().accounts)
|
|
15
15
|
.filter((a) => hasKeychainToken(a.secretRef))
|
|
16
|
-
.map((a) => ({ name: a.name, provider: a.provider, auth: a.auth }));
|
|
16
|
+
.map((a) => ({ name: a.name, provider: a.provider, auth: a.auth, secretPresent: true }));
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* PURE: fold provider-account candidates into the native version-home list.
|
|
@@ -45,7 +45,12 @@ export function foldRegistryCandidates(agent, inputs) {
|
|
|
45
45
|
usageError: null,
|
|
46
46
|
usageMinutesToLimit: null,
|
|
47
47
|
plan: null,
|
|
48
|
-
|
|
48
|
+
// Derive from the record's real secret-presence field rather than a bare
|
|
49
|
+
// `true` literal (PHNX-3502). Inert on today's only producer
|
|
50
|
+
// (`localRegistryRecords` already filters token-less accounts before the
|
|
51
|
+
// map), but keeps the field honest for any injected record that carries
|
|
52
|
+
// `secretPresent: false`.
|
|
53
|
+
signedIn: r.secretPresent,
|
|
49
54
|
authVerdict: null,
|
|
50
55
|
lastActive: null,
|
|
51
56
|
providerAccount: r.name,
|
|
@@ -16,6 +16,16 @@ export interface RegistryAccountRecord {
|
|
|
16
16
|
name: string;
|
|
17
17
|
provider: string;
|
|
18
18
|
auth: AccountAuthKind;
|
|
19
|
+
/**
|
|
20
|
+
* Whether this account's secret is actually present on THIS device
|
|
21
|
+
* (`hasKeychainToken(secretRef)`, checked by the record's builder). Carried
|
|
22
|
+
* through rather than assumed, so a candidate's `signedIn` reflects a real
|
|
23
|
+
* check instead of a literal disconnected from it (PHNX-3502) — a registry
|
|
24
|
+
* entry can exist with no local secret (added on another device, or
|
|
25
|
+
* revoked), and folding it in as unconditionally signed-in would let
|
|
26
|
+
* `--strategy balanced` pick an account that fails at spawn.
|
|
27
|
+
*/
|
|
28
|
+
secretPresent: boolean;
|
|
19
29
|
}
|
|
20
30
|
/** A registry account eligible to run one harness, ready to map to a candidate. */
|
|
21
31
|
export interface RegistryAccountInput {
|
|
@@ -25,6 +35,8 @@ export interface RegistryAccountInput {
|
|
|
25
35
|
name: string;
|
|
26
36
|
provider: string;
|
|
27
37
|
auth: AccountAuthKind;
|
|
38
|
+
/** See {@link RegistryAccountRecord.secretPresent}. */
|
|
39
|
+
secretPresent: boolean;
|
|
28
40
|
}
|
|
29
41
|
/**
|
|
30
42
|
* Which provider accounts can authenticate `agent`. An account is a candidate for
|
|
@@ -163,6 +163,27 @@ export declare function isVersionLaunchableHere(agent: AgentId, version: string)
|
|
|
163
163
|
* launched into it while the account was actually at its weekly cap.
|
|
164
164
|
*/
|
|
165
165
|
export declare const USAGE_DECISION_MAX_AGE_MS: number;
|
|
166
|
+
/**
|
|
167
|
+
* How old a usage snapshot may be before routing REFUSES to run at all
|
|
168
|
+
* (NO_VERIFIED_USAGE), as opposed to merely declining to *weight* by its number.
|
|
169
|
+
*
|
|
170
|
+
* These are two different risks and now two different bars. Weighting on a
|
|
171
|
+
* slightly-old number is cheap to get wrong (a floored weight, {@link
|
|
172
|
+
* USAGE_DECISION_MAX_AGE_MS} = 5 min); refusing to launch at all is expensive to
|
|
173
|
+
* get wrong — it fails the user's `agents run` outright. The daemon's usage
|
|
174
|
+
* refresher paces proactive fetches under a fixed per-provider budget
|
|
175
|
+
* (`usage-refresh.ts`, PROVIDER_HOURLY_BUDGET), so on a multi-account fleet an
|
|
176
|
+
* IDLE account is deliberately refreshed on a stretched round-robin cadence
|
|
177
|
+
* (bounded at N × spacing — ~16 min at 8 accounts, ~32 min at 16) rather than
|
|
178
|
+
* every 5 min, which would 429 the endpoint and park it for up to an hour. A
|
|
179
|
+
* budget-paced idle reading of 10–30 min is NOT the failure this refusal exists
|
|
180
|
+
* to catch. That failure is the `yosemite-s1` case: a box whose refresh is
|
|
181
|
+
* genuinely BROKEN, holding readings 26 h – 2.7 d old. 40 min sits comfortably
|
|
182
|
+
* above the worst-case budget cadence and still an order of magnitude below the
|
|
183
|
+
* multi-hour staleness of a broken box — and actively-used accounts refresh for
|
|
184
|
+
* free via the statusline ingest, so a *busy* account is never even this old.
|
|
185
|
+
*/
|
|
186
|
+
export declare const USAGE_STALE_REFUSAL_MAX_AGE_MS: number;
|
|
166
187
|
/**
|
|
167
188
|
* Whether this candidate's usage number is recent enough to route on. A missing
|
|
168
189
|
* snapshot is unverified by definition — there is no number to trust.
|
|
@@ -178,17 +199,23 @@ export declare const USAGE_DECISION_MAX_AGE_MS: number;
|
|
|
178
199
|
*/
|
|
179
200
|
export declare function isUsageVerified(candidate: RotateCandidate, nowMs?: number): boolean;
|
|
180
201
|
/**
|
|
181
|
-
* Whether this candidate carries a STALE
|
|
182
|
-
*
|
|
202
|
+
* Whether this candidate carries a GENUINELY-STALE usage number: a snapshot with
|
|
203
|
+
* windows whose capture time is older than {@link USAGE_STALE_REFUSAL_MAX_AGE_MS}.
|
|
183
204
|
*
|
|
184
205
|
* This is the misleading case the initial route must refuse — the number reads
|
|
185
206
|
* "48% used" with the same confidence whether captured a minute or three days
|
|
186
|
-
* ago, and a box whose refresh is failing stays wrong indefinitely.
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
207
|
+
* ago, and a box whose refresh is failing stays wrong indefinitely. Two things
|
|
208
|
+
* make it NARROWER than "not verified":
|
|
209
|
+
* 1. It uses the wider REFUSAL bar, not the 5-min weighting bar. A merely
|
|
210
|
+
* budget-paced idle account (10–30 min old) is not-verified — so it weights
|
|
211
|
+
* at the floor, conservatively — but it is NOT "stale" and must not, on its
|
|
212
|
+
* own, drive the whole provider to a NO_VERIFIED_USAGE refusal. Only a
|
|
213
|
+
* genuinely broken refresh (hours old) trips this.
|
|
214
|
+
* 2. A BLIND candidate with no snapshot (or a plan-only meterless one with no
|
|
215
|
+
* windows) carries no number to be misled by — a worker box whose usage
|
|
216
|
+
* endpoint 403s (RUSH-2392), or a meterless Grok login — so it is not
|
|
217
|
+
* "stale", and an entirely-blind pool still draws a pick (PHNX-3392) rather
|
|
218
|
+
* than fail loud with NO_VERIFIED_USAGE.
|
|
192
219
|
*/
|
|
193
220
|
export declare function hasStaleUsage(candidate: RotateCandidate, nowMs?: number): boolean;
|
|
194
221
|
/**
|
|
@@ -131,6 +131,27 @@ function isAvailableEligible(candidate) {
|
|
|
131
131
|
* launched into it while the account was actually at its weekly cap.
|
|
132
132
|
*/
|
|
133
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;
|
|
134
155
|
/**
|
|
135
156
|
* Whether this candidate's usage number is recent enough to route on. A missing
|
|
136
157
|
* snapshot is unverified by definition — there is no number to trust.
|
|
@@ -152,24 +173,30 @@ export function isUsageVerified(candidate, nowMs = Date.now()) {
|
|
|
152
173
|
return nowMs - capturedAt.getTime() <= USAGE_DECISION_MAX_AGE_MS;
|
|
153
174
|
}
|
|
154
175
|
/**
|
|
155
|
-
* Whether this candidate carries a STALE
|
|
156
|
-
*
|
|
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}.
|
|
157
178
|
*
|
|
158
179
|
* This is the misleading case the initial route must refuse — the number reads
|
|
159
180
|
* "48% used" with the same confidence whether captured a minute or three days
|
|
160
|
-
* ago, and a box whose refresh is failing stays wrong indefinitely.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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.
|
|
166
193
|
*/
|
|
167
194
|
export function hasStaleUsage(candidate, nowMs = Date.now()) {
|
|
168
195
|
const snapshot = candidate.usageSnapshot;
|
|
169
196
|
const capturedAt = snapshot?.capturedAt;
|
|
170
197
|
if (!capturedAt || !snapshot?.windows.length)
|
|
171
198
|
return false;
|
|
172
|
-
return nowMs - capturedAt.getTime() >
|
|
199
|
+
return nowMs - capturedAt.getTime() > USAGE_STALE_REFUSAL_MAX_AGE_MS;
|
|
173
200
|
}
|
|
174
201
|
function hasUsageAvailable(candidate) {
|
|
175
202
|
const snapshot = candidate.usageSnapshot;
|
|
@@ -629,7 +656,7 @@ export function formatNoVerifiedUsageError(agent, strategy, candidates, nowMs =
|
|
|
629
656
|
const staleness = age === null ? 'no usage snapshot' : `usage ${age}m old`;
|
|
630
657
|
return `${c.version} (${staleness})`;
|
|
631
658
|
}).join(', ');
|
|
632
|
-
const maxAgeMin = Math.round(
|
|
659
|
+
const maxAgeMin = Math.round(USAGE_STALE_REFUSAL_MAX_AGE_MS / 60_000);
|
|
633
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.`;
|
|
634
661
|
}
|
|
635
662
|
/**
|
|
@@ -29,7 +29,7 @@ import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShel
|
|
|
29
29
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
30
30
|
import { loadDevicesSync } from '../devices/registry.js';
|
|
31
31
|
import { sshTargetFor } from '../devices/connect.js';
|
|
32
|
-
import { isHostPinned } from '../devices/known-hosts.js';
|
|
32
|
+
import { isHostPinned, isDevicePinned } from '../devices/known-hosts.js';
|
|
33
33
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
34
34
|
import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
|
|
35
35
|
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
|
|
@@ -154,7 +154,7 @@ export function pullUsageFromPrimary(deps = {}) {
|
|
|
154
154
|
const primary = devices
|
|
155
155
|
.filter((device) => roles[device.name] === 'personal')
|
|
156
156
|
.concat(devices.filter((device) => roles[device.name] === 'desktop'))
|
|
157
|
-
.find((device) => device.tailscale?.online !== false &&
|
|
157
|
+
.find((device) => device.tailscale?.online !== false && isDevicePinned(device, isPinned));
|
|
158
158
|
if (!primary) {
|
|
159
159
|
result.error = 'no reachable, pinned personal or desktop device is configured as the usage primary';
|
|
160
160
|
return result;
|
|
@@ -205,7 +205,7 @@ export function syncFleetUsageSnapshots(deps = {}) {
|
|
|
205
205
|
name: d.name,
|
|
206
206
|
role: roles[d.name],
|
|
207
207
|
online: d.tailscale?.online !== false,
|
|
208
|
-
pinned:
|
|
208
|
+
pinned: isDevicePinned(d, isPinned),
|
|
209
209
|
}));
|
|
210
210
|
const rows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
|
|
211
211
|
const plan = planUsagePush(isHeadedDeviceRole(selfRole), Object.keys(rows).length > 0, targets);
|
|
@@ -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;
|