@centerforagenticai/pi-multi-account 0.1.5 → 0.1.7

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/src/index.ts CHANGED
@@ -19,6 +19,11 @@ import {
19
19
  type OperatorAccount,
20
20
  } from "./commands.js";
21
21
  import { CompactionRouter } from "./compaction.js";
22
+ import {
23
+ accountGroupMemberAvailability,
24
+ isAccountGroupVirtualModel,
25
+ readAccountGroupRegistrySnapshot,
26
+ } from "./account-group-members.js";
22
27
  import {
23
28
  createCodexAliasProviderConfig,
24
29
  getCodexModelsFromRegistry,
@@ -35,6 +40,8 @@ import {
35
40
  import {
36
41
  ALLOWED_FAMILIES,
37
42
  DEFAULT_CONFIG,
43
+ ConfigValidationError,
44
+ isAccountLimit,
38
45
  accountSlotIndexes,
39
46
  canonicalProviderIdForAccountSlot,
40
47
  isAccountSlotIndex,
@@ -137,6 +144,7 @@ import {
137
144
  type InstalledDeclarationStatus,
138
145
  } from "./models-declaration.js";
139
146
  import { createLogicalDispatch } from "./logical-dispatch.js";
147
+ import type { AccountGroupFailureBlockReason } from "./account-group-failure.js";
140
148
  import type { ModelsCatalogs } from "./commands.js";
141
149
  import {
142
150
  NOOP_LOGICAL_ATTRIBUTION_ATTEMPT,
@@ -156,6 +164,7 @@ import {
156
164
  import { isCredentialUsable } from "./credential-lifecycle.js";
157
165
  import {
158
166
  routeAfterFailure,
167
+ logicalAccountEligible,
159
168
  selectAvailableManagedAccount,
160
169
  selectAvailableRecoveryAccount,
161
170
  selectAvailableRouteCandidates,
@@ -201,7 +210,7 @@ import {
201
210
  type SessionIdSource,
202
211
  } from "./session-account-groups.js";
203
212
  import { projectKeyForCwd } from "./project-identity.js";
204
- import { RuntimeState, isCanonicalManagedProviderId } from "./runtime-state.js";
213
+ import { RuntimeState, isCanonicalManagedProviderId, normalizeCredentialRevision } from "./runtime-state.js";
205
214
  import { providerTypeFor } from "./vendor.js";
206
215
  import { resolveTierModel } from "./tier-model-resolver.js";
207
216
  import {
@@ -409,6 +418,17 @@ function readInstalledDeclaration(targetPath: string): unknown {
409
418
  }
410
419
  }
411
420
 
421
+ /** Only schema field names, never validator prose or operator-supplied values. */
422
+ function configRepairMessage(error: unknown): string {
423
+ const field = error instanceof ConfigValidationError
424
+ ? [...Object.keys(DEFAULT_CONFIG), "defaultAccountGroup"].find((key) =>
425
+ error.message.startsWith(`[multi-account config] ${key} `) ||
426
+ error.message.startsWith(`[multi-account config] ${key}.`),
427
+ )
428
+ : undefined;
429
+ return `Multi-account configuration is invalid${field === undefined ? "" : ` (${field})`}. Correct the global config and run /multi-account reload or /reload.`;
430
+ }
431
+
412
432
  /** The session's id, or undefined when the host context cannot supply one. */
413
433
  function readSessionId(ctx: ExtensionContext): string | undefined {
414
434
  try {
@@ -1604,11 +1624,12 @@ function readLiveCredentialFacts(
1604
1624
  ): {
1605
1625
  credential: CredentialUsability | undefined;
1606
1626
  fingerprint: string | undefined;
1627
+ credentialRevision: number | undefined;
1607
1628
  } {
1608
1629
  try {
1609
1630
  const stored = readStoredCredential(providerId, authPath);
1610
1631
  if (stored === null || typeof stored !== "object") {
1611
- return { credential: undefined, fingerprint: undefined };
1632
+ return { credential: undefined, fingerprint: undefined, credentialRevision: undefined };
1612
1633
  }
1613
1634
  const record = stored as Record<string, unknown>;
1614
1635
  const expiresAtMs =
@@ -1622,10 +1643,11 @@ function readLiveCredentialFacts(
1622
1643
  ...(expiresAtMs === undefined ? {} : { expiresAtMs }),
1623
1644
  },
1624
1645
  fingerprint: accountFingerprint(record.access),
1646
+ credentialRevision: expiresAtMs,
1625
1647
  };
1626
1648
  } catch {
1627
1649
  // Missing or unreadable metadata must preserve existing reactive routing.
1628
- return { credential: undefined, fingerprint: undefined };
1650
+ return { credential: undefined, fingerprint: undefined, credentialRevision: undefined };
1629
1651
  }
1630
1652
  }
1631
1653
 
@@ -1638,7 +1660,7 @@ function readLiveCredentialFacts(
1638
1660
  * routing keep the managed superset.
1639
1661
  */
1640
1662
  type OperatorManagedAccount = Omit<OperatorAccount, "family"> &
1641
- Omit<ManagedAccount, "family"> & { readonly family: ManagedFamily };
1663
+ Omit<ManagedAccount, "family"> & { readonly family: ManagedFamily; readonly credentialRevision?: number };
1642
1664
 
1643
1665
  export type AccountGroupEnforcementReason =
1644
1666
  | "unknown-group"
@@ -1730,7 +1752,7 @@ function operatorAccounts(
1730
1752
  // Project the stored credential at this boundary into bounded metadata only.
1731
1753
  // The raw access value is used transiently by accountFingerprint and never
1732
1754
  // enters OperatorAccount, ManagedAccount, routing state, or diagnostics.
1733
- const { credential, fingerprint } = readLiveCredentialFacts(
1755
+ const { credential, fingerprint, credentialRevision } = readLiveCredentialFacts(
1734
1756
  slot.providerId,
1735
1757
  authPath,
1736
1758
  );
@@ -1747,6 +1769,7 @@ function operatorAccounts(
1747
1769
  modelIds: models.map((candidate) => candidate.id),
1748
1770
  displayName: slot.providerId,
1749
1771
  ...(credential === undefined ? {} : { credential }),
1772
+ ...(credentialRevision === undefined ? {} : { credentialRevision }),
1750
1773
  ...(fingerprint === undefined
1751
1774
  ? {}
1752
1775
  : { accountFingerprint: fingerprint }),
@@ -1963,9 +1986,10 @@ function delegateOwnedApiSet(): WeakSet<object> {
1963
1986
  * True when this extension API belongs to a delegate-owned in-process session
1964
1987
  * rather than the real foreground originator.
1965
1988
  *
1966
- * This read must happen while the factory runs inside bindExtensions(). An active
1967
- * current bind has a defined AsyncLocalStorage frame; a legacy bind has positive
1968
- * depth. Either signal marks the API persistently, matching pi-delegate's own
1989
+ * Read at session_start, which bindExtensions() emits inside the trusted frame,
1990
+ * not while the loader executes the factory. An active current bind has a defined
1991
+ * AsyncLocalStorage frame; a legacy bind has positive depth. Either signal marks
1992
+ * the API persistently, matching pi-delegate's own
1969
1993
  * shouldSkipForegroundLifecycleForDelegateOwnedApi contract.
1970
1994
  */
1971
1995
  function isDelegateOwnedApi(api: Parameters<ExtensionFactory>[0]): boolean {
@@ -2032,7 +2056,7 @@ function delegateWorkerOriginOwnerSessionId(
2032
2056
 
2033
2057
  /**
2034
2058
  * Persist an inherited effective result under the child's own session id without
2035
- * consulting the child's real cwd/default policy. This makes a durable child a
2059
+ * consulting the child's real cwd/default policy. This makes a delegate child a
2036
2060
  * valid parent for the next durable or in-process hop while preserving the
2037
2061
  * inherited source exactly.
2038
2062
  */
@@ -2118,13 +2142,9 @@ function buildLivePiCatalogSnapshot(
2118
2142
  export const createMultiAccountExtension =
2119
2143
  (options: MultiAccountExtensionOptions = {}): ExtensionFactory =>
2120
2144
  async (pi) => {
2121
- // Read pi-delegate's bind scope while the factory body runs synchronously
2122
- // inside bindExtensions(). A delegate-owned in-process worker/clone session
2123
- // shares the foreground's TUI, so its route indicator would publish to the
2124
- // same qualified widget key and overwrite the real foreground footer. The
2125
- // footer is foreground-only; every other surface (provider registration,
2126
- // routing, cost attribution) stays active for the worker.
2127
- const delegateOwnedSession = isDelegateOwnedApi(pi);
2145
+ // Ownership becomes available at session_start, not factory loading. Keep
2146
+ // the bind-time result for later routing and foreground-only UI callbacks.
2147
+ let delegateOwnedSession = false;
2128
2148
  // Copy only pi-delegate's two named child-identity fields. Public request
2129
2149
  // objects and caller-provided env maps never cross this trust boundary.
2130
2150
  const delegateDriverChildMarker = process.env[DELEGATE_DRIVER_CHILD_ENV];
@@ -2261,6 +2281,17 @@ export const createMultiAccountExtension =
2261
2281
  let latestLogicalPhysicalProviderId: string | undefined;
2262
2282
  const logicalTerminalAssociations = createLogicalTerminalAssociationStore();
2263
2283
  let config: MultiAccountConfig = DEFAULT_CONFIG;
2284
+ // Discovery defaults are not request authorization. Cleared only by a
2285
+ // complete validated load; a failed reload keeps the last valid policy.
2286
+ let policyConfigError: string | undefined =
2287
+ "Multi-account configuration is not loaded. Run /multi-account reload or /reload.";
2288
+ const loadGlobalConfig = (): MultiAccountConfig => {
2289
+ try {
2290
+ return readConfig(configPath);
2291
+ } catch (error) {
2292
+ throw new Error(configRepairMessage(error));
2293
+ }
2294
+ };
2264
2295
  const defaultCostReportReader = createDefaultCostReportReader({
2265
2296
  config: () => config,
2266
2297
  // `context` is undefined until `session_start` assigns it below; this
@@ -2399,11 +2430,23 @@ export const createMultiAccountExtension =
2399
2430
  return executeLogicalModelSwitch(args, dependencies);
2400
2431
  };
2401
2432
 
2402
- const openRouterPolicy = () =>
2403
- resolveOpenRouterEnvironmentPolicy({
2433
+ const openRouterPolicy = () => {
2434
+ const policy = resolveOpenRouterEnvironmentPolicy({
2404
2435
  environment: openRouterEnvironment,
2405
2436
  sessionDisabled: openRouterSessionDisabled,
2406
2437
  });
2438
+ // In-process workers share the parent's environment, so lineage env
2439
+ // alone cannot exclude them. Only retained trusted bind ownership
2440
+ // supplies this additional veto; caller-shaped origins never do.
2441
+ return delegateOwnedSession
2442
+ ? {
2443
+ enabled: false as const,
2444
+ reason: "delegate-session" as const,
2445
+ conversationEgressConsented: policy.conversationEgressConsented,
2446
+ delegatesAllowed: false as const,
2447
+ }
2448
+ : policy;
2449
+ };
2407
2450
  const openRouterModel = (
2408
2451
  modelId: string,
2409
2452
  modelContext: ExtensionContext | undefined = context,
@@ -2469,7 +2512,9 @@ export const createMultiAccountExtension =
2469
2512
  | undefined;
2470
2513
  let accountGroupResolutionInitialized = false;
2471
2514
  let accountGroupScopeBlocked = false;
2472
- const blockAccountGroupScope = (key: string, message: string): void => {
2515
+ let accountGroupBlockReason: AccountGroupFailureBlockReason | undefined;
2516
+ const blockAccountGroupScope = (key: AccountGroupFailureBlockReason, message: string): void => {
2517
+ accountGroupBlockReason = key;
2473
2518
  accountGroupResolutionInitialized = true;
2474
2519
  accountGroupScopeBlocked = true;
2475
2520
  effectiveAccountGroupResolution = undefined;
@@ -2480,10 +2525,12 @@ export const createMultiAccountExtension =
2480
2525
  ): EffectiveAccountGroupResolution => {
2481
2526
  accountGroupResolutionInitialized = true;
2482
2527
  accountGroupScopeBlocked = false;
2528
+ accountGroupBlockReason = undefined;
2483
2529
  effectiveAccountGroupResolution = resolution;
2484
2530
  return resolution;
2485
2531
  };
2486
2532
  const accountGroupScopeRestrictsRouting = (): boolean =>
2533
+ policyConfigError !== undefined ||
2487
2534
  accountGroupScopeBlocked ||
2488
2535
  effectiveAccountGroupResolution === undefined ||
2489
2536
  effectiveAccountGroupResolution.source !== "unrestricted";
@@ -2501,6 +2548,10 @@ export const createMultiAccountExtension =
2501
2548
  const initializeSessionAccountGroupScope = (
2502
2549
  accountContext: ExtensionContext | undefined,
2503
2550
  ): void => {
2551
+ if (policyConfigError !== undefined) {
2552
+ blockAccountGroupScope("config-invalid", policyConfigError);
2553
+ return;
2554
+ }
2504
2555
  const sessionManager = accountContext?.sessionManager as
2505
2556
  | AccountGroupSessionManager
2506
2557
  | undefined;
@@ -2547,14 +2598,23 @@ export const createMultiAccountExtension =
2547
2598
  );
2548
2599
  return;
2549
2600
  }
2550
- const parentOverride =
2551
- sessionAccountGroups.readOverride(parentSessionId);
2552
- if (parentOverride === undefined) {
2553
- resolveOwnSessionAccountGroup(sessionManager, cwd);
2601
+ const inherited =
2602
+ sessionAccountGroups.readCachedResolution(parentSessionId);
2603
+ if (inherited === undefined) {
2604
+ blockAccountGroupScope(
2605
+ "delegate-resolution-unavailable",
2606
+ "The delegate parent's effective account group is unavailable; managed routing is blocked for this worker.",
2607
+ );
2554
2608
  return;
2555
2609
  }
2556
- sessionAccountGroups.setOverride(sessionManager, parentOverride);
2557
- resolveOwnSessionAccountGroup(sessionManager, cwd);
2610
+ rememberAccountGroupResolution(
2611
+ cacheInheritedAccountGroupResolution(
2612
+ sessionAccountGroups,
2613
+ sessionManager,
2614
+ cwd,
2615
+ inherited,
2616
+ ),
2617
+ );
2558
2618
  return;
2559
2619
  }
2560
2620
  if (delegateDriverIdentityPresent) {
@@ -2601,6 +2661,7 @@ export const createMultiAccountExtension =
2601
2661
  accounts: readonly OperatorManagedAccount[],
2602
2662
  accountContext: ExtensionContext | undefined,
2603
2663
  ): OperatorManagedAccount[] => {
2664
+ if (policyConfigError !== undefined) return [];
2604
2665
  if (!accountGroupResolutionInitialized) {
2605
2666
  initializeSessionAccountGroupScope(accountContext);
2606
2667
  }
@@ -2649,13 +2710,17 @@ export const createMultiAccountExtension =
2649
2710
  (account): account is OperatorAccount & SubscriptionManagedAccount =>
2650
2711
  isRoutingEligibleAccountFamily(account),
2651
2712
  );
2652
- const physicalAccountsForRouting = (nowMs: number): ManagedAccount[] =>
2653
- physicalOperatorAccounts().map((account) => {
2713
+ const physicalAccountsForRouting = (
2714
+ nowMs: number,
2715
+ accounts: readonly OperatorManagedAccount[] = physicalOperatorAccounts(),
2716
+ ): ManagedAccount[] =>
2717
+ accounts.map((account) => {
2654
2718
  const {
2655
2719
  providerId,
2656
2720
  family,
2657
2721
  credentialType,
2658
2722
  credential,
2723
+ credentialRevision,
2659
2724
  accountFingerprint,
2660
2725
  modelIds,
2661
2726
  } = account;
@@ -2682,6 +2747,7 @@ export const createMultiAccountExtension =
2682
2747
  : { accountFingerprint }),
2683
2748
  ...(modelIds === undefined ? {} : { modelIds }),
2684
2749
  ...(fleetUsage === undefined ? {} : { fleetUsage }),
2750
+ ...(account.credentialRevision === undefined ? {} : { credentialRevision: account.credentialRevision }),
2685
2751
  };
2686
2752
  });
2687
2753
  const subscriptionAccountsForRouting = (
@@ -2899,6 +2965,10 @@ export const createMultiAccountExtension =
2899
2965
  authJsonPath: authPath,
2900
2966
  config,
2901
2967
  });
2968
+ for (const slot of discovered.slots) {
2969
+ const revision = normalizeCredentialRevision(slot.expiresAtMs ?? NaN);
2970
+ if (revision !== undefined) state.observeCredentialRevision(slot.providerId, slot.family, revision);
2971
+ }
2902
2972
  // When codex is disabled (host OAuth surface unavailable), strip codex
2903
2973
  // slots so the disabled sentinel's throwing callbacks are never reached.
2904
2974
  discovery = codexEnabled
@@ -3117,44 +3187,58 @@ export const createMultiAccountExtension =
3117
3187
  };
3118
3188
  };
3119
3189
 
3120
- const resolveCommandAccountGroup = (): AccountGroupCommandStatus => {
3121
- const liveContext = context;
3122
- if (
3123
- liveContext === undefined ||
3124
- typeof liveContext.sessionManager?.getSessionId !== "function"
3125
- ) {
3126
- throw new Error("The live session identity is unavailable.");
3190
+ const readCommandAccountGroupStatus = (): AccountGroupCommandStatus => {
3191
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
3192
+ const resolution = effectiveAccountGroupResolution;
3193
+ if (accountGroupScopeBlocked || resolution === undefined) {
3194
+ throw new Error("The effective session account group is unavailable; managed routing is blocked.");
3127
3195
  }
3128
- const resolution = resolveOwnSessionAccountGroup(
3129
- liveContext.sessionManager,
3130
- liveContext.cwd,
3131
- );
3132
3196
  if (resolution.source === "unrestricted") return { resolution };
3133
3197
  const members = config.accountGroups?.[resolution.groupId] ?? [];
3134
3198
  const nowMs = Date.now();
3135
- const routingAccounts = physicalAccountsForRouting(nowMs);
3199
+ const snapshot = readAccountGroupRegistrySnapshot(context?.modelRegistry);
3200
+ const providerIds = new Set([...members, ...snapshot.providerIds]);
3201
+ providerIds.delete(LOGICAL_PROVIDER_ID);
3136
3202
  return {
3137
3203
  resolution,
3138
- members: members.map((providerId) => {
3139
- const eligible =
3140
- selectAvailableManagedAccount({
3141
- accounts: routingAccounts.filter(
3142
- (account) => account.providerId === providerId,
3143
- ),
3144
- state,
3145
- nowMs,
3146
- }) !== undefined;
3147
- return {
3148
- providerId,
3149
- eligible,
3150
- reason: eligible
3151
- ? "routing-eligible"
3152
- : "not currently routing-eligible",
3153
- };
3204
+ members: [...providerIds].map((providerId) => {
3205
+ const availability = accountGroupMemberAvailability(providerId, members, snapshot);
3206
+ if (!availability.eligible) return availability;
3207
+ if (providerId === OPENROUTER_PROVIDER_ID) {
3208
+ return { providerId, eligible: false, reason: "metered OpenRouter disabled by active group" };
3209
+ }
3210
+ const slot = classifyProviderId({ providerId, credentialType: "unknown" });
3211
+ if (slot === null) return availability;
3212
+ const fleetUsage = isAllowedFamily(slot.family)
3213
+ ? sharedUsageHint(
3214
+ usage.routingUsage(providerId, slot.family, nowMs),
3215
+ usage.activeExhaustionHoldUntilMs(providerId, slot.family, nowMs),
3216
+ )
3217
+ : undefined;
3218
+ const eligible = !disabledProviders.has(providerId) && logicalAccountEligible(
3219
+ { providerId, exhausted: snapshotIndicatesExhaustion(fleetUsage, nowMs, "all-observed") },
3220
+ state, nowMs,
3221
+ );
3222
+ return { providerId, eligible, reason: eligible ? availability.reason : "not currently routing-eligible" };
3154
3223
  }),
3155
3224
  };
3156
3225
  };
3157
3226
 
3227
+ // Explicit operator actions resolve this session's own policy. Observational
3228
+ // status must instead preserve the effective result inherited at startup.
3229
+ const resolveCommandAccountGroup = (): AccountGroupCommandStatus => {
3230
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
3231
+ const liveContext = context;
3232
+ if (
3233
+ liveContext === undefined ||
3234
+ typeof liveContext.sessionManager?.getSessionId !== "function"
3235
+ ) {
3236
+ throw new Error("The live session identity is unavailable.");
3237
+ }
3238
+ resolveOwnSessionAccountGroup(liveContext.sessionManager, liveContext.cwd);
3239
+ return readCommandAccountGroupStatus();
3240
+ };
3241
+
3158
3242
  const commands = new MultiAccountCommandController({
3159
3243
  state,
3160
3244
  usage,
@@ -3198,7 +3282,7 @@ export const createMultiAccountExtension =
3198
3282
  sessionAccountGroups.clearOverride(sessionManager);
3199
3283
  return resolveCommandAccountGroup();
3200
3284
  },
3201
- status: resolveCommandAccountGroup,
3285
+ status: readCommandAccountGroupStatus,
3202
3286
  },
3203
3287
  logicalRoutingState: () => logicalRoutingState,
3204
3288
  disabledProviders,
@@ -3283,9 +3367,12 @@ export const createMultiAccountExtension =
3283
3367
  setModel,
3284
3368
  rediscover,
3285
3369
  addSlot,
3286
- reloadGlobalConfig: async () => readConfig(configPath),
3370
+ reloadGlobalConfig: async () => loadGlobalConfig(),
3287
3371
  onConfigReload: async (nextConfig) => {
3288
3372
  config = nextConfig;
3373
+ policyConfigError = undefined;
3374
+ accountGroupResolutionInitialized = false;
3375
+ initializeSessionAccountGroupScope(context);
3289
3376
  await rediscover();
3290
3377
  },
3291
3378
  routingConfig: {
@@ -3333,8 +3420,8 @@ export const createMultiAccountExtension =
3333
3420
  // Group scope is stricter than the optional metered rung. For unrestricted
3334
3421
  // sessions, consent, delegate exclusion, session disablement, model syntax,
3335
3422
  // and the positive budget cap remain the authority for OpenRouter.
3336
- // A named account group is a closed dispatch set. Metered OpenRouter is
3337
- // intentionally outside every group and therefore cannot widen exhaustion.
3423
+ // A named group remains closed to metered OpenRouter even when listed.
3424
+ // Membership alone cannot enable or widen the existing metered gate.
3338
3425
  if (accountGroupScopeRestrictsRouting()) return false;
3339
3426
  const policy = openRouterPolicy();
3340
3427
  if (!policy.enabled) return false;
@@ -3476,12 +3563,31 @@ export const createMultiAccountExtension =
3476
3563
  providerId: string,
3477
3564
  inputContext: ExtensionContext,
3478
3565
  ): boolean => {
3566
+ if (policyConfigError !== undefined) return false;
3479
3567
  if (!accountGroupScopeRestrictsRouting()) return true;
3568
+ // Pi virtual routers may choose arbitrary physical targets. No public
3569
+ // cross-host hook here can authorize those targets before they send.
3570
+ // Check the selected model before the extension-owned unified exception.
3571
+ if (isAccountGroupVirtualModel(inputContext.model ?? currentModel)) return false;
3480
3572
  if (providerId === LOGICAL_PROVIDER_ID) return true;
3573
+ // Listing OpenRouter does not enable a metered route. Issue 138 owns
3574
+ // changing that gate; all existing consent/budget/delegate controls stay.
3575
+ if (providerId === OPENROUTER_PROVIDER_ID) return false;
3576
+ const resolution = effectiveAccountGroupResolution;
3577
+ if (accountGroupScopeBlocked || resolution === undefined || resolution.source === "unrestricted") {
3578
+ return false;
3579
+ }
3580
+ const members = config.accountGroups?.[resolution.groupId] ?? [];
3581
+ const modelId = inputContext.model?.id ?? currentModel?.id;
3582
+ const availability = accountGroupMemberAvailability(
3583
+ providerId, members,
3584
+ readAccountGroupRegistrySnapshot(inputContext.modelRegistry), modelId,
3585
+ );
3586
+ if (!availability.eligible) return false;
3587
+ // Membership never enrolls custom providers in extension-owned routing.
3588
+ if (classifyProviderId({ providerId, credentialType: "unknown" }) === null) return true;
3481
3589
  try {
3482
- return subscriptionOperatorAccounts(context ?? inputContext).some(
3483
- (account) => account.providerId === providerId,
3484
- );
3590
+ return physicalOperatorAccounts(context ?? inputContext).some((account) => account.providerId === providerId);
3485
3591
  } catch {
3486
3592
  return false;
3487
3593
  }
@@ -3526,12 +3632,29 @@ export const createMultiAccountExtension =
3526
3632
  ): Promise<{ readonly action: "continue" | "handled" }> => {
3527
3633
  const activeProviderId =
3528
3634
  inputContext.model?.provider ?? currentModel?.provider;
3635
+ if (policyConfigError !== undefined && activeProviderId === undefined) {
3636
+ inputContext.ui.notify(policyConfigError, "warning");
3637
+ return { action: "handled" };
3638
+ }
3529
3639
  if (
3530
3640
  activeProviderId === undefined ||
3531
3641
  providerAllowedByActiveGroup(activeProviderId, inputContext)
3532
3642
  ) {
3533
3643
  return { action: "continue" };
3534
3644
  }
3645
+ if (policyConfigError !== undefined) {
3646
+ inputContext.ui.notify(policyConfigError, "warning");
3647
+ return { action: "handled" };
3648
+ }
3649
+ if (isAccountGroupVirtualModel(inputContext.model ?? currentModel)) {
3650
+ // Never reinterpret a virtual selection as a managed physical origin
3651
+ // or replace it silently, even under a managed provider's name.
3652
+ inputContext.ui.notify(
3653
+ "Multi-account blocked a virtual model under the active account group. Select a physical model allowed by the group.",
3654
+ "warning",
3655
+ );
3656
+ return { action: "handled" };
3657
+ }
3535
3658
  const origin = accountGroupOrigin(
3536
3659
  activeProviderId,
3537
3660
  inputContext.model?.id ?? currentModel?.id,
@@ -3574,7 +3697,7 @@ export const createMultiAccountExtension =
3574
3697
  replacement = undefined;
3575
3698
  }
3576
3699
  }
3577
- if (replacement !== undefined) {
3700
+ if (replacement !== undefined && !isAccountGroupVirtualModel(replacement)) {
3578
3701
  try {
3579
3702
  if (await setModel(replacement)) {
3580
3703
  if (origin !== undefined) {
@@ -3781,6 +3904,11 @@ export const createMultiAccountExtension =
3781
3904
  */
3782
3905
  const settleTurn = async (): Promise<void> => {
3783
3906
  openRouterInputApproved = false;
3907
+ if (policyConfigError !== undefined) {
3908
+ pendingFailure = undefined;
3909
+ pendingOpenRouterFailure = false;
3910
+ return;
3911
+ }
3784
3912
  if (pendingOpenRouterFailure) {
3785
3913
  pendingOpenRouterFailure = false;
3786
3914
  const restored = await restoreOpenRouterOrigin();
@@ -3800,6 +3928,24 @@ export const createMultiAccountExtension =
3800
3928
  const origin = turnRouteOrigin;
3801
3929
  if (!classified || origin === undefined) return;
3802
3930
  const { providerId, family, failure } = classified;
3931
+ if (origin.logical) {
3932
+ // Unified recovers inside the model call, so its final failure already
3933
+ // spent the call's bounded sends, and message_end recorded its quota
3934
+ // effects. Settlement adds only a detached usage refresh for a quota
3935
+ // failure and a terminal-auth invalidation: never a switch, park,
3936
+ // OpenRouter rung, route pin, or fixed continuation.
3937
+ // Direct physical selections keep the legacy path below.
3938
+ clearTurnRouteOrigin();
3939
+ if (classified.failedAtMs !== undefined) {
3940
+ refreshUsageAfterLogicalQuotaFailure(
3941
+ providerId,
3942
+ family,
3943
+ classified.failedAtMs,
3944
+ );
3945
+ }
3946
+ await invalidateOnTerminalAuth(providerId, family, failure);
3947
+ return;
3948
+ }
3803
3949
  const accounts = physicalOperatorAccounts();
3804
3950
  const nowMs = Date.now();
3805
3951
  const routingAccounts = physicalAccountsForRouting(nowMs);
@@ -4162,7 +4308,13 @@ export const createMultiAccountExtension =
4162
4308
  const observedMessages = new WeakSet<object>();
4163
4309
  // Operator advice for a structured refusal. A delegate-owned in-process
4164
4310
  // session shares the foreground UI, so only the foreground advises.
4165
- const refusalAdvisor = createRefusalAdvisor({ foreground: !delegateOwnedSession });
4311
+ const refusalAdvisor = createRefusalAdvisor({
4312
+ // The advisor is constructed before binding; read retained ownership
4313
+ // when advising rather than freezing the factory-time foreground value.
4314
+ get foreground() {
4315
+ return !delegateOwnedSession;
4316
+ },
4317
+ });
4166
4318
  const recordUsage = (
4167
4319
  observation: () => UsageObservation | undefined,
4168
4320
  ): void => {
@@ -4312,8 +4464,9 @@ export const createMultiAccountExtension =
4312
4464
  const activeProviderId =
4313
4465
  runContext.model?.provider ?? currentModel?.provider;
4314
4466
  if (
4315
- activeProviderId !== undefined &&
4316
- !providerAllowedByActiveGroup(activeProviderId, runContext)
4467
+ (activeProviderId === undefined && policyConfigError !== undefined) ||
4468
+ (activeProviderId !== undefined &&
4469
+ !providerAllowedByActiveGroup(activeProviderId, runContext))
4317
4470
  ) {
4318
4471
  openRouterInputApproved = false;
4319
4472
  diagnostics.record(
@@ -4322,11 +4475,11 @@ export const createMultiAccountExtension =
4322
4475
  "A custom-message run selected a provider outside the active account group and was aborted before provider dispatch.",
4323
4476
  { providerId: activeProviderId },
4324
4477
  );
4478
+ runContext.abort();
4325
4479
  runContext.ui.notify(
4326
- "Multi-account aborted a provider run outside the active account group.",
4480
+ policyConfigError ?? "Multi-account aborted a provider run outside the active account group.",
4327
4481
  "warning",
4328
4482
  );
4329
- runContext.abort();
4330
4483
  return;
4331
4484
  }
4332
4485
  const ownsActiveOpenRouter =
@@ -4355,6 +4508,10 @@ export const createMultiAccountExtension =
4355
4508
  pi.on(
4356
4509
  "session_start",
4357
4510
  guarded("lifecycle.session-start", async (_event, startupContext) => {
4511
+ // Pi executes factories during resource loading, before pi-delegate
4512
+ // opens its trusted bind frame. Capture ownership here before any
4513
+ // UI or routing initialization, and retain it after the frame ends.
4514
+ delegateOwnedSession ||= isDelegateOwnedApi(pi);
4358
4515
  context = startupContext;
4359
4516
  try {
4360
4517
  logicalRouteIndicator?.shutdown();
@@ -4414,7 +4571,27 @@ export const createMultiAccountExtension =
4414
4571
  now: Date.now,
4415
4572
  });
4416
4573
  currentModel = startupContext.model;
4417
- config = readConfig(configPath);
4574
+ try {
4575
+ config = loadGlobalConfig();
4576
+ policyConfigError = undefined;
4577
+ } catch (error) {
4578
+ // loadGlobalConfig produces only bounded, field-only guidance.
4579
+ const message = (error as Error).message;
4580
+ if (policyConfigError !== undefined) {
4581
+ policyConfigError = message;
4582
+ const raw = readInstalledDeclaration(configPath) as
4583
+ | { accountLimit?: unknown }
4584
+ | undefined;
4585
+ config = {
4586
+ ...DEFAULT_CONFIG,
4587
+ accountLimit: isAccountLimit(raw?.accountLimit)
4588
+ ? raw.accountLimit
4589
+ : DEFAULT_CONFIG.accountLimit,
4590
+ };
4591
+ }
4592
+ diagnostics.record("warning", "config.invalid", message);
4593
+ startupContext.ui.notify(message, "warning");
4594
+ }
4418
4595
  // Resolve and durably cache the session policy before rediscovery can
4419
4596
  // publish any request-capable provider surface.
4420
4597
  accountGroupResolutionInitialized = false;
@@ -4579,10 +4756,32 @@ export const createMultiAccountExtension =
4579
4756
  : decorateLogicalAttributionLifecycle(store, indicator);
4580
4757
  },
4581
4758
  deps: {
4759
+ captureSelectionSnapshot(modelId) {
4760
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
4761
+ if (!accountGroupResolutionInitialized) initializeSessionAccountGroupScope(context);
4762
+ const resolution = effectiveAccountGroupResolution;
4763
+ const members = resolution === undefined || resolution.source === "unrestricted" ? [] : [...(config.accountGroups?.[resolution.groupId] ?? [])];
4764
+ const policy = resolution === undefined || accountGroupScopeBlocked || policyConfigError !== undefined
4765
+ ? { kind: "blocked" as const, reason: policyConfigError !== undefined ? "config-invalid" as const : accountGroupBlockReason ?? "context-unavailable" }
4766
+ : { kind: "resolved" as const, resolution: resolution.source === "unrestricted" ? { source: "unrestricted" as const } : { source: resolution.source, groupId: resolution.groupId }, members };
4767
+ const accountLimit = config.accountLimit;
4768
+ const nowMs = Date.now();
4769
+ const rows = operatorAccounts(context, discovery, authPath, accountLimit).filter((account) => !disabledProviders.has(account.providerId));
4770
+ const allAccounts = logicalAccountsFromManaged(physicalAccountsForRouting(nowMs, rows), nowMs);
4771
+ const authorized = new Set(enforceSessionAccountGroup(rows, context).map((row) => row.providerId));
4772
+ const registry = readAccountGroupRegistrySnapshot(context?.modelRegistry);
4773
+ const physicalModels = context?.modelRegistry.getAll().filter((row) => !isAccountGroupVirtualModel(row)) ?? [];
4774
+ const otherCandidates = [...registry.providerIds].filter((providerId) => providerId !== LOGICAL_PROVIDER_ID && providerId !== OPENROUTER_PROVIDER_ID && classifyProviderId({ providerId, credentialType: "unknown" }) === null).map((providerId) => {
4775
+ const availability = accountGroupMemberAvailability(providerId, [providerId], registry, modelId);
4776
+ return { providerId, eligible: availability.eligible, reason: availability.reason, servesModel: physicalModels.some((row) => row.provider === providerId && row.id === modelId) };
4777
+ });
4778
+ return { accounts: allAccounts.filter((row) => authorized.has(row.providerId)), group: { policy, accountLimit, allAccounts, otherCandidates } };
4779
+ },
4582
4780
  // A getter, not an array. `clear`, `disable`, expiry and
4583
4781
  // provider-reported exhaustion must take effect on the next
4584
4782
  // request, not at the next restart.
4585
4783
  get accounts() {
4784
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
4586
4785
  const nowMs = Date.now();
4587
4786
  return logicalAccountsFromManaged(
4588
4787
  physicalAccountsForRouting(nowMs),
@@ -4592,16 +4791,27 @@ export const createMultiAccountExtension =
4592
4791
  dispatch: createLogicalDispatch(context.modelRegistry),
4593
4792
  onPublicTerminal: logicalTerminalAssociations.bindPublicTerminal,
4594
4793
  state,
4595
- routePin: {
4596
- get: () => state.getLogicalRoutePin(),
4597
- consume: (generation, requestedModelId) =>
4598
- state.consumeLogicalRoutePin(generation, requestedModelId),
4599
- clear: () => state.clearLogicalRoutePin(),
4794
+ // A recovered-past attempt never reaches message_end; commit its
4795
+ // account effects through its own exact association.
4796
+ onSupersededTerminal: (physical: AssistantMessage) => {
4797
+ commitSupersededLogicalFailure(physical);
4798
+ },
4799
+ get recoveryTiming() {
4800
+ return {
4801
+ recoveryIdleTimeoutMs: config.recoveryIdleTimeoutMs,
4802
+ recoveryAbsoluteTimeoutMs: config.recoveryAbsoluteTimeoutMs,
4803
+ recoveryStallTimeoutMs: config.recoveryStallTimeoutMs,
4804
+ };
4600
4805
  },
4601
4806
  modelVendor,
4602
4807
  get tierModelMap() {
4603
4808
  return config.tierModelMap;
4604
4809
  },
4810
+ // A getter, so a reload that turns same-family failover off
4811
+ // stops the next call's in-call account switch.
4812
+ get sameFamilyFailover() {
4813
+ return config.sameFamilyFailover;
4814
+ },
4605
4815
  ...(config.crossFamilyChainEnabled
4606
4816
  ? { crossFamilyChains: config.crossFamilyChains }
4607
4817
  : {}),
@@ -4664,20 +4874,22 @@ export const createMultiAccountExtension =
4664
4874
  // a fault in the logical gate must not take them down with it.
4665
4875
  diagnostics.recordError("logical.registration", error);
4666
4876
  }
4667
- void Promise.resolve()
4668
- .then(() =>
4669
- warmer.start(
4670
- () =>
4671
- projectAutomaticAccountCandidates(
4672
- discovery,
4673
- config.accountLimit,
4674
- ),
4675
- config,
4676
- ),
4677
- )
4678
- .catch((error) => {
4679
- diagnostics.recordError("credential.warmer.start", error);
4680
- });
4877
+ if (policyConfigError === undefined) {
4878
+ void Promise.resolve()
4879
+ .then(() =>
4880
+ warmer.start(
4881
+ () =>
4882
+ projectAutomaticAccountCandidates(
4883
+ discovery,
4884
+ config.accountLimit,
4885
+ ),
4886
+ config,
4887
+ ),
4888
+ )
4889
+ .catch((error) => {
4890
+ diagnostics.recordError("credential.warmer.start", error);
4891
+ });
4892
+ }
4681
4893
  // Pi restores the session model inside createAgentSession, strictly
4682
4894
  // BEFORE extensions load, so a managed alias does not exist yet and the
4683
4895
  // host falls back to a base provider. Now that the aliases are
@@ -4744,6 +4956,7 @@ export const createMultiAccountExtension =
4744
4956
  guarded(
4745
4957
  "lifecycle.before-agent-start",
4746
4958
  async (_event, upcomingContext) => {
4959
+ if (policyConfigError !== undefined) return;
4747
4960
  // The warmer and usage fetcher each project independently, so
4748
4961
  // removing one call-site guard cannot expose the sibling sink.
4749
4962
  void Promise.resolve()
@@ -4779,6 +4992,10 @@ export const createMultiAccountExtension =
4779
4992
  const accounts = subscriptionOperatorAccounts(
4780
4993
  context ?? upcomingContext,
4781
4994
  );
4995
+ for (const account of accounts) {
4996
+ const revision = normalizeCredentialRevision(account.credentialRevision ?? NaN);
4997
+ if (revision !== undefined) state.observeCredentialRevision(account.providerId, account.family, revision);
4998
+ }
4782
4999
  const nowMs = Date.now();
4783
5000
  const managedAccounts: SubscriptionManagedAccount[] = accounts.map(
4784
5001
  ({
@@ -4995,6 +5212,137 @@ export const createMultiAccountExtension =
4995
5212
  });
4996
5213
  }),
4997
5214
  );
5215
+ /**
5216
+ * A unified terminal-auth failure: invalidate the account, unless an
5217
+ * explicit 401 on a subscription account is repaired by the one forced
5218
+ * refresh the session allows it. The refresh only restores the account for
5219
+ * later calls; it never sends this call again.
5220
+ */
5221
+ const invalidateOnTerminalAuth = async (
5222
+ providerId: string,
5223
+ family: ManagedFamily,
5224
+ failure: ProviderFailureSignal,
5225
+ ): Promise<void> => {
5226
+ // An explicit 401 gets the one forced refresh whatever else the body
5227
+ // says, as direct settlement does: a server-revoked token can still
5228
+ // look locally valid.
5229
+ if (
5230
+ failure.httpStatus === 401 &&
5231
+ forcedCredentialRefresher !== undefined &&
5232
+ isAllowedFamily(family)
5233
+ ) {
5234
+ const outcome = await forcedCredentialRefresher
5235
+ .attempt(providerId, family)
5236
+ .catch(() => "failed" as const);
5237
+ if (outcome === "refreshed") return;
5238
+ }
5239
+ if (classifyFailure(failure).category !== "terminal-auth") return;
5240
+ state.invalidateAccount({
5241
+ providerId,
5242
+ family,
5243
+ reason: "terminal-auth-failure",
5244
+ invalidatedAtMs: Date.now(),
5245
+ });
5246
+ };
5247
+ /**
5248
+ * One opportunistic usage refresh for the physical account a unified
5249
+ * quota failure refused on, as direct settlement does for its own.
5250
+ *
5251
+ * Detached and fail-soft: it improves routing state and never delays or
5252
+ * changes the call. Only named fields reach the fetcher, and only for a
5253
+ * live subscription account; an owning-vendor API account has no pooled
5254
+ * usage to refresh.
5255
+ */
5256
+ const refreshUsageAfterLogicalQuotaFailure = (
5257
+ providerId: string,
5258
+ family: ManagedFamily,
5259
+ failedAtMs: number,
5260
+ ): void => {
5261
+ try {
5262
+ const account = physicalOperatorAccounts().find(
5263
+ (candidate) =>
5264
+ candidate.providerId === providerId && candidate.family === family,
5265
+ );
5266
+ if (account === undefined || !isRoutingEligibleAccountFamily(account)) {
5267
+ return;
5268
+ }
5269
+ void usageFetcher
5270
+ .refreshAfterFailure(
5271
+ {
5272
+ providerId: account.providerId,
5273
+ family: account.family,
5274
+ ...(account.credentialType === undefined
5275
+ ? {}
5276
+ : { credentialType: account.credentialType }),
5277
+ },
5278
+ config,
5279
+ failedAtMs,
5280
+ )
5281
+ .catch(() => undefined);
5282
+ } catch {
5283
+ // A refresh is an improvement, never a step the call depends on.
5284
+ }
5285
+ };
5286
+ /**
5287
+ * Account effects of one unified physical attempt the call recovered past.
5288
+ *
5289
+ * It never reaches message_end, so this applies what message_end and
5290
+ * settlement would: the quota limit streak and, when the provider gave no
5291
+ * recovery time, a bounded exhaustion hold, and one detached usage
5292
+ * refresh; or a terminal-auth invalidation. Its cooldown was already
5293
+ * written before the engine chose the next send. Nothing here switches the
5294
+ * model, continues, parks, or pins.
5295
+ */
5296
+ const applyLogicalFailureEffects = (
5297
+ association: {
5298
+ readonly route: LogicalRouteFact;
5299
+ readonly response?: ProviderFailureSignal;
5300
+ readonly failure?: ProviderFailureSignal;
5301
+ },
5302
+ dispatchedModelId: string,
5303
+ ): void => {
5304
+ const route = association.route;
5305
+ const providerId = route.providerId;
5306
+ const failure: ProviderFailureSignal = {
5307
+ ...(association.failure ?? {}),
5308
+ ...(association.response ?? {}),
5309
+ modelId: dispatchedModelId,
5310
+ };
5311
+ if (classifyFailure(failure).category === "quota-rate-limit") {
5312
+ const failedAtMs = Date.now();
5313
+ state.recordLimitError(providerId, failedAtMs);
5314
+ // Only the subscription families have shared usage state; an
5315
+ // owning-vendor API account is billed per request, not pooled.
5316
+ if (isAllowedFamily(route.family) && refusalWithoutRecoveryTime(failure)) {
5317
+ usage.recordExhaustionHold(providerId, route.family, failedAtMs);
5318
+ }
5319
+ refreshUsageAfterLogicalQuotaFailure(providerId, route.family, failedAtMs);
5320
+ }
5321
+ void invalidateOnTerminalAuth(providerId, route.family, failure).catch(() => undefined);
5322
+ };
5323
+ const commitSupersededLogicalFailure = (physical: AssistantMessage): void => {
5324
+ const association = logicalTerminalAssociations.consume(physical);
5325
+ let accepted = false;
5326
+ try {
5327
+ if (
5328
+ association !== undefined &&
5329
+ association.outcome === "fail" &&
5330
+ association.dispatchedModelId !== undefined &&
5331
+ boundedPhysicalIdentity(association.dispatchedModelId)
5332
+ ) {
5333
+ applyLogicalFailureEffects(association, association.dispatchedModelId);
5334
+ accepted = true;
5335
+ }
5336
+ } catch {
5337
+ diagnostics.record(
5338
+ "warning",
5339
+ "logical.attribution",
5340
+ "A recovered unified attempt's account effects could not be applied.",
5341
+ );
5342
+ } finally {
5343
+ logicalTerminalAssociations.complete(association, accepted);
5344
+ }
5345
+ };
4998
5346
  // Observation and classification only. Every switch, park, or resume waits
4999
5347
  // for `agent_settled`; see `settleTurn` above.
5000
5348
  pi.on("message_end", async (event, messageContext) => {