@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +19 -1
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.js +135 -233
  5. package/dist/commands/resume.d.ts +6 -21
  6. package/dist/commands/resume.js +18 -55
  7. package/dist/commands/sessions-resume.d.ts +4 -0
  8. package/dist/commands/sessions-resume.js +132 -49
  9. package/dist/commands/sessions.js +32 -5
  10. package/dist/index.js +0 -0
  11. package/dist/lib/accounting/account-launch.d.ts +54 -0
  12. package/dist/lib/accounting/account-launch.js +117 -0
  13. package/dist/lib/accounting/account-pool-collect.js +2 -1
  14. package/dist/lib/accounting/account-pool.d.ts +2 -0
  15. package/dist/lib/accounting/account-pool.js +1 -0
  16. package/dist/lib/accounting/rotate.d.ts +32 -6
  17. package/dist/lib/accounting/rotate.js +70 -42
  18. package/dist/lib/accounting/usage.d.ts +71 -0
  19. package/dist/lib/accounting/usage.js +160 -11
  20. package/dist/lib/exec-account-home.d.ts +3 -1
  21. package/dist/lib/exec-account-home.js +2 -2
  22. package/dist/lib/exec.d.ts +27 -1
  23. package/dist/lib/exec.js +150 -27
  24. package/dist/lib/models.d.ts +1 -1
  25. package/dist/lib/models.js +4 -4
  26. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  27. package/dist/lib/session/actor-sidecar.js +3 -0
  28. package/dist/lib/session/claude-accounts.d.ts +12 -73
  29. package/dist/lib/session/claude-accounts.js +32 -70
  30. package/dist/lib/session/db.d.ts +1 -1
  31. package/dist/lib/session/db.js +18 -5
  32. package/dist/lib/session/discover.d.ts +4 -0
  33. package/dist/lib/session/discover.js +116 -15
  34. package/dist/lib/session/recovery.d.ts +30 -34
  35. package/dist/lib/session/recovery.js +212 -76
  36. package/dist/lib/session/types.d.ts +2 -0
  37. package/dist/lib/teams/placement-probe.js +1 -1
  38. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  39. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  40. package/dist/session-tracker/dist/hook.sh +191 -0
  41. package/dist/session-tracker/dist/index.d.ts +19 -0
  42. package/dist/session-tracker/dist/index.js +67 -0
  43. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  44. package/dist/session-tracker/dist/install-hook.js +245 -0
  45. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  46. package/dist/session-tracker/dist/prune-state.js +7 -0
  47. package/dist/session-tracker/dist/reader.d.ts +7 -0
  48. package/dist/session-tracker/dist/reader.js +151 -0
  49. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  50. package/dist/session-tracker/dist/state-file.js +119 -0
  51. package/dist/session-tracker/dist/types.d.ts +32 -0
  52. package/dist/session-tracker/dist/types.js +1 -0
  53. package/dist/session-tracker/dist/writer.d.ts +12 -0
  54. package/dist/session-tracker/dist/writer.js +27 -0
  55. 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;
@@ -22,6 +22,7 @@ export function registryPoolCandidates(records, agent) {
22
22
  if (!providerAuthenticatesHarness(r.provider, r.auth, agent))
23
23
  continue;
24
24
  out.push({
25
+ id: r.id,
25
26
  accountKey: `${agent}:name=${r.name}`,
26
27
  email: null,
27
28
  name: r.name,
@@ -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
- export declare function readinessFromCandidate(candidate: RotateCandidate, now?: number): AccountReadiness;
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[]>): Promise<{
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 { readSlots } from '../accounts/slots.js';
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 candidateIdentity(c) {
336
- return c.usageKey ?? c.accountKey ?? c.email ?? `${c.agent}@${c.version}`;
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 = candidateIdentity(c);
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 (!isAvailableEligible(c)) {
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 slot = slots[account.id];
707
- if (!slot || !fs.existsSync(slot.slotDir))
732
+ const resolved = await resolveNativeSpawnHome(agent, account, meta, { readOnly: true }).catch(() => null);
733
+ if (!resolved)
708
734
  return null;
709
- const home = slot.slotDir;
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(slot.verdict);
743
+ const slotOk = !slot || isLaunchableSlotVerdict(effectiveVerdict);
713
744
  return {
714
745
  agent,
715
- version: binaryLabel,
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: slot.verdict,
755
+ authVerdict: effectiveVerdict,
725
756
  authCheckedAt: (() => {
726
- const ts = slot.checkedAt ? Date.parse(slot.checkedAt) : NaN;
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: true,
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(path.resolve(row.home));
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(path.resolve(home)))
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
  /**