@phnx-labs/agents-cli 1.22.105 → 1.22.106
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 +6 -0
- package/README.md +19 -1
- package/dist/browser.js +0 -0
- package/dist/commands/exec.js +135 -233
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/sessions-resume.d.ts +4 -0
- package/dist/commands/sessions-resume.js +132 -49
- package/dist/commands/sessions.js +32 -5
- package/dist/index.js +0 -0
- package/dist/lib/accounting/account-launch.d.ts +54 -0
- package/dist/lib/accounting/account-launch.js +117 -0
- package/dist/lib/accounting/account-pool-collect.js +2 -1
- package/dist/lib/accounting/account-pool.d.ts +2 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +32 -6
- package/dist/lib/accounting/rotate.js +70 -42
- package/dist/lib/accounting/usage.d.ts +71 -0
- package/dist/lib/accounting/usage.js +160 -11
- package/dist/lib/exec-account-home.d.ts +3 -1
- package/dist/lib/exec-account-home.js +2 -2
- package/dist/lib/exec.d.ts +27 -1
- package/dist/lib/exec.js +150 -27
- package/dist/lib/models.d.ts +1 -1
- package/dist/lib/models.js +4 -4
- package/dist/lib/session/actor-sidecar.d.ts +3 -11
- package/dist/lib/session/actor-sidecar.js +3 -0
- package/dist/lib/session/claude-accounts.d.ts +12 -73
- package/dist/lib/session/claude-accounts.js +32 -70
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +18 -5
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +116 -15
- package/dist/lib/session/recovery.d.ts +30 -34
- package/dist/lib/session/recovery.js +212 -76
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/teams/placement-probe.js +1 -1
- package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
- package/dist/session-tracker/dist/adapters/claude.js +45 -0
- package/dist/session-tracker/dist/hook.sh +191 -0
- package/dist/session-tracker/dist/index.d.ts +19 -0
- package/dist/session-tracker/dist/index.js +67 -0
- package/dist/session-tracker/dist/install-hook.d.ts +19 -0
- package/dist/session-tracker/dist/install-hook.js +245 -0
- package/dist/session-tracker/dist/prune-state.d.ts +2 -0
- package/dist/session-tracker/dist/prune-state.js +7 -0
- package/dist/session-tracker/dist/reader.d.ts +7 -0
- package/dist/session-tracker/dist/reader.js +151 -0
- package/dist/session-tracker/dist/state-file.d.ts +10 -0
- package/dist/session-tracker/dist/state-file.js +119 -0
- package/dist/session-tracker/dist/types.d.ts +32 -0
- package/dist/session-tracker/dist/types.js +1 -0
- package/dist/session-tracker/dist/writer.d.ts +12 -0
- package/dist/session-tracker/dist/writer.js +27 -0
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical target-local account launch resolution (PHNX-3940).
|
|
3
|
+
*
|
|
4
|
+
* Callers may carry a selector or a {@link RotateCandidate} across a routing
|
|
5
|
+
* boundary. This module resolves that intent on the machine that will spawn the
|
|
6
|
+
* harness, where account slots and credential stores actually live. The result
|
|
7
|
+
* is deliberately local-only: `execHome` and `env` must never cross SSH or be
|
|
8
|
+
* written to events.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import { resolveSpawnAccount } from '../account-registry.js';
|
|
12
|
+
import { adoptedConfigPointsAtHome, adoptedSymlinkMismatchError, durableSlotEnv, isSymlinkAdoptedHarness, resolveNativeSpawnHome, symlinkAdoptedAccountError, } from '../exec-account-home.js';
|
|
13
|
+
import { getVersionHomePath } from '../installations/versions.js';
|
|
14
|
+
import { readMeta } from '../state.js';
|
|
15
|
+
import { candidateAccountKey } from './rotate.js';
|
|
16
|
+
function launchAccountFromSpawn(account) {
|
|
17
|
+
return {
|
|
18
|
+
kind: account.kind,
|
|
19
|
+
id: account.id,
|
|
20
|
+
name: account.name,
|
|
21
|
+
selector: account.name,
|
|
22
|
+
key: `${account.kind}:${account.id}`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function candidateSelector(candidate) {
|
|
26
|
+
return candidate.nativeAccount ?? candidate.providerAccount;
|
|
27
|
+
}
|
|
28
|
+
function legacyAccount(candidate) {
|
|
29
|
+
const key = candidateAccountKey(candidate);
|
|
30
|
+
const name = candidate.accountLabel || candidate.email || key;
|
|
31
|
+
return {
|
|
32
|
+
kind: 'legacy-native',
|
|
33
|
+
id: candidate.accountKey ?? candidate.email ?? key,
|
|
34
|
+
name,
|
|
35
|
+
selector: candidate.email ?? candidate.accountKey ?? name,
|
|
36
|
+
key,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve one account-specific attempt at the local spawn boundary.
|
|
41
|
+
*
|
|
42
|
+
* Native selections resolve the registered slot and harness-specific home;
|
|
43
|
+
* provider selections materialize their credential env exactly here. An
|
|
44
|
+
* unmigrated version-home candidate remains a compatibility case, but it is
|
|
45
|
+
* never rediscovered by scanning homes for identity.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveLocalAccountLaunch(options) {
|
|
48
|
+
const meta = options.meta ?? readMeta();
|
|
49
|
+
const selected = options.candidate;
|
|
50
|
+
const selector = options.selector ?? (selected ? candidateSelector(selected) : undefined);
|
|
51
|
+
// Compatibility for a candidate already discovered in an unmigrated home.
|
|
52
|
+
// The candidate is the identity decision; the label only locates that exact
|
|
53
|
+
// local config home and never participates in account comparison.
|
|
54
|
+
if (selected && !selector) {
|
|
55
|
+
const execHome = getVersionHomePath(options.agent, selected.version);
|
|
56
|
+
if (!fs.existsSync(execHome)) {
|
|
57
|
+
throw new Error(`${selected.accountLabel || options.agent} has no local account home at ${execHome}.`);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
agent: options.agent,
|
|
61
|
+
executableVersion: options.executableVersion,
|
|
62
|
+
account: legacyAccount(selected),
|
|
63
|
+
execHome,
|
|
64
|
+
configVersion: selected.version,
|
|
65
|
+
env: {},
|
|
66
|
+
signedIn: selected.signedIn,
|
|
67
|
+
email: selected.email,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const spawnAccount = resolveSpawnAccount(selector, options.agent, options.executableVersion, meta, {
|
|
71
|
+
useDefault: options.useDefault,
|
|
72
|
+
provider: options.provider,
|
|
73
|
+
target: options.target,
|
|
74
|
+
});
|
|
75
|
+
if (!spawnAccount) {
|
|
76
|
+
return {
|
|
77
|
+
agent: options.agent,
|
|
78
|
+
executableVersion: options.executableVersion,
|
|
79
|
+
account: null,
|
|
80
|
+
env: {},
|
|
81
|
+
signedIn: selected?.signedIn ?? null,
|
|
82
|
+
email: selected?.email ?? null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const account = launchAccountFromSpawn(spawnAccount);
|
|
86
|
+
if (spawnAccount.kind === 'provider') {
|
|
87
|
+
return {
|
|
88
|
+
agent: options.agent,
|
|
89
|
+
executableVersion: options.executableVersion,
|
|
90
|
+
account,
|
|
91
|
+
env: spawnAccount.env,
|
|
92
|
+
signedIn: selected?.signedIn ?? true,
|
|
93
|
+
email: selected?.email ?? null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (isSymlinkAdoptedHarness(options.agent)) {
|
|
97
|
+
const defaultName = meta.accounts?.defaults?.[options.agent];
|
|
98
|
+
if (spawnAccount.name !== defaultName) {
|
|
99
|
+
throw new Error(symlinkAdoptedAccountError(options.agent, spawnAccount.name, defaultName));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const home = await resolveNativeSpawnHome(options.agent, spawnAccount, meta);
|
|
103
|
+
if (isSymlinkAdoptedHarness(options.agent)
|
|
104
|
+
&& !adoptedConfigPointsAtHome(options.agent, home.execHome)) {
|
|
105
|
+
throw new Error(adoptedSymlinkMismatchError(options.agent, spawnAccount.name, home.execHome));
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
agent: options.agent,
|
|
109
|
+
executableVersion: options.executableVersion,
|
|
110
|
+
account,
|
|
111
|
+
execHome: home.execHome,
|
|
112
|
+
configVersion: home.source === 'legacy-home' ? home.label : undefined,
|
|
113
|
+
env: durableSlotEnv(options.agent, spawnAccount, home, meta),
|
|
114
|
+
signedIn: selected?.signedIn ?? null,
|
|
115
|
+
email: selected?.email ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -19,7 +19,7 @@ function localRegistryRecords() {
|
|
|
19
19
|
try {
|
|
20
20
|
return Object.values(readAccountRegistry().accounts)
|
|
21
21
|
.filter((a) => hasKeychainTokenSync(a.secretRef))
|
|
22
|
-
.map((a) => ({ name: a.name, provider: a.provider, auth: a.auth, secretPresent: true }));
|
|
22
|
+
.map((a) => ({ id: a.id, name: a.name, provider: a.provider, auth: a.auth, secretPresent: true }));
|
|
23
23
|
}
|
|
24
24
|
catch (err) {
|
|
25
25
|
if (isSecretsTransportError(err))
|
|
@@ -66,6 +66,7 @@ export function foldRegistryCandidates(agent, inputs) {
|
|
|
66
66
|
authVerdict: null,
|
|
67
67
|
lastActive: null,
|
|
68
68
|
providerAccount: r.name,
|
|
69
|
+
providerAccountId: r.id,
|
|
69
70
|
}));
|
|
70
71
|
return [...native, ...extra];
|
|
71
72
|
}
|
|
@@ -13,6 +13,7 @@ import type { AccountAuthKind } from '../account-provider-registry.js';
|
|
|
13
13
|
*/
|
|
14
14
|
/** A provider account record as stored in the registry (identity captured separately). */
|
|
15
15
|
export interface RegistryAccountRecord {
|
|
16
|
+
id?: string;
|
|
16
17
|
name: string;
|
|
17
18
|
provider: string;
|
|
18
19
|
auth: AccountAuthKind;
|
|
@@ -29,6 +30,7 @@ export interface RegistryAccountRecord {
|
|
|
29
30
|
}
|
|
30
31
|
/** A registry account eligible to run one harness, ready to map to a candidate. */
|
|
31
32
|
export interface RegistryAccountInput {
|
|
33
|
+
id?: string;
|
|
32
34
|
/** Agent-scoped key so `(claude, X)` and `(codex, X)` stay distinct. */
|
|
33
35
|
accountKey: string;
|
|
34
36
|
email: string | null;
|
|
@@ -68,6 +68,9 @@ export interface RotateCandidate {
|
|
|
68
68
|
* binary (managed install) and is no longer the account identity.
|
|
69
69
|
*/
|
|
70
70
|
nativeAccount?: string;
|
|
71
|
+
/** Stable registry id for {@link nativeAccount}; never inferred from version. */
|
|
72
|
+
nativeAccountId?: string;
|
|
73
|
+
providerAccountId?: string;
|
|
71
74
|
/** Slot dir when this candidate is a slot — the spawn HOME. */
|
|
72
75
|
slotDir?: string;
|
|
73
76
|
/** True when this row came from `deviceAccounts.slots`, not a version home. */
|
|
@@ -242,7 +245,7 @@ export type AccountReadiness = {
|
|
|
242
245
|
ready: true;
|
|
243
246
|
} | {
|
|
244
247
|
ready: false;
|
|
245
|
-
reason: 'rate_limited' | 'out_of_credits' | 'signed_out' | 'revoked';
|
|
248
|
+
reason: 'rate_limited' | 'out_of_credits' | 'signed_out' | 'revoked' | 'model_limited';
|
|
246
249
|
email: string | null;
|
|
247
250
|
};
|
|
248
251
|
/**
|
|
@@ -254,8 +257,19 @@ export type AccountReadiness = {
|
|
|
254
257
|
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
255
258
|
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
256
259
|
* reported while the account is actually serving requests.
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
*
|
|
261
|
+
* `model`, when supplied, additionally consults a per-(account, model)
|
|
262
|
+
* refusal Claude can surface on ONE model family ("You've reached your Fable
|
|
263
|
+
* limit…") while the account's other models and its global usage windows stay
|
|
264
|
+
* healthy — a global rate_limited/out_of_credits marker would wrongly exclude
|
|
265
|
+
* the whole account for an unrelated model. Keyed on the candidate's stable
|
|
266
|
+
* native account id (never the org-shared usageKey) via
|
|
267
|
+
* {@link candidateAccountKey}, so a model-only limit on one login can never
|
|
268
|
+
* poison a sibling account that merely shares the same org usage bucket.
|
|
269
|
+
* Omitting `model` (every existing caller) leaves generic account status
|
|
270
|
+
* completely unaffected — the model check runs only when a caller opts in.
|
|
271
|
+
*/
|
|
272
|
+
export declare function readinessFromCandidate(candidate: RotateCandidate, now?: number, model?: string): AccountReadiness;
|
|
259
273
|
/**
|
|
260
274
|
* Whether a human sitting at a terminal can clear this exclusion by launching
|
|
261
275
|
* the agent and signing in. The two unhealthy classes are opposites, and the
|
|
@@ -287,6 +301,14 @@ export declare function signInRecoverableCandidates(candidates: RotateCandidate[
|
|
|
287
301
|
* than the version home carries), so neither is checkable here.
|
|
288
302
|
*/
|
|
289
303
|
export declare function checkRunAccountReadiness(agent: AgentId, version: string): Promise<AccountReadiness>;
|
|
304
|
+
/**
|
|
305
|
+
* Identity a candidate dedups on. Quota is tracked per-org, so two versions
|
|
306
|
+
* that share an org are the same rate-limit bucket and must collapse — but two
|
|
307
|
+
* orgs under the same email (e.g. Enterprise + Personal on one Google identity)
|
|
308
|
+
* are genuinely separate buckets and must stay distinct. Prefer the org usage
|
|
309
|
+
* key; fall back to email only when no usage identity is available.
|
|
310
|
+
*/
|
|
311
|
+
export declare function candidateAccountKey(c: RotateCandidate): string;
|
|
290
312
|
/**
|
|
291
313
|
* Pick a healthy candidate using weighted random by remaining capacity.
|
|
292
314
|
*
|
|
@@ -311,15 +333,19 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
|
|
|
311
333
|
*
|
|
312
334
|
* Returns null if no candidate is eligible — callers fall back to the pinned
|
|
313
335
|
* version so behavior stays predictable.
|
|
336
|
+
*
|
|
337
|
+
* `model`, when supplied, additionally excludes an account carrying a live
|
|
338
|
+
* per-model refusal for that exact model (see {@link readinessFromCandidate}).
|
|
339
|
+
* Omitted (every pre-existing caller), eligibility is unchanged.
|
|
314
340
|
*/
|
|
315
|
-
export declare function pickBalancedCandidate(candidates: RotateCandidate[], nowMs?: number): RotateResult | null;
|
|
341
|
+
export declare function pickBalancedCandidate(candidates: RotateCandidate[], nowMs?: number, model?: string): RotateResult | null;
|
|
316
342
|
export { PROJECTION_HORIZON_MIN, capacityWeight };
|
|
317
343
|
/**
|
|
318
344
|
* Pick an available candidate. Prefers the configured pinned version when that
|
|
319
345
|
* version has usage available; otherwise routes to the candidate with the most
|
|
320
346
|
* usage headroom.
|
|
321
347
|
*/
|
|
322
|
-
export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null, nowMs?: number): RotateResult | null;
|
|
348
|
+
export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null, nowMs?: number, model?: string): RotateResult | null;
|
|
323
349
|
/**
|
|
324
350
|
* Per-harness routing summary for `agents run auto` — the cross-harness layer
|
|
325
351
|
* that sits above `pickBalancedCandidate` (which is strictly per-harness).
|
|
@@ -463,7 +489,7 @@ export declare function buildRotationDecisionEvent(rotation: RotateResult, agent
|
|
|
463
489
|
* else is healthy, `exhausted` is set so the caller fails loud instead of
|
|
464
490
|
* spawning into a credential-less home.
|
|
465
491
|
*/
|
|
466
|
-
export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]
|
|
492
|
+
export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]>, model?: string): Promise<{
|
|
467
493
|
version: string | null;
|
|
468
494
|
rotation: RotateResult | null;
|
|
469
495
|
/**
|
|
@@ -9,16 +9,18 @@ import * as path from 'path';
|
|
|
9
9
|
import { PROJECTION_HORIZON_MIN, capacityWeight } from './capacity.js';
|
|
10
10
|
import { accountDisplayLabel, getAccountInfo, credentialPresence, ALL_AGENT_IDS, } from '../agents.js';
|
|
11
11
|
import { readMeta, writeMeta, getHelpersDir } from '../state.js';
|
|
12
|
+
import { resolveConfiguredModel } from '../models.js';
|
|
13
|
+
import { isTierToken, resolveTier } from '../model-tiers.js';
|
|
12
14
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from '../installations/versions.js';
|
|
13
15
|
import { resolveManagedInstallation } from '../installations/store.js';
|
|
14
16
|
import { listNativeAccounts } from '../account-registry.js';
|
|
15
|
-
import {
|
|
17
|
+
import { resolveNativeSpawnHome } from '../exec-account-home.js';
|
|
16
18
|
import { getProjectRunConfigs } from '../run-config.js';
|
|
17
19
|
import { emit } from '../feed/events.js';
|
|
18
|
-
import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
|
|
20
|
+
import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, getClaudeModelRefusal, claudeModelRefusalKey, } from './usage.js';
|
|
19
21
|
import { readAccountHeadroom } from '../fleet-cache.js';
|
|
20
22
|
import { machineId } from '../machine-id.js';
|
|
21
|
-
import { AUTH_PROBE_MAX_AGE_MS, readAuthHealthCache, authCacheKey, isDeadVerdict } from '../auth-health.js';
|
|
23
|
+
import { AUTH_PROBE_MAX_AGE_MS, readAuthHealthCache, authCacheKey, slotAuthVersionKey, isDeadVerdict } from '../auth-health.js';
|
|
22
24
|
function getRotateDir() {
|
|
23
25
|
const dir = path.join(getHelpersDir(), 'rotate');
|
|
24
26
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -82,10 +84,10 @@ const LAUNCHABLE_SLOT_VERDICTS = new Set(['live', 'unverified']);
|
|
|
82
84
|
function isLaunchableSlotVerdict(verdict) {
|
|
83
85
|
return verdict !== null && LAUNCHABLE_SLOT_VERDICTS.has(verdict);
|
|
84
86
|
}
|
|
85
|
-
function isRotationEligible(candidate) {
|
|
87
|
+
function isRotationEligible(candidate, nowMs = Date.now(), model) {
|
|
86
88
|
if (candidate.fromSlot && !isLaunchableSlotVerdict(candidate.authVerdict))
|
|
87
89
|
return false;
|
|
88
|
-
return readinessFromCandidate(candidate).ready;
|
|
90
|
+
return readinessFromCandidate(candidate, nowMs, model).ready;
|
|
89
91
|
}
|
|
90
92
|
/**
|
|
91
93
|
* Whether a version home can actually authenticate a launch.
|
|
@@ -128,9 +130,6 @@ export async function isVersionLaunchableHere(agent, version) {
|
|
|
128
130
|
const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
|
|
129
131
|
return { launchable, email: launchable ? info.email : null };
|
|
130
132
|
}
|
|
131
|
-
function isAvailableEligible(candidate) {
|
|
132
|
-
return isRotationEligible(candidate);
|
|
133
|
-
}
|
|
134
133
|
/**
|
|
135
134
|
* How old a usage snapshot may be and still settle a routing DECISION.
|
|
136
135
|
*
|
|
@@ -238,8 +237,19 @@ function hasUsageAvailable(candidate) {
|
|
|
238
237
|
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
239
238
|
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
240
239
|
* reported while the account is actually serving requests.
|
|
240
|
+
*
|
|
241
|
+
* `model`, when supplied, additionally consults a per-(account, model)
|
|
242
|
+
* refusal Claude can surface on ONE model family ("You've reached your Fable
|
|
243
|
+
* limit…") while the account's other models and its global usage windows stay
|
|
244
|
+
* healthy — a global rate_limited/out_of_credits marker would wrongly exclude
|
|
245
|
+
* the whole account for an unrelated model. Keyed on the candidate's stable
|
|
246
|
+
* native account id (never the org-shared usageKey) via
|
|
247
|
+
* {@link candidateAccountKey}, so a model-only limit on one login can never
|
|
248
|
+
* poison a sibling account that merely shares the same org usage bucket.
|
|
249
|
+
* Omitting `model` (every existing caller) leaves generic account status
|
|
250
|
+
* completely unaffected — the model check runs only when a caller opts in.
|
|
241
251
|
*/
|
|
242
|
-
export function readinessFromCandidate(candidate, now = Date.now()) {
|
|
252
|
+
export function readinessFromCandidate(candidate, now = Date.now(), model) {
|
|
243
253
|
if (!candidate.signedIn) {
|
|
244
254
|
return { ready: false, reason: 'signed_out', email: candidate.email };
|
|
245
255
|
}
|
|
@@ -252,6 +262,16 @@ export function readinessFromCandidate(candidate, now = Date.now()) {
|
|
|
252
262
|
if (authFresh && candidate.authVerdict !== null && isDeadVerdict(candidate.authVerdict)) {
|
|
253
263
|
return { ready: false, reason: 'revoked', email: candidate.email };
|
|
254
264
|
}
|
|
265
|
+
const modelKey = claudeModelRefusalKey(candidate.nativeAccountId ?? candidate.providerAccountId, candidate.providerAccount ? undefined : candidate.slotDir ?? getVersionHomePath(candidate.agent, candidate.version));
|
|
266
|
+
if (candidate.agent === 'claude' && modelKey) {
|
|
267
|
+
const requested = model ?? resolveConfiguredModel(candidate.agent, candidate.version, candidate.slotDir)?.model;
|
|
268
|
+
const concrete = requested && isTierToken(requested)
|
|
269
|
+
? resolveTier(candidate.agent, candidate.version, requested).model
|
|
270
|
+
: requested;
|
|
271
|
+
if (concrete && getClaudeModelRefusal(modelKey, concrete, now)) {
|
|
272
|
+
return { ready: false, reason: 'model_limited', email: candidate.email };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
255
275
|
if (hasUsageAvailable(candidate)) {
|
|
256
276
|
return { ready: true };
|
|
257
277
|
}
|
|
@@ -332,13 +352,17 @@ function compareCandidates(a, b) {
|
|
|
332
352
|
* are genuinely separate buckets and must stay distinct. Prefer the org usage
|
|
333
353
|
* key; fall back to email only when no usage identity is available.
|
|
334
354
|
*/
|
|
335
|
-
function
|
|
336
|
-
|
|
355
|
+
export function candidateAccountKey(c) {
|
|
356
|
+
if (c.nativeAccountId)
|
|
357
|
+
return `native:${c.nativeAccountId}`;
|
|
358
|
+
if (c.providerAccount)
|
|
359
|
+
return `provider:${c.providerAccount}`;
|
|
360
|
+
return c.usageKey ?? c.accountKey ?? c.email ?? `${c.agent}:unregistered:${c.accountLabel || c.version}`;
|
|
337
361
|
}
|
|
338
362
|
function dedupeAndSortCandidates(candidates) {
|
|
339
363
|
const byIdentity = new Map();
|
|
340
364
|
for (const c of candidates) {
|
|
341
|
-
const id =
|
|
365
|
+
const id = candidateAccountKey(c);
|
|
342
366
|
const existing = byIdentity.get(id);
|
|
343
367
|
if (!existing) {
|
|
344
368
|
byIdentity.set(id, c);
|
|
@@ -373,12 +397,16 @@ function dedupeAndSortCandidates(candidates) {
|
|
|
373
397
|
*
|
|
374
398
|
* Returns null if no candidate is eligible — callers fall back to the pinned
|
|
375
399
|
* version so behavior stays predictable.
|
|
400
|
+
*
|
|
401
|
+
* `model`, when supplied, additionally excludes an account carrying a live
|
|
402
|
+
* per-model refusal for that exact model (see {@link readinessFromCandidate}).
|
|
403
|
+
* Omitted (every pre-existing caller), eligibility is unchanged.
|
|
376
404
|
*/
|
|
377
|
-
export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
|
|
405
|
+
export function pickBalancedCandidate(candidates, nowMs = Date.now(), model) {
|
|
378
406
|
const healthy = [];
|
|
379
407
|
const excluded = [];
|
|
380
408
|
for (const c of candidates) {
|
|
381
|
-
if (!isRotationEligible(c)) {
|
|
409
|
+
if (!isRotationEligible(c, nowMs, model)) {
|
|
382
410
|
excluded.push(c);
|
|
383
411
|
continue;
|
|
384
412
|
}
|
|
@@ -489,11 +517,11 @@ function weightedRandomByCapacity(sorted, nowMs = Date.now()) {
|
|
|
489
517
|
* version has usage available; otherwise routes to the candidate with the most
|
|
490
518
|
* usage headroom.
|
|
491
519
|
*/
|
|
492
|
-
export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Date.now()) {
|
|
520
|
+
export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Date.now(), model) {
|
|
493
521
|
const healthy = [];
|
|
494
522
|
const excluded = [];
|
|
495
523
|
for (const c of candidates) {
|
|
496
|
-
if (!
|
|
524
|
+
if (!isRotationEligible(c, nowMs, model)) {
|
|
497
525
|
excluded.push(c);
|
|
498
526
|
continue;
|
|
499
527
|
}
|
|
@@ -538,7 +566,7 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
|
|
|
538
566
|
export function classifyHarnessCandidates(byHarness, nowMs = Date.now()) {
|
|
539
567
|
const summaries = [];
|
|
540
568
|
for (const [agent, candidates] of byHarness) {
|
|
541
|
-
const eligible = candidates.filter(isRotationEligible);
|
|
569
|
+
const eligible = candidates.filter((c) => isRotationEligible(c));
|
|
542
570
|
if (eligible.length === 0) {
|
|
543
571
|
const counts = new Map();
|
|
544
572
|
for (const c of candidates) {
|
|
@@ -697,22 +725,25 @@ export async function collectRunCandidates(agent) {
|
|
|
697
725
|
const meta = readMeta();
|
|
698
726
|
const binaryLabel = resolveManagedInstallation(agent)?.label ?? versions[0];
|
|
699
727
|
const slotRows = [];
|
|
700
|
-
const slotIdentities = new Set();
|
|
701
728
|
const slotDirs = new Set();
|
|
702
729
|
if (binaryLabel) {
|
|
703
|
-
const slots = readSlots(meta);
|
|
704
730
|
const natives = listNativeAccounts(meta).filter((row) => row.agent === agent);
|
|
705
731
|
const probed = await Promise.all(natives.map(async (account) => {
|
|
706
|
-
const
|
|
707
|
-
if (!
|
|
732
|
+
const resolved = await resolveNativeSpawnHome(agent, account, meta, { readOnly: true }).catch(() => null);
|
|
733
|
+
if (!resolved)
|
|
708
734
|
return null;
|
|
709
|
-
const
|
|
735
|
+
const slot = resolved.slot;
|
|
736
|
+
const home = resolved.execHome;
|
|
737
|
+
const version = resolved.label ?? binaryLabel;
|
|
738
|
+
const cachedHealth = authCache[authCacheKey(localHost, agent, slot ? slotAuthVersionKey(account.id) : version)];
|
|
739
|
+
const authHealth = cachedHealth && Date.now() - cachedHealth.checkedAt <= AUTH_PROBE_MAX_AGE_MS ? cachedHealth : undefined;
|
|
740
|
+
const effectiveVerdict = authHealth?.verdict ?? slot?.verdict ?? null;
|
|
710
741
|
const info = await getAccountInfo(agent, home);
|
|
711
742
|
const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
|
|
712
|
-
const slotOk = isLaunchableSlotVerdict(
|
|
743
|
+
const slotOk = !slot || isLaunchableSlotVerdict(effectiveVerdict);
|
|
713
744
|
return {
|
|
714
745
|
agent,
|
|
715
|
-
version
|
|
746
|
+
version,
|
|
716
747
|
home,
|
|
717
748
|
info,
|
|
718
749
|
accountKey: launchable ? info.accountKey : null,
|
|
@@ -721,38 +752,33 @@ export async function collectRunCandidates(agent) {
|
|
|
721
752
|
usageStatus: launchable ? info.usageStatus : null,
|
|
722
753
|
plan: launchable ? info.plan : null,
|
|
723
754
|
signedIn: launchable && slotOk,
|
|
724
|
-
authVerdict:
|
|
755
|
+
authVerdict: effectiveVerdict,
|
|
725
756
|
authCheckedAt: (() => {
|
|
726
|
-
|
|
757
|
+
if (authHealth)
|
|
758
|
+
return authHealth.checkedAt;
|
|
759
|
+
const ts = slot?.checkedAt ? Date.parse(slot.checkedAt) : NaN;
|
|
727
760
|
return Number.isFinite(ts) ? ts : null;
|
|
728
761
|
})(),
|
|
729
762
|
lastActive: info.lastActive,
|
|
730
763
|
nativeAccount: account.name,
|
|
764
|
+
nativeAccountId: account.id,
|
|
731
765
|
slotDir: home,
|
|
732
|
-
fromSlot:
|
|
766
|
+
fromSlot: !!slot,
|
|
733
767
|
};
|
|
734
768
|
}));
|
|
735
769
|
for (const row of probed) {
|
|
736
770
|
if (!row)
|
|
737
771
|
continue;
|
|
738
772
|
slotRows.push(row);
|
|
739
|
-
slotDirs.add(
|
|
740
|
-
if (row.info.accountKey)
|
|
741
|
-
slotIdentities.add(row.info.accountKey);
|
|
742
|
-
if (row.email)
|
|
743
|
-
slotIdentities.add(row.email.toLowerCase());
|
|
773
|
+
slotDirs.add(fs.realpathSync(row.home));
|
|
744
774
|
}
|
|
745
775
|
}
|
|
746
776
|
// Legacy per-account installations (`acct-*` homes) until T7 migrates them.
|
|
747
777
|
const versionRows = await Promise.all(versions.map(async (version) => {
|
|
748
778
|
const home = getVersionHomePath(agent, version);
|
|
749
|
-
if (slotDirs.has(
|
|
779
|
+
if (fs.existsSync(home) && slotDirs.has(fs.realpathSync(home)))
|
|
750
780
|
return null;
|
|
751
781
|
const info = await getAccountInfo(agent, home);
|
|
752
|
-
if (info.accountKey && slotIdentities.has(info.accountKey))
|
|
753
|
-
return null;
|
|
754
|
-
if (info.email && slotIdentities.has(info.email.toLowerCase()))
|
|
755
|
-
return null;
|
|
756
782
|
// We used to additionally call isClaudeAuthValid(home), which reads
|
|
757
783
|
// "Claude Code-credentials-<hash>" from the system keychain. That item is
|
|
758
784
|
// written by Claude Code itself with its own process in the ACL, so our
|
|
@@ -849,7 +875,9 @@ export function matchAccountCandidate(candidates, account, preferredLabel) {
|
|
|
849
875
|
const matching = candidates.filter((c) => c.signedIn &&
|
|
850
876
|
(c.email?.toLowerCase() === needle
|
|
851
877
|
|| c.accountKey?.toLowerCase() === needle
|
|
852
|
-
|| c.nativeAccount?.toLowerCase() === needle
|
|
878
|
+
|| c.nativeAccount?.toLowerCase() === needle
|
|
879
|
+
|| c.nativeAccountId?.toLowerCase() === needle
|
|
880
|
+
|| c.providerAccount?.toLowerCase() === needle));
|
|
853
881
|
return matching.find(candidate => candidate.version === preferredLabel) ?? matching[0] ?? null;
|
|
854
882
|
}
|
|
855
883
|
export function matchAccountVersion(candidates, account, preferredLabel) {
|
|
@@ -1064,7 +1092,7 @@ function emitRotationDecision(event, rotation, agent, strategy, extra = {}) {
|
|
|
1064
1092
|
* else is healthy, `exhausted` is set so the caller fails loud instead of
|
|
1065
1093
|
* spawning into a credential-less home.
|
|
1066
1094
|
*/
|
|
1067
|
-
export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
|
|
1095
|
+
export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates, model) {
|
|
1068
1096
|
const fallback = resolveVersion(agent, cwd);
|
|
1069
1097
|
const candidates = await collect(agent);
|
|
1070
1098
|
// Entirely stale usage (PHNX-2526): every eligible account carries a
|
|
@@ -1089,7 +1117,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
|
|
|
1089
1117
|
// Auth-blocked pin: the home cannot authenticate, so launching it is a
|
|
1090
1118
|
// guaranteed miss. Prefer a signed-in sibling on this device.
|
|
1091
1119
|
if (pinnedCandidate && isSignInRecoverable(readinessFromCandidate(pinnedCandidate))) {
|
|
1092
|
-
const rotation = pickAvailableCandidate(candidates, fallback);
|
|
1120
|
+
const rotation = pickAvailableCandidate(candidates, fallback, undefined, model);
|
|
1093
1121
|
// The auth-blocked pin rotates to a sibling — an initial selection, so it
|
|
1094
1122
|
// gets the same verified-only gate as balanced/available. Without this, a
|
|
1095
1123
|
// revoked pin with only stale siblings launched one blind (the yosemite-s1
|
|
@@ -1109,8 +1137,8 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
|
|
|
1109
1137
|
return { version: fallback, rotation: null };
|
|
1110
1138
|
}
|
|
1111
1139
|
const rotation = strategy === 'available'
|
|
1112
|
-
? pickAvailableCandidate(candidates, fallback)
|
|
1113
|
-
: pickBalancedCandidate(candidates);
|
|
1140
|
+
? pickAvailableCandidate(candidates, fallback, undefined, model)
|
|
1141
|
+
: pickBalancedCandidate(candidates, undefined, model);
|
|
1114
1142
|
if (rotation && rotation.noVerifiedUsage)
|
|
1115
1143
|
return refuseStaleUsage(rotation);
|
|
1116
1144
|
if (rotation) {
|
|
@@ -284,6 +284,20 @@ interface CachedUsageWindow {
|
|
|
284
284
|
resetsAt: string | null;
|
|
285
285
|
windowMinutes: number | null;
|
|
286
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* A model-specific refusal ("You've reached your Fable limit…") observed from
|
|
289
|
+
* a real run. Independent of {@link CachedUsageSnapshot.unavailable}: Claude
|
|
290
|
+
* can block ONE model family while the account's other models and its global
|
|
291
|
+
* usage windows stay healthy, so this must never fold into the account-wide
|
|
292
|
+
* marker (that would wrongly exclude every model on the account for a limit
|
|
293
|
+
* that only ever named one). No invented reset — `resetsAt` is present only
|
|
294
|
+
* when the refusal text itself carried a clock; absent means the marker is
|
|
295
|
+
* sticky until a later successful run on this exact (account, model) clears it.
|
|
296
|
+
*/
|
|
297
|
+
interface CachedModelRefusal {
|
|
298
|
+
family?: string;
|
|
299
|
+
resetsAt?: string;
|
|
300
|
+
}
|
|
287
301
|
/** Serialized usage snapshot for the on-disk cache. */
|
|
288
302
|
export interface CachedUsageSnapshot {
|
|
289
303
|
capturedAt: string | null;
|
|
@@ -294,6 +308,15 @@ export interface CachedUsageSnapshot {
|
|
|
294
308
|
reason: 'session_limit' | 'out_of_credits';
|
|
295
309
|
resetsAt?: string;
|
|
296
310
|
};
|
|
311
|
+
/**
|
|
312
|
+
* Per-model refusal markers, keyed by the exact model name a refusal was
|
|
313
|
+
* observed against. Keyed on the account row (itself keyed by a stable
|
|
314
|
+
* accountId-preferring key — see {@link noteClaudeModelRefusal}), not on
|
|
315
|
+
* the org-shared usage key alone, so a per-model limit on one login cannot
|
|
316
|
+
* be misread as blocking a sibling account that merely shares the same org
|
|
317
|
+
* usage bucket.
|
|
318
|
+
*/
|
|
319
|
+
modelRefusals?: Record<string, CachedModelRefusal>;
|
|
297
320
|
}
|
|
298
321
|
/** The single registry of agent usage sources and their transport. */
|
|
299
322
|
declare const USAGE_SOURCES: {
|
|
@@ -762,6 +785,54 @@ export declare function clearClaudeAccountRefusal(usageKey: string, cachePath?:
|
|
|
762
785
|
* This quota is not part of Anthropic's five-hour/weekly usage response.
|
|
763
786
|
*/
|
|
764
787
|
export declare function noteClaudeSessionLimit(usageKey: string, resetsAt: Date, cachePath?: string): void;
|
|
788
|
+
/**
|
|
789
|
+
* Persist a Claude MODEL-specific refusal — "You've reached your Fable limit.
|
|
790
|
+
* Run /usage-credits to continue or switch models with /model." — a distinct
|
|
791
|
+
* class from {@link noteClaudeOutOfCredits} / {@link noteClaudeSessionLimit}:
|
|
792
|
+
* those exclude the whole account, this excludes only ONE model on it (an
|
|
793
|
+
* organization quota group can meter models separately). `accountKey` MUST be
|
|
794
|
+
* the candidate's stable native-account key (see `candidateAccountKey` in
|
|
795
|
+
* rotate.ts), never the org-shared `usageKey` — using the org key here would
|
|
796
|
+
* poison every sibling account under that org for a limit that named one
|
|
797
|
+
* model on one login. No invented reset: `resetsAt` is written only when the
|
|
798
|
+
* refusal text carried one; otherwise the marker is sticky until a later
|
|
799
|
+
* successful run on this exact (account, model) clears it via
|
|
800
|
+
* {@link clearClaudeModelRefusal}.
|
|
801
|
+
*/
|
|
802
|
+
export declare function claudeModelRefusalKey(accountId?: string | null, home?: string | null): string | undefined;
|
|
803
|
+
export declare function noteClaudeModelRefusal(accountKey: string, model: string, refusal: {
|
|
804
|
+
family?: string;
|
|
805
|
+
resetsAt?: Date;
|
|
806
|
+
}, cachePath?: string): void;
|
|
807
|
+
/**
|
|
808
|
+
* Clear a persisted model-refusal marker for exactly ONE (account, model)
|
|
809
|
+
* pair after a run SUCCEEDS on that same account+model. Never clears a
|
|
810
|
+
* sibling model on the same account, and never fires for an interactive
|
|
811
|
+
* detach or an unknown outcome — the caller must have demonstrated an actual
|
|
812
|
+
* completed success on this exact model before calling this.
|
|
813
|
+
*/
|
|
814
|
+
export declare function clearClaudeModelRefusal(accountKey: string, model: string, cachePath?: string): void;
|
|
815
|
+
/**
|
|
816
|
+
* Read a live (non-expired) model-refusal marker for (accountKey, model), or
|
|
817
|
+
* null when none is recorded or the recorded one has passed its clock. A
|
|
818
|
+
* marker with no `resetsAt` never expires here — it is sticky until
|
|
819
|
+
* {@link clearClaudeModelRefusal} observes a real success.
|
|
820
|
+
*/
|
|
821
|
+
export declare function getClaudeModelRefusal(accountKey: string, model: string, nowMs?: number, cachePath?: string): {
|
|
822
|
+
family?: string;
|
|
823
|
+
resetsAt: Date | null;
|
|
824
|
+
} | null;
|
|
825
|
+
/**
|
|
826
|
+
* Parse Claude's model-specific refusal — the CLI's own phrasing when ONE
|
|
827
|
+
* model's quota is exhausted while the account otherwise keeps serving:
|
|
828
|
+
* "You've reached your Fable limit. Run /usage-credits to continue or switch
|
|
829
|
+
* models with /model." Deliberately narrow (unlike the broad RATE_LIMIT_PATTERNS
|
|
830
|
+
* scan) so a session that merely discusses `/usage-credits` cannot false-positive.
|
|
831
|
+
* Tolerates both a straight and curly apostrophe.
|
|
832
|
+
*/
|
|
833
|
+
export declare function parseClaudeModelRefusal(text: string): {
|
|
834
|
+
family: string;
|
|
835
|
+
} | null;
|
|
765
836
|
/** Parse Claude's `hit your session limit · resets …` refusal. */
|
|
766
837
|
export declare function parseClaudeSessionLimitReset(text: string, nowMs?: number): Date | null;
|
|
767
838
|
/**
|