/** * Persistent and effective per-seat model/thinking configuration for ak-role. */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { assertRegisteredHostName, DEFAULT_ROLE_TURN_HOST } from "../host-descriptions.ts"; import { assertLegalEngineModel, assertLegalEngineName, pickEngineAxis, } from "../package-resources/engine-material.ts"; import { resolveConfiguredProvinceOfficer } from "../institutional-resolution.ts"; import { PUBLIC_CALLABLE_ROLES, PUBLIC_CONFIGURABLE_SEATS, isPublicCallableRole, isPublicConfigurableSeat, seatModelOnly, type ModelRef, type PublicCallableRole, type PublicConfigurableSeat, type PublicThinkingLevel, } from "./registry.ts"; /** Province officers that may carry a persistent model override (#453). */ export const GATE_OFFICER_SEATS = [ "gatekeeper", "inspector", "notary", ] as const; export type GateOfficerSeat = (typeof GATE_OFFICER_SEATS)[number]; export function isGateOfficerSeat(value: string): value is GateOfficerSeat { return (GATE_OFFICER_SEATS as readonly string[]).includes(value); } export type CredentialProviders = { "openai-codex": boolean; xai: boolean; }; export type SeatModelConfig = ModelRef; /** * Persistent seat row (#356/#384/#453/#522/#568): model, engine, and host are * independent axes. Axis-only residuals: notary and inspector may keep * host/engine after model clear; all other seats keep the baseline * provider/model required contract. */ export type PersistentSeatConfig = { provider?: string; model?: string; thinking?: PublicThinkingLevel; engine?: string; /** Optional labor-engine model id (#883); only meaningful with engine. */ engineModel?: string; host?: string; }; export type PublicCliConfig = { seats: Partial>; /** * #422: single-call auto-resume retry ceiling, sibling of `seats`. * Non-negative integer; 0 disables auto-resume (one dispatch per call). * undefined = package default (AUTO_RESUME_LIMIT). No package-local upper * bound (ADR 0035). */ autoResumeLimit?: number; /** * #592: opaque seat rows this build does not own. Carried through every * parse→save cycle so a write never silently erases neighboring-line rows * in the shared machine-wide file (same survival duty as #422's sibling * top-level key). Never consulted by resolveEffectiveSeat / * effectiveSeatConfigurations — read consumption still skips them. */ unknownSeats?: Record; }; export type EffectiveSource = | "persistent" | "invocation" | "inherit-gatekeeper" | "unconfigured"; /** Engine axis source is independent of model source (#356). */ export type EngineSource = "invocation" | "persistent" | "unconfigured"; export type HostSource = "invocation" | "persistent" | "default"; export type EffectiveSeat = { seat: PublicConfigurableSeat; source: EffectiveSource; selection?: SeatModelConfig; /** Selected engine name when configured; undefined = no engine (default path). */ engine?: string; /** Labor-engine model id when the resolved engine carries one (#883). */ engineModel?: string; engineSource: EngineSource; host: string; hostSource: HostSource; }; export type InvocationModelOverride = { model?: string; thinking?: PublicThinkingLevel; /** Optional engine override for this invocation only (#356). */ engine?: string; /** Optional engine-model override for this invocation only (#883). */ engineModel?: string; host?: string; }; export function publicCliConfigPath(home: string): string { if (typeof home !== "string" || home.trim() === "") { throw new Error("home must be explicitly provided"); } return join(home, ".ak-roles", "public-cli.json"); } export async function loadPublicCliConfig( home: string, ): Promise { const path = publicCliConfigPath(home); try { const raw = await readFile(path, "utf8"); return parsePublicCliConfig(JSON.parse(raw)); } catch (error) { if ( error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ) { return { seats: {} }; } throw error; } } export async function savePublicCliConfig( config: PublicCliConfig, home: string, ): Promise { const path = publicCliConfigPath(home); await mkdir(dirname(path), { recursive: true }); const normalized = parsePublicCliConfig(config); // Fold the opaque bucket back under seats for the on-disk shape. Do not // emit unknownSeats as a top-level key — neighboring lines read seats only. await writeFile( path, `${JSON.stringify(serializePublicCliConfig(normalized), null, 2)}\n`, "utf8", ); } export function setPersistentSeatConfig( config: PublicCliConfig, seat: PublicConfigurableSeat, selection: SeatModelConfig, ): PublicCliConfig { const previous = config.seats[seat]; return { // Spread preserves sibling top-level keys such as autoResumeLimit (#422): // a seat write must never silently drop them. ...config, seats: { ...config.seats, [seat]: { ...selection, // Model rewrite preserves a previously configured engine axis. ...pickEngineAxis(previous ?? {}), ...(previous?.host === undefined ? {} : { host: previous.host }), }, }, }; } /** * Clear a gate officer's persistent model override (#453). * Scope is GateOfficerSeat only — non-province seats have no destructive clear seam. * Notary and direct Inspector may retain host/engine residual axes while model * resolution returns to gatekeeper inheritance (#522 two independent * axes; #568 public Inspector; #620). Gatekeeper drops the whole row. * Already-absent seats are a no-op. */ export function clearPersistentSeatConfig( config: PublicCliConfig, seat: GateOfficerSeat, ): PublicCliConfig { const previous = config.seats[seat]; if (previous === undefined) return config; // Callable province seats keep independent host/engine residuals after model clear. if ( (seat === "notary" || seat === "inspector") && (previous.engine !== undefined || previous.host !== undefined) ) { return { ...config, seats: { ...config.seats, [seat]: { ...pickEngineAxis(previous), ...(previous.host === undefined ? {} : { host: previous.host }), }, }, }; } const { [seat]: _dropped, ...seats } = config.seats; return { ...config, seats }; } /** Set or clear the persistent main-session host on a callable role seat. */ export function setPersistentSeatHost( config: PublicCliConfig, seat: PublicCallableRole, host: string | undefined, ): PublicCliConfig { const previous = config.seats[seat]; if (previous === undefined) { throw new Error(`config seat ${seat} has no persistent model; set provider/model[:thinking] before host`); } if (host === undefined) { const { host: _dropped, ...rest } = previous; if ( seatModelOnly(rest) === undefined && rest.engine === undefined && rest.engineModel === undefined ) { const { [seat]: _row, ...seats } = config.seats; return { ...config, seats }; } return { ...config, seats: { ...config.seats, [seat]: rest } }; } const registered = assertRegisteredHostName(host); return { ...config, seats: { ...config.seats, [seat]: { ...previous, host: registered } } }; } /** * Set or clear persistent engine on a callable role seat (#356 / #378 / #391 / #453 / #883). * First engine still requires an existing seat row (model, or residual axes). * Clearing engine drops engineModel with it; clearing engine from an axis-only * residual drops the empty row; clearing engine from a model+engine row leaves * model-only. Optional engineModel on set writes both halves of the pool directive. * Seat type is PublicCallableRole (navigator included since #639). */ export function setPersistentSeatEngine( config: PublicCliConfig, seat: PublicCallableRole, engine: string | undefined, engineModel?: string, ): PublicCliConfig { const previous = config.seats[seat]; if (previous === undefined) { throw new Error( `config seat ${seat} has no persistent model; set provider/model[:thinking] before engine`, ); } if (engine === undefined) { const { engine: _dropped, engineModel: _droppedModel, ...modelOnly } = previous; // Drop the row only after the final independent axis is cleared. if (seatModelOnly(modelOnly) === undefined && modelOnly.host === undefined) { const { [seat]: _row, ...seats } = config.seats; return { ...config, seats }; } return { ...config, seats: { ...config.seats, [seat]: modelOnly, }, }; } // Engine-name path-safety syntax is owned solely by assertLegalEngineName // (call-request + config-parse seams). Setter is pure seat mutation. // Name-only set clears any prior engineModel so a new engine does not inherit // a stale multi-model id; pass engineModel to set both halves together. // Model-only edits use setPersistentSeatEngineModel. const { engineModel: _priorModel, ...withoutModel } = previous; const next: PersistentSeatConfig = engineModel === undefined ? { ...withoutModel, engine } : { ...withoutModel, engine, engineModel }; return { ...config, seats: { ...config.seats, [seat]: next, }, }; } /** * Set or clear the labor-engine model id on a callable seat (#883). * Requires an existing engine name; clearing model leaves engine intact. */ export function setPersistentSeatEngineModel( config: PublicCliConfig, seat: PublicCallableRole, engineModel: string | undefined, ): PublicCliConfig { const previous = config.seats[seat]; if (previous === undefined || previous.engine === undefined) { throw new Error( `config seat ${seat} has no persistent engine; set-engine before engine model`, ); } if (engineModel === undefined) { const { engineModel: _dropped, ...rest } = previous; return { ...config, seats: { ...config.seats, [seat]: rest, }, }; } return { ...config, seats: { ...config.seats, [seat]: { ...previous, engineModel }, }, }; } /** * #422 value domain authority for the auto-resume ceiling: non-negative integer, * no package-local upper bound (ADR 0035). `0` is legal and means auto-resume is * disabled (a single dispatch, no in-place retry). Negative numbers, fractions, * NaN, Infinity and non-number types are rejected loudly — never silently coerced. */ export function parseAutoResumeLimit(value: unknown): number { if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { throw new Error( `auto-resume limit must be a non-negative integer, got ${JSON.stringify(value)}`, ); } return value; } /** * #422: set the persistent autoResumeLimit top-level key. Pure mutation that * preserves all sibling keys (seats included). */ export function setAutoResumeLimit( config: PublicCliConfig, limit: number, ): PublicCliConfig { return { ...config, autoResumeLimit: parseAutoResumeLimit(limit) }; } /** * Config-parse seam: persistent call axes belong to PUBLIC_CALLABLE_ROLES; * engine names need only path-safety syntax (no closed material catalog; * #376 / #378 / #391 / ADR 0069). Syntax authority = assertLegalEngineName * (no injected duplicate). Persistent host must be pi or a description-table key (#510 / #731). */ export function validatePublicCliConfigAxes( config: PublicCliConfig, _packageRoot: string, ): void { for (const seat of Object.keys(config.seats) as PublicConfigurableSeat[]) { const row = config.seats[seat]; if (row?.engine !== undefined) { try { assertLegalEngineName(row.engine); } catch (error) { throw new Error( `config seat ${seat} engine is illegal: ${row.engine}`, { cause: error }, ); } } if (row?.engineModel !== undefined) { if (row.engine === undefined) { throw new Error( `config seat ${seat} engineModel requires engine`, ); } try { assertLegalEngineModel(row.engineModel); } catch (error) { throw new Error( `config seat ${seat} engineModel is illegal: ${row.engineModel}`, { cause: error }, ); } } if (row?.host !== undefined) { try { assertRegisteredHostName(row.host); } catch (error) { throw new Error( `config seat ${seat} host is unregistered: ${row.host}`, { cause: error }, ); } } } } export function parseModelSpec( spec: string, fallbackThinking?: PublicThinkingLevel, ): SeatModelConfig { const trimmed = spec.trim(); if (!trimmed) { throw new Error("model specification must be non-empty"); } const thinkingSplit = trimmed.lastIndexOf(":"); let modelPart = trimmed; let thinking: PublicThinkingLevel | undefined = fallbackThinking; // #346/#683: no colon → bare provider/model is legal. Colon present → suffix // is opaque thinking pass-through (no local whitelist); never swallow into model. if (thinkingSplit !== -1) { thinking = trimmed.slice(thinkingSplit + 1); modelPart = trimmed.slice(0, thinkingSplit); } const slash = modelPart.indexOf("/"); if (slash <= 0 || slash === modelPart.length - 1) { throw new Error( `model specification must be provider/model[:thinking], got ${spec}`, ); } const provider = modelPart.slice(0, slash); const model = modelPart.slice(slash + 1); // #346/#384: bare provider/model is legal — do not invent thinking. return thinking === undefined ? { provider, model } : { provider, model, thinking }; } export function formatModelSpec(selection: SeatModelConfig): string { const base = `${selection.provider}/${selection.model}`; return selection.thinking === undefined ? base : `${base}:${selection.thinking}`; } /** * Single source: seat model selection → Pi argv at the public CLI execution seam. * Bare provider/model omits --thinking so pi/model defaults apply; suffix passes through. */ export function buildSeatModelCliArgs(model: SeatModelConfig | undefined): string[] { if (model === undefined) return []; return [ "--provider", model.provider, "--model", model.model, ...(model.thinking === undefined ? [] : ["--thinking", model.thinking]), ]; } /** * Disk document shape for public-cli.json. Unknown seat rows live under * `seats` (not a parallel top-level key) so every build — old or new — sees * one seats map. */ function serializePublicCliConfig(config: PublicCliConfig): { seats: Record; autoResumeLimit?: number; } { return { // Known seats win on any key clash; by construction the two maps are // disjoint after parse, but prefer owned rows if a caller stuffed both. seats: { ...(config.unknownSeats ?? {}), ...config.seats, }, ...(config.autoResumeLimit === undefined ? {} : { autoResumeLimit: config.autoResumeLimit }), }; } function parsePublicCliConfig(value: unknown): PublicCliConfig { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error("public CLI config must be an object"); } const record = value as { seats?: unknown; autoResumeLimit?: unknown; unknownSeats?: unknown; }; // #422 round-trip preservation: the sibling top-level key must survive every // parse→save cycle; an unknown-key drop would silently erase it on any write. let autoResumeLimit: number | undefined; if (record.autoResumeLimit !== undefined) { autoResumeLimit = parseAutoResumeLimit(record.autoResumeLimit); } // #592: opaque bucket may already be present on an in-memory round-trip // (load→set→save calls parse again). Carry it before seats iteration so a // disk-shaped seats map and a memory-shaped bucket both survive. const unknownSeats: Record = {}; if (record.unknownSeats !== undefined) { if ( record.unknownSeats === null || typeof record.unknownSeats !== "object" || Array.isArray(record.unknownSeats) ) { throw new Error("public CLI config.unknownSeats must be an object"); } Object.assign(unknownSeats, record.unknownSeats as Record); } const withOpaque = (seats: PublicCliConfig["seats"]): PublicCliConfig => ({ seats, ...(autoResumeLimit === undefined ? {} : { autoResumeLimit }), ...(Object.keys(unknownSeats).length === 0 ? {} : { unknownSeats }), }); if (record.seats === undefined) { return withOpaque({}); } if ( record.seats === null || typeof record.seats !== "object" || Array.isArray(record.seats) ) { throw new Error("public CLI config.seats must be an object"); } const seats: PublicCliConfig["seats"] = {}; for (const [key, raw] of Object.entries( record.seats as Record, )) { // #592: shared machine-wide public-cli.json may hold seat rows a newer CLI // wrote. Do not consume them (resolve/enum stay owned-seat only), but keep // the raw value in the opaque bucket so save can put them back under seats. // Unknown field-level keys on known seats keep their existing tolerance. if (!isPublicConfigurableSeat(key)) { unknownSeats[key] = raw; continue; } seats[key] = parseSeatModelConfig(raw, key); } return withOpaque(seats); } function parseSeatModelConfig(value: unknown, seat: string): PersistentSeatConfig { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error(`config seat ${seat} must be an object`); } const raw = value as Record; const hasProvider = raw.provider !== undefined; const hasModel = raw.model !== undefined; if (hasProvider !== hasModel) { throw new Error(`config seat ${seat} requires both provider and model`); } if (raw.host !== undefined && (typeof raw.host !== "string" || raw.host.trim() === "")) { throw new Error(`config seat ${seat} host must be a non-empty string`); } if (raw.engine !== undefined && typeof raw.engine !== "string") { // Shape only: engine must be a string field. Path-safety syntax is deferred to // validatePublicCliConfigAxes → assertLegalEngineName (single authority). throw new Error(`config seat ${seat} engine must be a string`); } if (raw.engineModel !== undefined && typeof raw.engineModel !== "string") { throw new Error(`config seat ${seat} engineModel must be a string`); } // #453/#522/#568: notary/inspector host/engine residuals remain legal after // model clear. All other seats keep provider/model. if (!hasProvider) { if ( (seat === "notary" || seat === "inspector") && (typeof raw.engine === "string" || typeof raw.host === "string") ) { if (raw.thinking !== undefined) { throw new Error(`config seat ${seat} thinking requires provider/model`); } return { ...pickEngineAxis({ engine: typeof raw.engine === "string" ? raw.engine : undefined, engineModel: typeof raw.engineModel === "string" ? raw.engineModel : undefined, }), ...(raw.host === undefined ? {} : { host: raw.host as string }), }; } throw new Error(`config seat ${seat} requires provider`); } if (typeof raw.provider !== "string" || raw.provider.trim() === "") { throw new Error(`config seat ${seat} requires provider`); } if (typeof raw.model !== "string" || raw.model.trim() === "") { throw new Error(`config seat ${seat} requires model`); } // #384/#683: thinking is optional opaque pass-through; when present must be a string. if (raw.thinking !== undefined && typeof raw.thinking !== "string") { throw new Error(`config seat ${seat} thinking must be a string`); } const parsed: PersistentSeatConfig = { provider: raw.provider as string, model: raw.model as string, ...(raw.thinking === undefined ? {} : { thinking: raw.thinking as PublicThinkingLevel }), ...pickEngineAxis({ engine: typeof raw.engine === "string" ? raw.engine : undefined, engineModel: typeof raw.engineModel === "string" ? raw.engineModel : undefined, }), ...(raw.host === undefined ? {} : { host: raw.host as string }), }; return parsed; } export function providerConfigured( credentials: CredentialProviders, provider: string, ): boolean { if (provider === "openai-codex") return credentials["openai-codex"] === true; if (provider === "xai") return credentials.xai === true; return false; } /** * Typed provider-credential absence for the public seat providers ak-role owns. * Presence is read from auth.json shape (CredentialProviders), never from stderr prose. * Returns undefined for unknown/custom providers (not this package's credential map). */ export function missingPublicProviderCredential( provider: string, credentials: CredentialProviders, ): provider is "openai-codex" | "xai" { if (provider !== "openai-codex" && provider !== "xai") return false; return !providerConfigured(credentials, provider); } /** Effective seat with model axis present (#178). */ export type EffectiveSeatWithModel = EffectiveSeat & { selection: NonNullable; }; /** #178: narrow seat with model, or undefined — callers own presentation. */ export function resolvedSeatWithModel( seat: EffectiveSeat, ): EffectiveSeatWithModel | undefined { if (seat.selection === undefined) return undefined; return seat as EffectiveSeatWithModel; } /** * #178 shared missing-model wording; throw site stays per entry. * Recommend `--model` only when the entry actually has an invocation-model channel * (direct public CLI). Nested summons / navigator seat reads do not — config set only. */ export function missingResolvedSeatModelMessage( seat: PublicConfigurableSeat, remediation: "config" | "invocation-or-config" = "config", ): string { const configSet = `ak-role config set ${seat} `; if (remediation === "invocation-or-config") { return ( `seat ${seat} has no model configured; set with --model ` + ` or ${configSet}` ); } return `seat ${seat} has no model configured; set with ${configSet}`; } type UnhostedEffectiveSeat = Omit; function attachHostAxis( seat: UnhostedEffectiveSeat, config: PublicCliConfig, invocation?: InvocationModelOverride, ): EffectiveSeat { if (invocation?.host !== undefined) return { ...seat, host: invocation.host, hostSource: "invocation" }; const persistent = config.seats[seat.seat]?.host; if (persistent !== undefined) return { ...seat, host: persistent, hostSource: "persistent" }; return { ...seat, host: DEFAULT_ROLE_TURN_HOST, hostSource: "default" }; } function attachEngineAxis( seat: UnhostedEffectiveSeat, config: PublicCliConfig, invocation?: InvocationModelOverride, ): UnhostedEffectiveSeat { // #391: engine axis is PUBLIC_CALLABLE_ROLES only (single callable-seat predicate). if (!isPublicCallableRole(seat.seat)) { return { ...seat, engineSource: "unconfigured", }; } const persistentRow = config.seats[seat.seat]; const persistentEngine = persistentRow?.engine; const persistentEngineModel = persistentRow?.engineModel; if (invocation?.engine !== undefined) { // Invocation engine override: model only when the same invocation supplies it. return { ...seat, ...pickEngineAxis({ engine: invocation.engine, engineModel: invocation.engineModel, }), engineSource: "invocation", }; } if (persistentEngine !== undefined) { return { ...seat, ...pickEngineAxis({ engine: persistentEngine, engineModel: persistentEngineModel, }), engineSource: "persistent", }; } return { ...seat, engineSource: "unconfigured", }; } function resolveBaseSeat( config: PublicCliConfig, seat: PublicConfigurableSeat, ): UnhostedEffectiveSeat { // #620: subordinate officers consume institutional-resolution authority result. if (seat === "notary" || seat === "inspector") { const resolved = resolveConfiguredProvinceOfficer(config, seat); return { seat, source: resolved.source, ...(resolved.selection === undefined ? {} : { selection: resolved.selection }), engineSource: "unconfigured", }; } // Engine-only residual is not a persistent model source (#453). // #178: no package startup candidates — caller specifies via seat table or --model. const persistentModel = seatModelOnly(config.seats[seat]); if (persistentModel !== undefined) { return { seat, source: "persistent", selection: persistentModel, engineSource: "unconfigured", }; } return { seat, source: "unconfigured", engineSource: "unconfigured" }; } export function resolveEffectiveSeat( config: PublicCliConfig, seat: PublicConfigurableSeat, credentials: CredentialProviders, invocation?: InvocationModelOverride, ): EffectiveSeat { const hasModelInvocation = invocation !== undefined && (invocation.model !== undefined || invocation.thinking !== undefined); // credentials retained on the public resolve surface for call-site stability; // model axis no longer consults them (#178 deleted package startup fallback). void credentials; let modelSeat: UnhostedEffectiveSeat; if (!hasModelInvocation || invocation === undefined) { modelSeat = resolveBaseSeat(config, seat); } else if (invocation.model !== undefined) { const spec = invocation.model.includes(":") || invocation.thinking === undefined ? invocation.model : `${invocation.model}:${invocation.thinking}`; modelSeat = { seat, source: "invocation", selection: parseModelSpec(spec), engineSource: "unconfigured", }; } else { const base = resolveBaseSeat(config, seat); if (base.selection === undefined || invocation.thinking === undefined) { modelSeat = { seat, source: "unconfigured", engineSource: "unconfigured", }; } else { modelSeat = { seat, source: "invocation", selection: { ...base.selection, thinking: invocation.thinking }, engineSource: "unconfigured", }; } } // Seat axes only. Host-facing provider projection (#788) runs after the host // is selected as registered — never before (bad host must not reach model). return attachHostAxis( attachEngineAxis(modelSeat, config, invocation), config, invocation, ); } export function effectiveSeatConfigurations( config: PublicCliConfig, credentials: CredentialProviders, invocation?: InvocationModelOverride, ): EffectiveSeat[] { return PUBLIC_CONFIGURABLE_SEATS.map((seat) => resolveEffectiveSeat(config, seat, credentials, invocation), ); } /** Callable roles in package order. */ export function listRolesForDisplay( config: PublicCliConfig, credentials: CredentialProviders, invocation?: InvocationModelOverride, ): EffectiveSeat[] { return effectiveSeatConfigurations(config, credentials, invocation); } /** * Read configured credential presence from a Pi auth.json document. */ export function credentialProvidersFromAuthData( data: unknown, ): CredentialProviders { if (data === null || typeof data !== "object" || Array.isArray(data)) { return { "openai-codex": false, xai: false }; } const record = data as Record; return { "openai-codex": Object.prototype.hasOwnProperty.call(record, "openai-codex"), xai: Object.prototype.hasOwnProperty.call(record, "xai"), }; } export async function loadCredentialProviders( agentDir: string, ): Promise { try { const raw = await readFile(join(agentDir, "auth.json"), "utf8"); return credentialProvidersFromAuthData(JSON.parse(raw)); } catch (error) { if ( error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ) { return { "openai-codex": false, xai: false }; } throw error; } }