// config.ts — framework configuration + per-session autopilot state. // Split out of core.ts: the Autopilot lifecycle is the engine; everything // about HOW it is configured, WHERE state lives, and WHICH sessions are // autopilot-on lives here. import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs"; import { join, resolve as resolvePath } from "node:path"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; // Scheduled-off parsing lives in the framework module (ONE place with the // due-check + timer manager). The per-session state file lives in the LEAF // session-store.ts so both this module and scheduled-off.ts can depend on it // WITHOUT an import cycle (the earlier config ↔ scheduled-off shape only // worked because every cross-reference resolved at call time). import { writeAtomic, sessionAutopilotPath, readSessionStore, writeSessionStore, readScheduledOffAt, writeSessionAutopilotState, scheduleScheduledOff, } from "./session-store.ts"; import { parseDurationMs } from "./framework/scheduled-off.ts"; import { specCompleteness, specGapTag, type SpecGapId } from "./framework/spec-completeness.ts"; import { requirementsFor, validateApprovalContract, type ContractGap } from "./framework/item-contract.ts"; import { loadStore, unmetDependencies } from "./queue-store.ts"; import { normalizeModel, levelDefProblem, MODEL_LEVEL_RESERVED_KEYS, type ModelsConfig } from "./spawn-model.ts"; // Re-exports: the state helpers' public home stays config.ts for callers // (hosts, tests) that predate the leaf extraction. export { writeAtomic, sessionAutopilotPath, readScheduledOffAt, writeSessionAutopilotState }; export interface AutopilotConfig { stateDir: string; maxSlots?: number; // default 3 queueLowThreshold?: number; // default 2 (matches orchestrate.md buffer rule) workerAgents?: string[]; // default ["worker"] reviewerAgents?: string[]; // default ["orchestrator-reviewer"] (framework-owned, installed by the reviewer installer) reviewCap?: number; // review-FAIL re-dispatch cap, default 5 quietPeriodMs?: number; // min gap between ticks, default 60_000 /** Minutes an `active` item may sit past its last update before the * reconciliation sweeps may condemn its run, default 30; 0 disables them. * Read by Autopilot (zombieReconcile / deadRunReconcile) and set from the * config file + AUTOPILOT_ZOMBIE_GRACE_MINUTES — it was always passed and * always read, but never declared here. */ zombieGraceMinutes?: number; /** Intake suppression window (hours): pending proposals suppress intake * ticks for this long, default 24 (see AutopilotConfigFile). Read by * Autopilot.decideTick; absent falls through to the same 24h default. */ intakeSuppressionHours?: number; log?: (line: string) => void; // telemetry sink now?: () => number; // injectable clock } // --------------------------------------------------------------------------- /** Append a JSONL telemetry line (never throws). */ export function appendTelemetry(stateDir: string, line: string): void { try { const p = join(stateDir, "autopilot.jsonl"); mkdirSync(stateDir, { recursive: true }); appendFileSync(p, line + "\n", "utf8"); } catch { // silent } } // --------------------------------------------------------------------------- // State-dir resolution from the HOST CONFIG (AUTOPILOT-32 D3) // --------------------------------------------------------------------------- // The state dir used to live as a `- STATE_DIR: ` line INSIDE each // host's projected orchestrate.md — the one per-profile FACT that forced the // procedure to be copied per profile, and a standing violation of this // project's own rule (facts in config, procedure in prompts). It now lives in // autopilot.host.json in the host's own config dir ($PI_CODING_AGENT_DIR / // $OPENCODE_CONFIG_DIR): same per-profile scoping, no prompt copy. The // migration is OUTRIGHT — nothing reads the command file any more. /** The per-profile host config: the state-dir fact, next to the host's own * settings. NOT autopilot.config.json — that one lives INSIDE the state dir * and cannot declare where the state dir is. */ export function autopilotHostConfigPath(hostConfigDir: string): string { return join(hostConfigDir, "autopilot.host.json"); } export interface AutopilotHostConfig { /** Absolute path, or `~`-relative, to this profile's orchestrator state dir. */ stateDir?: string; } /** Read the declared state dir from a host config dir. Returns null when the * file is absent, unreadable, or declares nothing. Never throws. */ export function readHostStateDir(hostConfigDir: string): string | null { try { const p = autopilotHostConfigPath(hostConfigDir); if (!existsSync(p)) return null; const cfg = JSON.parse(readFileSync(p, "utf8")) as AutopilotHostConfig; let dir = typeof cfg.stateDir === "string" ? cfg.stateDir.trim() : ""; if (!dir) return null; if (dir.startsWith("~")) dir = join(process.env.HOME ?? homedir(), dir.slice(1)); dir = dir.replace(/\/+$/, ""); // normalize trailing slash return dir || null; } catch { return null; } } /** The opencode PROFILE config dir — ONE resolution for BOTH opencode entry * points (the server plugin and the TUI plugin), so they cannot resolve two * different queues. * * Chain: $OPENCODE_CONFIG_DIR (what the shell wrapper exports to select the * work/personal layer) → the TUI's own `api.state.path.config` when it has * one → the conventional root ($XDG_CONFIG_HOME/opencode, else * ~/.config/opencode) which is what an UNLAYERED session's api path reports. * * EMPIRICAL (opencode 1.18.30, pty run 2026-09-10): launched with * OPENCODE_CONFIG_DIR=/tmp/scratch the TUI STILL reports * `state.path.config = ~/.config/opencode`, so the api path must come second * — first would miss every layered profile. The SERVER plugin has no api path * at all (its Plugin input carries directory/worktree/client only), which is * why the last rung exists: without it the server skipped the host-config * fact entirely while the TUI read it. */ export function opencodeHostConfigDir(apiConfigPath?: string, env: NodeJS.ProcessEnv = process.env): string { if (env.OPENCODE_CONFIG_DIR) return env.OPENCODE_CONFIG_DIR; if (apiConfigPath) return apiConfigPath; const home = env.HOME ?? homedir(); return env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "opencode") : join(home, ".config/opencode"); } // --------------------------------------------------------------------------- // State-dir startup/activation probe (AUTOPILOT-3) // --------------------------------------------------------------------------- // The SILENT split-brain bug class: the extension resolves a state dir // (env → host command STATE_DIR → profile fallback) that is stale, missing, // or diverged from the orchestrator's own projection, then operates on a // phantom/empty store with NO signal — observed three times (opencode // projections pointing at pre-migration paths → empty-queue phantoms; a // missing autopilot.config.json → unreadable workspace facts; legacy // digit-less keys latching weird series prefixes). This probe verifies the // RESOLVED dir before anyone relies on it: the dir exists, queue.json (the // store) is there, autopilot.config.json is there when workspace facts are // promised, and the extension's resolution agrees with the orchestrate.md // projection (host parity). It NEVER throws — findings are reported (ONE // telemetry log + ONE host notify); the harness keeps running fail-open (a // spurious warning costs a notify; silent wrong-queue operation compounds). export interface StateDirProbeOptions { stateDir: string; /** The host config dir the host resolved FROM — the host-parity input (its * autopilot.host.json declares the profile's state dir). Omit when the host * has no config dir (an env-only harness session). */ hostConfigDir?: string; /** Verify autopilot.config.json too. The hosts pass true: the packaged * procedure they now inject ALWAYS promises workspace facts (the ON message * points the orchestrator at the config; a promised-but-missing config makes * the workspace facts unreadable). Default false — a bare framework probe * promises nothing. */ expectWorkspaceConfig?: boolean; } export interface StateDirProbeResult { ok: boolean; /** Human-readable findings; empty when ok. */ findings: string[]; } /** The framework-level state-dir probe (AUTOPILOT-3). Called at activation * (/autopilot on) and at session start; reports findings, NEVER throws. */ export function probeStateDir(opts: StateDirProbeOptions): StateDirProbeResult { const findings: string[] = []; const { stateDir } = opts; try { if (!existsSync(stateDir)) { findings.push(`state dir does not exist: ${stateDir} — the harness would operate on a PHANTOM store (nothing is ever read here)`); } const queuePath = join(stateDir, "queue.json"); if (!existsSync(queuePath)) { findings.push(`queue.json is missing at ${stateDir} — every queue read would be an EMPTY store. The real queue may live elsewhere (stale STATE_DIR projection?)`); } const configExpected = opts.expectWorkspaceConfig ?? false; if (configExpected && !existsSync(join(stateDir, "autopilot.config.json"))) { findings.push(`autopilot.config.json is missing at ${stateDir} — workspace facts were promised (intake sources / goals file / notes) but the orchestrator cannot read them`); } // HOST PARITY: what this host RESOLVED vs what the profile's host config // DECLARES. Normally identical (one resolution chain); a mismatch means env // took over while the declared fact still says otherwise — the harness // queues into A while the declared profile store is B. if (opts.hostConfigDir) { const hostPath = autopilotHostConfigPath(opts.hostConfigDir); if (existsSync(hostPath)) { const declared = readHostStateDir(opts.hostConfigDir); if (declared && declared !== stateDir) { findings.push(`HOST PARITY MISMATCH — the harness resolves ${stateDir} but ${hostPath} declares ${declared}; the orchestrator operates on a DIFFERENT store`); } else if (declared === null) { findings.push(`${hostPath} declares no stateDir — this profile has no state-dir fact; the harness falls back to ${stateDir}. Set "stateDir" there or the two diverge silently`); } } else if (!process.env.AUTOPILOT_STATE_DIR) { // NOTHING declares the store and no env pins it, so the resolution // FELL BACK — and a fallback can land on ANOTHER profile's LIVE store // (observed 2026-09-10: an opencode session with no declared state dir // resolved to the live personal store, and fixture writes overwrote // real queue items). Silence here is what made that possible. findings.push(`no ${hostPath} — this profile declares no state dir, so the harness FELL BACK to ${stateDir}. A fallback can be another profile's LIVE store: declare "stateDir" there (or set AUTOPILOT_STATE_DIR) before writing anything`); } } } catch { findings.push(`state-dir probe could not read ${stateDir} (permissions?) — verify the directory is accessible`); } return { ok: findings.length === 0, findings }; } /** Report a probe ONCE: ONE telemetry line (type `state-dir-probe`) when there * are findings, nothing when healthy. The host delivers the single notify * itself (its UI is host-specific). Never throws. */ export function logStateDirProbe(stateDir: string, r: StateDirProbeResult, extra: Record = {}): void { if (r.ok) return; appendTelemetry(stateDir, JSON.stringify({ t: new Date().toISOString(), type: "state-dir-probe", stateDir, ...extra, findings: r.findings })); } /** PROVISIONAL-LINGER check (AUTOPILOT-3): Q- PROVISIONAL proposals older * than `thresholdDays` that never reached approval. A provisional handle is * the default-Q series key allocated to a cwd-less proposal; it keeps its * Q- name until the approved transition renames it into the repo's real * series — so a stale one looks like a REAL series key (e.g. a genuine Q * workstream) and gets mistaken for one. Returns each lingering key with its * age in whole days (oldest first). NEVER throws. */ export function staleProvisionalProposals( stateDir: string, opts: { thresholdDays?: number; now?: number } = {}, ): Array<{ key: string; days: number }> { const thresholdDays = opts.thresholdDays ?? 3; const now = opts.now ?? Date.now(); const out: Array<{ key: string; days: number }> = []; try { const store = loadStore(stateDir); if (!store) return out; for (const it of Object.values(store.items)) { if (it.status !== "proposal") continue; if (it.provisionalKey !== true) continue; // explicit keys / deliberate series are never provisional if (!/^Q-\d/.test(it.key)) continue; const t = Date.parse(it.createdAt || it.updatedAt || ""); if (Number.isNaN(t)) continue; const days = Math.floor(Math.max(0, now - t) / 86_400_000); if (days >= thresholdDays) out.push({ key: it.key, days }); } } catch { // best-effort read — never throws } out.sort((a, b) => b.days - a.days); return out; } /** SPEC-COMPLETENESS surface (AUTOPILOT-26): the proposals whose scope is * missing at least one element, each with its gap list (worst first — no * scope at all outranks a thin one). ADVISORY ONLY: this feeds the panel tag * and the /autopilot status count; it is never consulted by the approval * gate. Reading the store here (rather than in the panel) is what keeps the * panel count and the status count identical by construction. NEVER throws. */ export function underSpecifiedProposals(stateDir: string): Array<{ key: string; gaps: SpecGapId[] }> { const out: Array<{ key: string; gaps: SpecGapId[] }> = []; try { const store = loadStore(stateDir); if (!store) return out; for (const it of Object.values(store.items)) { if (it.status !== "proposal") continue; const gaps = specCompleteness(it); if (gaps.length) out.push({ key: it.key, gaps }); } } catch { // best-effort read — never throws } out.sort((a, b) => Number(b.gaps.includes("no-scope")) - Number(a.gaps.includes("no-scope")) || a.key.localeCompare(b.key)); return out; } /** APPROVED ITEMS THAT VIOLATE THE SPECIFICATION CONTRACT (AUTOPILOT-128, * generalizing AUTOPILOT-58 part B) — the "later surface" for the incident * this exists for: 8 approved items with real, complete scopes sat idle for up * to an hour because their empty `risk` field silently skipped them in * isAutoDispatchable's gate, and nothing anywhere said so. The approval-time * contract now REFUSES such an item, but it cannot retroactively fix data * already in the store (or written by a path that bypasses the tools) — this * scan closes that gap from the SAME item-contract table, so an operator sees * it in `/autopilot status` instead of manually diffing queue_list's columns. * Named per item WITH its offending fields. NEVER throws. */ export function approvedContractGaps(stateDir: string): Array<{ key: string; gaps: ContractGap[] }> { const out: Array<{ key: string; gaps: ContractGap[] }> = []; try { const store = loadStore(stateDir); if (!store) return out; for (const it of Object.values(store.items)) { if (it.status !== "approved") continue; const gaps = validateApprovalContract(it as unknown as Record); if (gaps.length) out.push({ key: it.key, gaps }); } } catch { // best-effort read — never throws } out.sort((a, b) => a.key.localeCompare(b.key)); return out; } /** DEPENDENCY WAITING surface (KEY: AUTOPILOT-81) — approved items whose * `after` still names an unfinished dependency, named in `/autopilot status` * (queue_list carries the live per-item detail) so a full-looking approved * buffer that is actually stuck on a dependency is visible, not silently * idle. NEVER throws. */ export function waitingOnDependencies(stateDir: string): Array<{ key: string; after: string[] }> { const out: Array<{ key: string; after: string[] }> = []; try { const store = loadStore(stateDir); if (!store) return out; for (const it of Object.values(store.items)) { if (it.status !== "approved") continue; const unmet = unmetDependencies(it, store); if (unmet.length) out.push({ key: it.key, after: unmet }); } } catch { // best-effort read — never throws } out.sort((a, b) => a.key.localeCompare(b.key)); return out; } /** SERIALIZED-NO-DEPENDENCY RESIDUE (KEY: AUTOPILOT-109). A `blocked` + * `serialized` item with NO `after` is a contradiction — `serialized` means * "waiting on a named dependency", so with no dependency NOTHING can ever * release it: the release sweep has nothing to satisfy and no human gate was * set. The write-time gate (updateItem) now refuses to CREATE one, but a store * written before that gate can still hold them, and they sit until a human * acts. The population must not hide again — named in `/autopilot status` * (the SAME operator surface approvedContractGaps uses) so it is always * visible. NOT auto-released: it may be a deliberate indefinite park, so the * human decides. NEVER throws. */ export function serializedWithoutDependency(stateDir: string): string[] { const out: string[] = []; try { const store = loadStore(stateDir); if (!store) return out; for (const it of Object.values(store.items)) { if (it.status !== "blocked" || it.blocker !== "serialized") continue; if (it.after && it.after.length) continue; out.push(it.key); } } catch { // best-effort read — never throws } out.sort(); return out; } // --------------------------------------------------------------------------- // Per-session autopilot state // --------------------------------------------------------------------------- // The on/off toggle is scoped to the SESSION (keyed by pi session id), not the // mode's state dir — turning autopilot on in one session must not enable it in // another session sharing the same queue. Stored in autopilot.sessions.json. // The per-session state file + its accessors (readSessionStore, // writeSessionStore, writeSessionAutopilotState, readScheduledOffAt, // scheduleScheduledOff) live in the LEAF src/session-store.ts — see the // cycle note in the imports above. This module keeps the POLICY on top of it: // the command semantics. /** Per-session autopilot state: "on" | "off". Unknown sessions default OFF. */ export function readSessionAutopilotState(stateDir: string, sessionId: string): "on" | "off" { if (!sessionId) return "off"; const store = readSessionStore(stateDir); const mine = store[sessionId]; if (mine?.status === "on" || mine?.status === "off") return mine.status; // Sessions start OFF (the host writes off at session_start); activation is // always an explicit /autopilot on. return "off"; } /** Per-session gate: the extension's behavior is scoped to one session. */ export function isAutopilotOn(stateDir: string, sessionId?: string): boolean { if (!sessionId) return false; return readSessionAutopilotState(stateDir, sessionId) === "on"; } // --------------------------------------------------------------------------- // Capacity / config // --------------------------------------------------------------------------- export interface AutopilotConfigFile { maxSlots?: number; queueLowThreshold?: number; workerAgents?: string[]; reviewerAgents?: string[]; reviewCap?: number; // review-FAIL re-dispatch cap (default 5) /** Zombie reconciliation grace (default 30 min): an `active` item idle this * long while the fleet reports ZERO active runs is flipped to failed — its * completion event was lost (timeout overnight / crash / restart). 0 * disables the sweep. */ zombieGraceMinutes?: number; /** Workspace FACTS (mode-invariant): the per-environment truth the * orchestrator reads instead of per-mode prompt projections. Facts live in * config; procedure lives in the (single, mode-invariant) /orchestrate * command. Credentials stay out — reference a secret store. */ workspace?: WorkspaceConfig; sweepIntervalMs?: number; // periodic capacity sweep; 0 disables (default 10 min) /** Intake suppression window: pending proposals suppress intake ticks for * this many hours (the user deliberates), then the suppression lapses so a * STALE proposal cannot starve the refill nudge forever. Default 24. */ intakeSuppressionHours?: number; /** SHIPPING POLICY (KEY: AUTO-SHIP-ON-DONE) — the per-repo merge-finisher * gate. See ShippingConfig. */ shipping?: ShippingConfig; /** Provisional-linger threshold (default 3 days): a Q- PROVISIONAL * proposal (a cwd-less candidate allocated in the default Q series, renamed * into its real series only at approval) pending for longer than this is * surfaced in the decision panel + /autopilot status so nobody mistakes a * provisional handle for a real queue key. */ provisionalLingerDays?: number; /** WORKER/REVIEWER MODEL DEFAULT (KEY: AUTOPILOT-80): `provider/id[:thinking]` * applied to EVERY backend.spawn site — worker AND reviewer — when neither * an explicit per-call param nor the item's own `model` (AUTOPILOT-79) is * set; see resolveSpawnModel (spawn-model.ts) for the shared precedence. * Absent/undefined = every spawn keeps inheriting the session's model, * exactly as before either model feature existed. A malformed value is * validated the same way as the item override (normalizeModel) and FAILS * CLOSED at load — never applied, and loadAutopilotConfig() itself never * throws (several callers read it unguarded, e.g. the B26 tool_call safety * net) — see configWarnings() for the diagnostic naming what was rejected. */ model?: string; /** LEVEL MAP (KEY: AUTOPILOT-110/111): a user-managed map from a NAMED level * ("high"/"medium"/"low") to a model, plus the optional worker (default) * and reviewer (reviewDefault) default tier names and per-repo overrides * (repos, keyed like shipping.repos). Items and dispatch/review calls carry * a `tier` (a level NAME); the level resolves through this map at dispatch * time — see resolveSpawnModel/resolveModelLevel (spawn-model.ts). A raw * `model` anywhere still wins, and an absent map changes nothing. Not * validated against the model registry. * * FALLBACK, NOT THE SOURCE (KEY: AUTOPILOT-111): the map is OWNED by the * actuator — `actuator.config.json` → `models`, a SIBLING file in the SAME * resolved state dir — which is the ONE place to re-point model choices. * This local key is kept working so an existing configuration does not * change behaviour: a level the actuator does NOT define still resolves from * here, while a key BOTH define is won by the actuator and the shadowed * local key is named by configWarnings(). Do not add new levels here. */ models?: ModelsConfig; } /** The intake source vocabulary is deliberately OPEN: `type` is the label the * orchestrator interprets (the procedure for each type lives in the * orchestrator skill/command); everything else is that type's params. */ export interface IntakeSource { type: string; [param: string]: unknown; } export interface WorkspaceConfig { /** Goals file path (absolute or relative to the state dir). Default: * goals.json in the state dir. */ goalsFile?: string; /** Where the orchestrator's intake scans come from. */ intake?: IntakeSource[]; /** Free-form environment notes the orchestrator should know (working * hours, escalation paths, whatever). */ notes?: string; } /** SHIPPING POLICY (KEY: AUTO-SHIP-ON-DONE). The deterministic post-approval * merge-finisher lane is gated by a PER-REPO shipping policy. NO FALLBACK: * a repo without a policy never gets a guessed flow/base — the shipping run * NOTICES, asks the user once (policy-inquiry), and stays paused until the * one-time answer is written. */ export type ShippingFlow = "mrs" | "merge"; export interface ShippingRepoPolicy { /** `mrs` = one MR per baseBranch (a remote exists); `merge` = merge the * approved work into the LOCAL base branch (no remote). */ flow: ShippingFlow; /** Base branches — ORDER = MR sequence. `["main"]` = one MR; * `["dev","master"]` = two MRs. Any strings. For `merge`, the first is the * local target (default `main`). */ baseBranches: string[]; } export interface ShippingConfig { /** `auto` (default) runs the merge-finisher as soon as an item hits `done`; * `manual` only nudges — batch finishers stay an explicit orchestrator act. */ mergeMode?: "auto" | "manual"; /** Per-repo policies, keyed by origin-slug (`owner/repo` or `repo`) or the * repo's basename. An unknown repo → policy-inquiry, never a guess. */ repos?: Record; } /** The fully-resolved config `loadAutopilotConfig` returns (defaults applied). * `workspace`/`shipping` stay optional — their ABSENCE is meaningful (no * workspace facts / no shipping policy), never defaulted away. */ export type LoadedAutopilotConfig = Omit, "workspace" | "shipping" | "model" | "models"> & { workspace?: WorkspaceConfig; shipping?: ShippingConfig; /** Config-wide worker/reviewer model default (AUTOPILOT-80) — see * AutopilotConfigFile.model. Absent when unset, or when the configured * value was malformed (fail-closed — see configWarnings()). */ model?: string; /** The EFFECTIVE level map (AUTOPILOT-110/111): the actuator's map merged * over this repo's own `models` fallback — see AutopilotConfigFile.models. * Absent when neither source defines an object map. */ models?: ModelsConfig; }; /** The autopilot TOGGLE — ONE implementation, both hosts (pi /autopilot + the * opencode autopilot tool used to carry the same switch twice). on/off write * the per-session state + return the framework's mode message; status reports * the mode + capacity; capacity validates + saves. The hosts keep ONLY * delivery + host side effects (pi: notify + the /orchestrate injection on * "on"; opencode: the tool return). */ /** The ONE toggle result. `mode` is set for explicit on/off; `scheduledOffAt` * is the DISTINCT scheduling signal (epoch ms) — scheduling must never be * reported as mode "off" (autopilot stays ON until the deadline). Hosts arm * their ScheduleManager from it and deliver the message verbatim. * `changed` is false ONLY for a redundant on/off (the session was already in * that mode) — the IDEMPOTENCY signal: a redundant call still persists state * (harmless) but `message` is the SHORT no-op notice, not the full * activation payload (hint + "run your loop now" directive). Hosts gate * their own re-activation side effects (informOrchestrator, the full-payload * re-injection) on this so a repeated "on" never re-triggers a fresh * activation sweep instruction into an already-running orchestrator. */ export interface AutopilotCommandResult { ok: boolean; message: string; mode?: "on" | "off"; scheduledOffAt?: number; changed?: boolean; } export function autopilotCommand( action: string, value: string | undefined, opts: { stateDir: string; sessionId?: string; now?: () => number }, ): AutopilotCommandResult { const { stateDir, sessionId } = opts; switch (action) { case "on": { // IDEMPOTENCY: read the PRIOR state before writing — a redundant "on" // (already on) gets the short no-op message, not the full activation // payload (see AutopilotCommandResult.changed). const wasOn = sessionId ? readSessionAutopilotState(stateDir, sessionId) === "on" : false; if (sessionId) writeSessionAutopilotState(stateDir, sessionId, "on"); // also clears any schedule // The workspace hint rides the shared message: both hosts deliver THIS // result to their agent (opencode tool return / pi injected message). return { ok: true, message: autopilotModeMessage("on", opts.stateDir ? { stateDir: opts.stateDir } : undefined, { redundant: wasOn }), mode: "on", changed: !wasOn, }; } case "off": { // "off in " = SCHEDULED off: keep ON until the deadline, return the // distinct scheduledOffAt signal (never mode:"off"). The leading "in" // is optional so both "off in 30m" and a bare duration value parse. const spec = value?.trim(); if (spec) { const parsed = parseDurationMs(spec.replace(/^in\s+/i, "")); if (!parsed) { return { ok: false, message: "Usage: autopilot off in — e.g. 90s | 30m | 2h | 1h30m (max 24h). Bare 'off' takes effect immediately." }; } const nowMs = (opts.now ?? Date.now)(); const at = nowMs + parsed; if (!sessionId) { // A schedule that persists nothing but reports success is a lie — // the caller would believe an OFF is armed when no deadline exists. // (Immediate on/off tolerate a missing session — pre-existing // per-session guards — but this is a NEW success-looking response.) return { ok: false, message: "No session target yet — 'off in ' needs an active session. Run status first, then retry once the session registers." }; } scheduleScheduledOff(stateDir, sessionId, at); return { ok: true, message: `Autopilot stays ON until ${new Date(at).toISOString()} (${parsed / 1000}s) — then OFF automatically. Any explicit on/off cancels the schedule.`, scheduledOffAt: at, }; } // IDEMPOTENCY: same rule as "on" — a redundant off (already off) gets // the short no-op message instead of the full OFF explanation. const wasOff = sessionId ? readSessionAutopilotState(stateDir, sessionId) === "off" : true; if (sessionId) writeSessionAutopilotState(stateDir, sessionId, "off"); // also clears any schedule return { ok: true, message: autopilotModeMessage("off", undefined, { redundant: wasOff }), mode: "off", changed: !wasOff }; } case "status": { const on = isAutopilotOn(stateDir, sessionId); const cfg = loadAutopilotConfig(stateDir); const pending = sessionId ? readScheduledOffAt(stateDir, sessionId) : null; const suffix = pending !== null ? ` Scheduled OFF at ${new Date(pending).toISOString()}.` : ""; // Provisional-linger surface (AUTOPILOT-3): old Q- provisional // proposals look like real queue keys — name them here so nobody // mistakes a provisional handle for dispatched/reviewed work. const stale = staleProvisionalProposals(stateDir, { thresholdDays: cfg.provisionalLingerDays }); const linger = stale.length ? ` NOTE: provisional ${stale.map((s) => `${s.key} (${s.days}d)`).join(", ")} pending — resolve or reject it in the panel; a provisional Q- is NOT a real key.` : ""; // Spec-completeness surface (AUTOPILOT-26): the SAME count the proposals // panel tags, named here so under-specification is visible before the // approval gate. Advisory — these items still approve normally. const thin = underSpecifiedProposals(stateDir); const spec = thin.length ? ` NOTE: ${thin.length} proposal${thin.length === 1 ? "" : "s"} under-specified — ${thin.map((t) => `${t.key} (${specGapTag(t.gaps)})`).join(", ")}; refine in the panel (advisory, approval is still yours).` : ""; // SPECIFICATION-CONTRACT surface (KEY: AUTOPILOT-58 part B, widened by // AUTOPILOT-128) — the recurring reminder so an approved item that // violates the contract (legacy data, or anything that bypassed the // approval-time gate) is never silently sitting idle: see // approvedContractGaps' doc comment for the incident this closes. The // gap list is rendered from the SAME item-contract table the write // validation uses, so the surface never drifts from the gate. const contractViolations = approvedContractGaps(stateDir); // The attributes auto-dispatch actually READS — derived from the contract's // DISPATCH row (scope/cwd/risk), never a second hand-written list. A gap in // one of THESE keeps the item out of a free slot; a gap in // evidence/value/urgency violates the approval contract but does NOT gate // auto-dispatch, so the surface states the consequence PER ITEM instead of // claiming every violation blocks dispatch (which would be false). const autoDispatchFields = new Set(requirementsFor("approved", "active").filter((r) => r.kind === "required").map((r) => r.field)); const contractConsequence = (c: { key: string; gaps: ContractGap[] }): string => { const fields = c.gaps.map((g) => g.field); return fields.some((f) => autoDispatchFields.has(f)) ? `${c.key} (${fields.join(", ")}) will NOT auto-dispatch (auto-dispatch reads scope/cwd/risk)` : `${c.key} (${fields.join(", ")}) still auto-dispatches (evidence/value/urgency are not lane-read)`; }; const risk = contractViolations.length ? ` NOTE: ${contractViolations.length} approved item${contractViolations.length === 1 ? "" : "s"} VIOLATE THE SPECIFICATION CONTRACT — ${contractViolations .map(contractConsequence) .join("; ")}; fix with queue_update({key, ...}) (risk must be low|medium|high; value/urgency H|M|L; scope/cwd/evidence required).` : ""; // MALFORMED CONFIG SURFACE (KEY: AUTOPILOT-80): a fail-closed value // (currently just `model`) is dropped silently by loadAutopilotConfig — // named here, the one place config is already announced to the operator, // so a silently-ignored value is still visible. const warn = configWarnings(stateDir); const warnNote = warn.length ? ` NOTE: autopilot.config.json — ${warn.join("; ")}.` : ""; // DEPENDENCY WAITING surface (KEY: AUTOPILOT-81) — named alongside the // no-risk surface: both describe an approved item that LOOKS ready but // will never auto-dispatch until the operator acts. const waiting = waitingOnDependencies(stateDir); const waitingNote = waiting.length ? ` NOTE: ${waiting.length} approved item${waiting.length === 1 ? "" : "s"} WAITING on a dependency — ${waiting.map((w) => `${w.key} (after: ${w.after.join(", ")})`).join(", ")}; released automatically once the dependency reaches done, or edit \`after\` to unblock now.` : ""; // SERIALIZED-NO-DEPENDENCY RESIDUE surface (KEY: AUTOPILOT-109) — named // alongside the other "legacy/bypassed data" surfaces so a blocked item // that nothing can release is visible rather than silently stranded. const residue = serializedWithoutDependency(stateDir); const residueNote = residue.length ? ` NOTE: ${residue.length} blocked item${residue.length === 1 ? "" : "s"} SERIALIZED with NO dependency — ${residue.join(", ")}; nothing can release ${residue.length === 1 ? "it" : "them"} automatically (a serialized park must name an \`after\`). Re-park as parked/decision, set \`after\`, or release by hand.` : ""; return { ok: true, message: `Autopilot ${on ? "ON" : "OFF"} (this session${sessionId ? ` ${sessionId.slice(0, 8)}` : ""}) — capacity ${cfg.maxSlots} workers, queue-low < ${cfg.queueLowThreshold} ready.${suffix}${linger}${spec}${risk}${waitingNote}${residueNote}${warnNote}`, }; } case "capacity": { const n = Number((value ?? "").trim()); if (!Number.isFinite(n) || n < 1) return { ok: false, message: "Usage: autopilot capacity (n ≥ 1)" }; // KEY: AUTOPILOT-111 — loadAutopilotConfig's `models` is the EFFECTIVE // map (the actuator's merged over this file's fallback). Persisting it // would copy every actuator level into autopilot.config.json, re-create // the duplication this item closes, and shadow the actuator's map // forever. Save THIS file's own `models` (absent when it has none). const local = ownModels(stateDir); const { models: _effectiveModels, ...rest } = loadAutopilotConfig(stateDir); saveAutopilotConfig(stateDir, { ...rest, maxSlots: n, ...(local ? { models: local } : {}) }); return { ok: true, message: `Worker capacity set to ${n} (takes effect immediately)` }; } default: return { ok: false, message: "Usage: autopilot on | off [in ] | status | capacity " }; } } /** The ONE mode explanation for the orchestrator — the toggle means the * SAME thing on every host. Hosts only DELIVER it (pi: sendUserMessage * followUp; opencode: the autopilot tool return); they never re-word it. * The per-session state (isAutopilotOn) is the shared gate for all of it. * `redundant: true` — the session was ALREADY in this mode — returns the * SHORT no-op notice instead: a repeated "on" must never re-send the * run-your-loop-now directive into an orchestrator that is already running * (that re-triggers a full activation sweep on every idempotent toggle). */ export function autopilotModeMessage(mode: "on" | "off", workspace?: { stateDir: string }, opts?: { redundant?: boolean }): string { if (opts?.redundant) { return mode === "on" ? "Autopilot is already ON — no change." : "Autopilot is already OFF — no change."; } // The workspace hint rides the ON message only: facts are read ONCE at // activation (single source of per-environment truth = the config file); // OFF doesn't need them (the orchestrator isn't scanning anything). const hint = mode === "on" && workspace ? `\n\nWorkspace facts: state dir ${workspace.stateDir} — autopilot.config.json there carries your \"workspace\" section (intake sources, goals file). Read it before intake scans; it is the single source of per-environment truth.` : ""; // The run-now directive makes startup DETERMINISTIC without a tick: the ON // message is an instruction to act, arriving AFTER /orchestrate loaded the // mode. Startup never nudges (no activation tick) — this replaces it. const runNow = mode === "on" ? "\n\nYou were just activated: run your loop now — call queue_list to read the queue, dispatch ready approved items into free slots, and run an intake scan (proposing the next batch) if the approved buffer is low (< 2 ready)." : ""; return mode === "on" ? "Autopilot is now ON — the harness is active: it auto-dispatches approved items (scope + cwd + low/medium risk), auto-dispatches reviews on completion, auto-re-dispatches on review FAIL, AUTO-RECOVERS failed items (by cause: capped → bigger budget, verdict/zombie → one P5 re-dispatch, provider/infra → bounded retries with a degraded-window hold), routes verdicts (PASS to human-review, awaiting you), and sends [orch-tick] state messages. You keep: approval, high-risk checkpoints, review overrides, flag_for_review, steering. Do not manually queue_dispatch/queue_review what the harness handles." + hint + runNow : "Autopilot is now OFF — the PROACTIVE harness is idle: no auto flips (no auto re-dispatch, no zombie/dead-run safety-net flips, no shipping merge), no auto-dispatch of approved items, no auto-dispatched reviews, no auto-recovery of failed items, and no intake/dispatch capacity nudges (KEY: AUTOPILOT-120). It STILL records run completions and routes verdicts, and it STILL sends the INFORMATIONAL [orch-tick] messages — review, failure, budget, decision, harness, ship, main-write, config — so you keep the event feed; only the intake tick and the automatic-action lane are silenced. What YOU must do by hand: dispatch (queue_dispatch), review (queue_review), approve (queue_update status: done), re-dispatch with findings (queue_update status: active), and flag (flag_for_review). The queue tools remain available. Re-enable by running the autopilot on command."; } /** The ownership marker recorded inside a basename-fallback profile folder — * the FULL caller path that resolved there, not just its basename * (AUTOPILOT-39 residual risk). Lets resolveStateDir tell "the same profile * dir, resolved again" (marker matches) apart from "a DIFFERENT config dir * that just happens to share a basename" (marker mismatch) — the latter * used to silently reuse the SAME folder, so an unrelated caller (a test * fixture, a throwaway harness dir, anything ending in e.g. `/personal`) * landed on and could corrupt another profile's LIVE store. Reproduced live * 2026-09-11 against this repo's own real `~/.local/state/orchestrator/ * personal` store. */ function profileOwnerMarkerPath(profileStateDir: string): string { return join(profileStateDir, ".profile-owner.json"); } /** Resolve which folder a basename-fallback caller actually gets: the plain * `` folder when it is unclaimed or already owned by THIS caller's * own full path, or a path-derived disambiguated folder when a DIFFERENT * full path already claims that basename — never the collision itself. * Fail-open: any marker read/write error falls back to the pre-fix plain * basename path (the old behavior), never a throw. */ function claimProfileFolder(profileDir: string, profile: string, home: string): string { const plain = join(home, ".local/state/orchestrator", profile); const callerPath = resolvePath(profileDir); try { const markerPath = profileOwnerMarkerPath(plain); if (existsSync(markerPath)) { const owner = JSON.parse(readFileSync(markerPath, "utf8")) as { hostConfigDir?: string }; if (typeof owner.hostConfigDir === "string" && owner.hostConfigDir !== callerPath) { // Collision: someone else already owns `plain`. This caller gets its // OWN folder, keyed off its full path (stable + unique per caller), // never the other owner's. const digest = createHash("sha1").update(callerPath).digest("hex").slice(0, 8); const disambiguated = join(home, ".local/state/orchestrator", `${profile}~${digest}`); const disambiguatedMarker = profileOwnerMarkerPath(disambiguated); if (!existsSync(disambiguatedMarker)) { writeAtomic(disambiguatedMarker, JSON.stringify({ hostConfigDir: callerPath })); } return disambiguated; } return plain; // unclaimed-by-anyone-else, or already ours } // First resolution of this basename (or a pre-fix folder with no marker // yet, AUTOPILOT-32-era installs included): claim it for THIS caller so a // later DIFFERENT caller sharing the basename is the one redirected, not // this one — preserves the existing folder for whoever is already using it. writeAtomic(markerPath, JSON.stringify({ hostConfigDir: callerPath })); return plain; } catch { return plain; // never fail closed — worse to throw than to fall back } } /** Resolve the state dir ONCE, in the framework — no per-host copies. Chain: * AUTOPILOT_STATE_DIR env → the host config dir's autopilot.host.json * `stateDir` (the per-profile FACT, AUTOPILOT-32 D3) → PROFILE-SCOPED: * ~/.local/state/orchestrator/. The * profile folder's NAME (not its semantics) namespaces the state — * machine-scoped (~/.local/state), never inside the profile dir itself (agent * dirs may be managed repos), no mode-name knowledge. The framework is * profile-blind AND host-blind. * * `env` defaults to `process.env` (production) and is injectable for tests — * same seam as `opencodeHostConfigDir` — so a test can isolate `HOME` (and * therefore every disk path this resolves) without mutating the real * process env, which a test-time `process.env.HOME = ...` assignment does * NOT actually redirect (`os.homedir()` ignores a post-startup mutation in * this runtime — verified live, AUTOPILOT-39). */ export function resolveStateDir(hostConfigDir?: string, env: NodeJS.ProcessEnv = process.env): string { if (env.AUTOPILOT_STATE_DIR) return env.AUTOPILOT_STATE_DIR; if (hostConfigDir) { const declared = readHostStateDir(hostConfigDir); if (declared) return declared; } // HOST-NEUTRAL fallback: scope by the CALLER's config dir, never by a host // variable that belongs to a different host. Reading PI_CODING_AGENT_DIR for // an opencode caller handed that session the PI profile's live store // whenever the variable happened to be exported (the shell wrappers export // both), so two hosts on two profiles shared one queue file. Only a caller // with NO config dir (an env-only harness) falls through to the pi variable. const profileDir = hostConfigDir || env.PI_CODING_AGENT_DIR || ""; const home = env.HOME ?? homedir(); if (!profileDir) return join(home, ".local/state/orchestrator"); // The profile dir's basename namespaces the state (personal, work, …) — a // folder name, not a mode name: no semantics, purely deterministic scoping. const profile = (profileDir.split("/").filter(Boolean).pop() ?? "").replace(/[^A-Za-z0-9_-]/g, "") || "default"; return claimProfileFolder(profileDir, profile, home); } export function autopilotConfigPath(stateDir: string): string { return join(stateDir, "autopilot.config.json"); } /** The ACTUATOR's config — a SIBLING of autopilot.config.json in the SAME * resolved state dir (KEY: AUTOPILOT-111). The two systems resolve that dir * through the SAME convention (AUTOPILOT_STATE_DIR → the host config dir → * the profile-scoped fallback under ~/.local/state/orchestrator/) and the * actuator loads its own actuator.config.json from it, so this is the * directory this repo ALREADY computes — not a cross-repo path guess. */ export function actuatorConfigPath(stateDir: string): string { return join(stateDir, "actuator.config.json"); } /** What the actuator's config contributed (KEY: AUTOPILOT-111). Distinguishes * "no file" from "file unreadable" from "the `models` key was not an object" * from "a level entry was malformed" so the diagnostic surface * (configWarnings) can name the problem while the loader degrades to this * repo's own fallback. */ export interface ActuatorModelsRead { /** Its `models` map, when present and a plain object — with any malformed * LEVEL entry DROPPED (see invalidEntries). The shape ACTUATOR-109 mirrors * from AUTOPILOT-110 exactly. */ models?: ModelsConfig; /** The `models` value as written, when present but NOT a plain object — * never applied. */ malformedModels?: unknown; /** Why the file could not be read or parsed. Degrades to the local map. */ readError?: string; /** Level entries dropped because they were not a valid definition — keyed * by level name. Reported, never fatal: a bad entry in another system's * file must not block a spawn, so a valid local definition (if any) still * applies. */ invalidEntries?: { key: string; reason: string }[]; } /** Drop malformed LEVEL entries from the actuator's map (KEY: AUTOPILOT-111): * an entry that is neither a `provider/id[:thinking]` string nor a * `{model, thinking?}` object is REPORTED (invalidEntries) and NOT applied, so * a valid local definition still applies — "a malformed entry should be * reported, not fatal". The reserved keys (default/reviewDefault/repos) pass * through untouched; their shape is validated where they are read. */ function sanitizeActuatorModels(models: ModelsConfig): { models: ModelsConfig; invalid: { key: string; reason: string }[] } { const clean: ModelsConfig = {}; const invalid: { key: string; reason: string }[] = []; for (const [k, v] of Object.entries(models)) { if (MODEL_LEVEL_RESERVED_KEYS.includes(k)) { clean[k] = v; continue; } const problem = levelDefProblem(v); if (problem) invalid.push({ key: k, reason: problem }); else clean[k] = v; } return { models: clean, invalid }; } /** Read the actuator's LEVEL MAP from `/actuator.config.json`. * NEVER throws and NEVER reports absence as an error: the file is optional * (an install that has no actuator, or has not written one yet), and a * missing/unreadable/partial config must degrade to today's behaviour rather * than block a spawn. A non-string/empty stateDir (an unset host dir reaching * loadAutopilotConfig, which is contractually a NON-throwing read) is treated * exactly like an absent file. */ export function readActuatorModels(stateDir: string): ActuatorModelsRead { if (typeof stateDir !== "string" || !stateDir) return {}; let p: string; try { p = actuatorConfigPath(stateDir); if (!existsSync(p)) return {}; } catch { return {}; // a state dir that cannot even be joined degrades like an absent file } try { const cfg: unknown = JSON.parse(readFileSync(p, "utf8")); const m = cfg && typeof cfg === "object" ? (cfg as { models?: unknown }).models : undefined; if (m === undefined) return {}; if (m === null || typeof m !== "object" || Array.isArray(m)) return { malformedModels: m }; const { models, invalid } = sanitizeActuatorModels(m as ModelsConfig); return invalid.length ? { models, invalidEntries: invalid } : { models }; } catch (e) { return { readError: e instanceof Error ? e.message : String(e) }; } } /** The EFFECTIVE level map (KEY: AUTOPILOT-111): the actuator's map is the * SOURCE OF TRUTH, this repo's own `models` a strictly secondary fallback. * Merged per TOP-LEVEL key (a level name, `default`, `reviewDefault`, or * `repos` as a whole): a key the actuator defines wins outright — a level * definition is REPLACED, never deep-merged (the object form's `{model, * thinking?}` must not borrow the local `thinking`) — and a key it does not * define falls back to the local map, so an existing autopilot.config.json * keeps resolving exactly as before. */ function effectiveModels(actuator: ModelsConfig | undefined, local: ModelsConfig | undefined): ModelsConfig | undefined { if (!actuator) return local; if (!local) return actuator; return { ...local, ...actuator }; } /** The `models` map THIS FILE (autopilot.config.json) itself carries — the * LOCAL fallback, distinct from the EFFECTIVE map loadAutopilotConfig returns. * Used by the capacity write-back so it never copies the actuator's map into * this file (KEY: AUTOPILOT-111) — that would re-create the duplication this * item closes and pin a stale shadow of the actuator's map. Never throws. */ function ownModels(stateDir: string): ModelsConfig | undefined { try { const p = autopilotConfigPath(stateDir); if (!existsSync(p)) return undefined; const file = JSON.parse(readFileSync(p, "utf8")) as AutopilotConfigFile; return file.models && typeof file.models === "object" && !Array.isArray(file.models) ? file.models : undefined; } catch { return undefined; } } /** * Read autopilot.config.json. Env overrides (AUTOPILOT_MAX_SLOTS, * AUTOPILOT_QUEUE_LOW, AUTOPILOT_WORKER_AGENTS) win over the file; the file * wins over built-in defaults. */ export function loadAutopilotConfig(stateDir: string, env: NodeJS.ProcessEnv = process.env): LoadedAutopilotConfig { let file: AutopilotConfigFile = {}; try { const p = autopilotConfigPath(stateDir); if (existsSync(p)) file = JSON.parse(readFileSync(p, "utf8")) as AutopilotConfigFile; } catch { file = {}; } const maxSlots = env.AUTOPILOT_MAX_SLOTS ? Number(env.AUTOPILOT_MAX_SLOTS) : file.maxSlots ?? 3; const queueLowThreshold = env.AUTOPILOT_QUEUE_LOW ? Number(env.AUTOPILOT_QUEUE_LOW) : file.queueLowThreshold ?? 2; const workerAgents = env.AUTOPILOT_WORKER_AGENTS ? env.AUTOPILOT_WORKER_AGENTS.split(",").map((s) => s.trim()).filter(Boolean) : file.workerAgents ?? ["worker"]; const reviewerAgents = env.AUTOPILOT_REVIEWER_AGENTS ? env.AUTOPILOT_REVIEWER_AGENTS.split(",").map((s) => s.trim()).filter(Boolean) : file.reviewerAgents ?? ["orchestrator-reviewer"]; const reviewCap = env.AUTOPILOT_REVIEW_CAP ? Number(env.AUTOPILOT_REVIEW_CAP) : file.reviewCap ?? 5; const sweepIntervalMs = env.AUTOPILOT_SWEEP_INTERVAL_MS ? Number(env.AUTOPILOT_SWEEP_INTERVAL_MS) : file.sweepIntervalMs ?? 600_000; const zombieGraceMinutes = env.AUTOPILOT_ZOMBIE_GRACE_MINUTES ? Number(env.AUTOPILOT_ZOMBIE_GRACE_MINUTES) : file.zombieGraceMinutes ?? 30; const provisionalLingerDays = file.provisionalLingerDays ?? 3; const intakeSuppressionHours = file.intakeSuppressionHours ?? 24; // WORKER/REVIEWER MODEL DEFAULT (KEY: AUTOPILOT-80): validated the same way // as the per-item/per-dispatch override (normalizeModel). A malformed value // FAILS CLOSED — dropped, never applied to a spawn — rather than reaching // backend.spawn as a bad string; this function stays a pure, non-throwing // read (see the field doc on AutopilotConfigFile.model). configWarnings() // is the diagnostic surface naming what was rejected. let model: string | undefined; if (file.model !== undefined) { const m = normalizeModel(file.model); if (m.ok) model = m.model; } // LEVEL MAP (KEY: AUTOPILOT-110/111): THE MAP'S SOURCE IS THE ACTUATOR. // The actuator OWNS this user-configurable capability (ADR-0001) and its map // is a SIBLING file in the SAME resolved state dir (see actuatorConfigPath), // so there is no cross-repo path guess; reading it here is what CLOSES the // duplication — ONE map to re-point. This repo's own `models` key is kept // working as a strictly secondary fallback (configWarnings names every local // key the actuator shadows). Both are passed through only when they are // objects — malformed actuator LEVEL entries are dropped and reported by // readActuatorModels — and the individual levels are validated at RESOLUTION // time (resolveModelLevel), where an unknown or malformed level fails // LOUDLY. A non-object local value is dropped here and named by // configWarnings(). const localModels: ModelsConfig | undefined = file.models && typeof file.models === "object" && !Array.isArray(file.models) ? file.models : undefined; const models = effectiveModels(readActuatorModels(stateDir).models, localModels); return { maxSlots: Number.isFinite(maxSlots) && maxSlots >= 1 ? maxSlots : 3, queueLowThreshold: Number.isFinite(queueLowThreshold) && queueLowThreshold >= 1 ? queueLowThreshold : 2, workerAgents: workerAgents.length ? workerAgents : ["worker"], reviewerAgents: reviewerAgents.length ? reviewerAgents : ["orchestrator-reviewer"], reviewCap: Number.isFinite(reviewCap) && reviewCap >= 1 ? reviewCap : 5, sweepIntervalMs: Number.isFinite(sweepIntervalMs) && sweepIntervalMs >= 0 ? sweepIntervalMs : 600_000, zombieGraceMinutes: Number.isFinite(zombieGraceMinutes) && zombieGraceMinutes >= 0 ? zombieGraceMinutes : 30, provisionalLingerDays: Number.isFinite(provisionalLingerDays) && provisionalLingerDays >= 1 ? provisionalLingerDays : 3, intakeSuppressionHours: Number.isFinite(intakeSuppressionHours) && intakeSuppressionHours >= 0 ? intakeSuppressionHours : 24, ...(file.workspace ? { workspace: file.workspace } : {}), ...(file.shipping ? { shipping: file.shipping } : {}), ...(model ? { model } : {}), ...(models ? { models } : {}), }; } /** Diagnostics for autopilot.config.json values that loadAutopilotConfig() * silently FAILS CLOSED on (KEY: AUTOPILOT-80): recognized but invalid, so * the loaded config drops them rather than applying something malformed — * but a dropped value must not be entirely invisible. loadAutopilotConfig() * itself stays a pure read with no throw and no side effect (several call * sites read it unguarded, e.g. the B26 tool_call safety net — making it * throw would put that unrelated guard at risk over a bad model string). * This is the separate, explicit surface a caller consults when it wants to * REPORT what was rejected (currently: `/autopilot status`). Never throws. * * AUTOPILOT-111 extends the surface to the level map's SOURCE: a malformed * or unreadable actuator.config.json, a malformed level entry in it, and * every local `models` key the actuator shadows (the actuator wins, so the * local entry no longer takes effect), are named here rather than silently * ignored. */ export function configWarnings(stateDir: string): string[] { const warnings: string[] = []; // LEVEL MAP SOURCE (KEY: AUTOPILOT-111): the actuator owns the map; this // repo's own `models` is only the fallback. Read independently of the local // file so a broken ACTUATOR config is named even when autopilot.config.json // is absent. readActuatorModels never throws. const actuator = readActuatorModels(stateDir); if (actuator.readError) { warnings.push(`actuator.config.json could not be read (${actuator.readError}) — the level map falls back to this file's models`); } else if (actuator.malformedModels !== undefined) { warnings.push(`actuator.config.json models: must be an object mapping level names to models, got ${JSON.stringify(actuator.malformedModels)} — ignored (no actuator levels)`); } else if (actuator.invalidEntries?.length) { for (const e of actuator.invalidEntries) { warnings.push(`actuator.config.json models['${e.key}']: ${e.reason} — level ignored (a local models['${e.key}'] applies, else a tier naming it is refused)`); } } try { const p = autopilotConfigPath(stateDir); if (!existsSync(p)) return warnings; const file = JSON.parse(readFileSync(p, "utf8")) as AutopilotConfigFile; if (file.model !== undefined) { const m = normalizeModel(file.model); if (!m.ok) warnings.push(`model: must be 'provider/id[:thinking]', got ${JSON.stringify(file.model)} — ignored, every spawn inherits (${m.reason})`); } // LEVEL MAP (AUTOPILOT-110): a non-object value is dropped at load; an // unknown/malformed LEVEL fails loudly at resolution, so only the shape // that would otherwise vanish silently is warned about here. if (file.models !== undefined && (!file.models || typeof file.models !== "object" || Array.isArray(file.models))) { warnings.push(`models: must be an object mapping level names to models, got ${JSON.stringify(file.models)} — ignored (no levels configured)`); } else if (file.models && typeof file.models === "object" && (file.models as ModelsConfig).repos !== undefined && (typeof (file.models as ModelsConfig).repos !== "object" || Array.isArray((file.models as ModelsConfig).repos))) { warnings.push(`models.repos: must be an object of per-repo level overrides, got ${JSON.stringify((file.models as ModelsConfig).repos)} — ignored (no per-repo overrides)`); } // SHADOWED LOCAL LEVELS (KEY: AUTOPILOT-111): a key the actuator ALSO // defines is won by the actuator, so the local entry no longer takes // effect — named here, never silently ignored. const actuatorModels = actuator.models; if (actuatorModels && file.models && typeof file.models === "object" && !Array.isArray(file.models)) { const local = file.models as ModelsConfig; const shadowed = Object.keys(local).filter((k) => k in actuatorModels); if (shadowed.length) { warnings.push(`models: ${shadowed.map((k) => `'${k}'`).join(", ")} also set in actuator.config.json — the actuator owns the level map and its values win; the local entr${shadowed.length === 1 ? "y is" : "ies are"} shadowed`); } // `repos` is replaced as a WHOLE (per-top-level-key precedence), so name // the local per-repo overrides that stop taking effect — naming only the // `repos` key would hide WHICH repo's levels were lost. if (shadowed.includes("repos")) { const localRepos = local.repos; if (localRepos && typeof localRepos === "object" && !Array.isArray(localRepos)) { const keys = Object.keys(localRepos); if (keys.length) { warnings.push(`models.repos: local per-repo override${keys.length === 1 ? "" : "s"} ${keys.map((k) => `'${k}'`).join(", ")} no longer take effect — the actuator defines \`repos\`, which replaces the whole local map`); } } } } } catch { // best-effort — a warning probe must never throw } return warnings; } export function saveAutopilotConfig(stateDir: string, cfg: AutopilotConfigFile): void { writeAtomic(autopilotConfigPath(stateDir), JSON.stringify(cfg, null, 2) + "\n"); }