// Journal of /switch-account decisions persisted inside the Pi session. Pi // stores custom entries in the session file and never sends them to the model, // so a resumed session restores the operator's last explicit account choice // while account health, cooldowns, and implicit affinity stay in memory where // the scheduler owns them. export const SESSION_PIN_ENTRY_TYPE = 'pi-multiprovider:switch-account' export const SESSION_PIN_ENV = 'PI_MULTIPROVIDER_SESSION_PINS' /** One /switch-account decision: the account pinned to one session pool. */ export interface SessionPin { pool: string key: string /** Pinned account; undefined records an explicit return to automatic selection. */ accountId?: string /** Account label at switch time, used only to describe restore failures. */ label?: string } /** Parent /switch-account decision rebound onto a child session. */ export interface InheritedSessionPin { pool: string accountId?: string label?: string } export interface SessionPinHost { /** Whether the pool's scheduler is registered yet. */ hasPool(pool: string): boolean pin(pool: string, key: string, accountId: string): void | Promise clear(pool: string, key: string): void } function pinFromRecord(value: unknown): SessionPin | undefined { if (typeof value !== 'object' || value === null) return undefined const record = value as Record const pool = typeof record.pool === 'string' ? record.pool.trim() : '' const key = typeof record.key === 'string' ? record.key.trim() : '' if (pool === '' || key === '') return undefined const accountId = typeof record.accountId === 'string' ? record.accountId.trim() : undefined if (record.accountId !== undefined && (accountId === undefined || accountId === '')) return undefined const label = typeof record.label === 'string' && record.label.trim() !== '' ? record.label : undefined return { pool, key, ...(accountId === undefined ? {} : { accountId }), ...(label === undefined ? {} : { label }), } } /** * Reads the session's custom entries and returns the latest decision per pool * and affinity key, in the order the pools were first decided. Entries written * by other extensions, malformed records, and superseded decisions are ignored. */ export function sessionPinsFromEntries(entries: Iterable): SessionPin[] { const latest = new Map() for (const entry of entries) { if (typeof entry !== 'object' || entry === null) continue const candidate = entry as { type?: unknown; customType?: unknown; data?: unknown } if (candidate.type !== 'custom' || candidate.customType !== SESSION_PIN_ENTRY_TYPE) continue const pin = pinFromRecord(candidate.data) if (pin === undefined) continue latest.set(JSON.stringify([pin.pool, pin.key]), pin) } return [...latest.values()] } /** * Replays recorded decisions into the scheduler. Pools whose scheduler is not * registered yet are returned so the caller can retry after the next * reconcile; stale records — a removed or disabled account — are reported to * onError and dropped so they never block a later pin. Decisions that reached * the scheduler are reported to onApplied, which is how a resume tells * followers of the session's active account that it changed. */ export async function applySessionPins( pins: readonly SessionPin[], host: SessionPinHost, onError?: (pin: SessionPin, error: unknown) => void, onApplied?: (pin: SessionPin) => void, ): Promise { const pending: SessionPin[] = [] for (const pin of pins) { if (!host.hasPool(pin.pool)) { pending.push(pin) continue } try { if (pin.accountId === undefined) host.clear(pin.pool, pin.key) else await host.pin(pin.pool, pin.key, pin.accountId) } catch (error) { onError?.(pin, error) continue } onApplied?.(pin) } return pending } function inheritedPinFromRecord(value: unknown): InheritedSessionPin | undefined { if (typeof value !== 'object' || value === null) return undefined const record = value as Record const pool = typeof record.pool === 'string' ? record.pool.trim() : '' if (pool === '') return undefined const accountId = typeof record.accountId === 'string' ? record.accountId.trim() : undefined if (record.accountId !== undefined && (accountId === undefined || accountId === '')) return undefined const label = typeof record.label === 'string' && record.label.trim() !== '' ? record.label : undefined return { pool, ...(accountId === undefined ? {} : { accountId }), ...(label === undefined ? {} : { label }), } } /** Latest inherited pin per pool. Foreign, malformed, and empty records are dropped. */ export function inheritedSessionPinsFromUnknown(value: unknown): InheritedSessionPin[] { if (!Array.isArray(value)) return [] const latest = new Map() for (const item of value) { const pin = inheritedPinFromRecord(item) if (pin === undefined) continue latest.set(pin.pool, pin) } return [...latest.values()] } export function inheritedSessionPinsFromEnv( env: NodeJS.ProcessEnv = process.env, ): InheritedSessionPin[] { const raw = env[SESSION_PIN_ENV] if (typeof raw !== 'string' || raw.trim() === '') return [] try { return inheritedSessionPinsFromUnknown(JSON.parse(raw) as unknown) } catch { return [] } } export function serializeInheritedSessionPins(pins: readonly InheritedSessionPin[]): string { return JSON.stringify(inheritedSessionPinsFromUnknown(pins)) }