/** OFF/CHAT/PLAN/EXECUTE unified state machine with session persistence via appendEntry blob. */ import { ENTRY_TYPE, getModeDefinition } from "./config.js"; import type { AgentMode, AgentModeBlob, ModeAppearanceConfig, ModeModelConfig } from "./types.js"; /** Mutable state — shared within the extension module. */ const state: { mode: AgentMode; activePlanFile: string | null; sessionCwd: string; restoringModel: ModeModelConfig | null; refining: boolean; refineCount: number; } = { mode: "off", activePlanFile: null, // Until the first `session_start` hands us the real one, the directory the harness was started // in is the only candidate. Plans and the footer path must never be resolved from // `process.cwd()` at the call site: a session opened elsewhere would write its plans there. sessionCwd: process.cwd(), restoringModel: null, refining: false, refineCount: 0, }; // Publish unified global and legacy globals at module load. (globalThis as Record).__agentMode = { mode: "off" }; (globalThis as Record).__planMode = { mode: "off" }; (globalThis as Record).__chatMode = { mode: "off" }; /** * Cross-extension seam. `opl-footer` publishes `__footerRequestRender` while a footer is * mounted; `__agentMode`, `__planMode` and `__chatMode` are read by the footer's mode * segment and by `opl-input`. Extensions install one directory at a time and load in any * combination, so nothing here may assume the other side exists or is healthy: the value can * be missing, be something else entirely, or close over a TUI that was already torn down. */ function requestFooterRender(): void { const requestRender = (globalThis as Record).__footerRequestRender; if (typeof requestRender !== "function") return; try { (requestRender as () => void)(); } catch { // A dead footer must not fail a mode transition. } } /** Derive and sync all globalThis snapshots from unified state. */ function syncGlobalThis(): void { const m = state.mode; // Unified global for the mode-switcher footer segment (globalThis as Record).__agentMode = { mode: m, appearance: getModeDefinition(m)?.appearance, } satisfies { mode: AgentMode; appearance?: ModeAppearanceConfig }; // Legacy globals for backward compat with chat-input and footer segments. Only the // plan-family modes light up __planMode, so a custom mode cannot read as "Plan mode ON". (globalThis as Record).__planMode = { mode: m === "plan" || m === "execute" ? m : "off" }; (globalThis as Record).__chatMode = { mode: m === "chat" ? "chat" : "off" }; requestFooterRender(); } /** Re-publish the seam after a fresh session starts in this process. */ export function publishModeGlobals(): void { syncGlobalThis(); } /** * Drop the seam when the session ends. A leftover value would let a footer in a later * session render a mode that belongs to nobody, and the footer's own render trigger is * removed with it (see `opl-footer`). */ export function clearModeGlobals(): void { delete (globalThis as Record).__agentMode; delete (globalThis as Record).__planMode; delete (globalThis as Record).__chatMode; } export function getMode(): AgentMode { return state.mode; } export function getActivePlanFile(): string | null { return state.activePlanFile; } export function getRefining(): boolean { return state.refining; } export function setRefining(value: boolean): void { state.refining = value; syncGlobalThis(); } export function getRefineCount(): number { return state.refineCount; } export function incrementRefineCount(): void { state.refineCount++; } /** Model reference to restore when the active mode ends (null = nothing to restore). */ export function getRestoringModel(): ModeModelConfig | null { return state.restoringModel; } export function setRestoringModel( model: ModeModelConfig | null, pi: { appendEntry: (type: string, data?: unknown) => void }, ): void { state.restoringModel = model; persist(pi); } export function setActivePlanFile( file: string | null, pi: { appendEntry: (type: string, data?: unknown) => void }, ): void { state.activePlanFile = file; syncGlobalThis(); persist(pi); } export function enterPlanWithFile( file: string | null, pi: { appendEntry: (type: string, data?: unknown) => void }, ): void { state.mode = "plan"; state.activePlanFile = file; state.refining = false; syncGlobalThis(); persist(pi); } export function transition( newMode: AgentMode, pi: { appendEntry: (type: string, data?: unknown) => void }, ): void { if (newMode === "plan") state.activePlanFile = null; state.mode = newMode; state.refining = false; state.refineCount = 0; syncGlobalThis(); persist(pi); } function persist(pi: { appendEntry: (type: string, data?: unknown) => void }): void { pi.appendEntry(ENTRY_TYPE, { mode: state.mode, activePlanFile: state.activePlanFile, restoreModel: state.restoringModel, } satisfies AgentModeBlob); } /** Reject activePlanFile values that could escape PLAN_DIR via path components or traversal. */ function normalizePlanFile(file: string | null | undefined): string | null { if (!file) return null; if (file.includes("/") || file.includes("\\") || file.includes("..")) return null; return file; } /** Keep only a well-formed `{ provider, id }` pair; anything else means "nothing to restore". */ function normalizeModelRef(model: unknown): ModeModelConfig | null { if (typeof model !== "object" || model === null) return null; const { provider, id } = model as Partial; return typeof provider === "string" && provider && typeof id === "string" && id ? { provider, id } : null; } /** * Restore state from the most recent relevant session entry on the current branch. * Checks for native mode-switcher entries first; falls back to legacy plan-mode/chat-mode entries * so sessions saved by the old extensions are restored correctly. */ export function restore( entries: Array<{ type: string; customType?: string; data?: unknown }>, ): boolean { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (!entry) continue; // a hole in the persisted array is not a restore candidate if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue; const data = entry.data as AgentModeBlob | undefined; if (!data?.mode) continue; // Malformed/incomplete blob: keep looking back. // The newest well-formed entry decides. A mode the registry does not know (a removed // custom mode, a foreign branch) restores nothing at all: falling through to an older // entry could resume a chat/execute mode the user had already left. if (!getModeDefinition(data.mode)) return false; state.mode = data.mode; state.activePlanFile = normalizePlanFile(data.activePlanFile); state.restoringModel = normalizeModelRef(data.restoreModel); state.refining = false; syncGlobalThis(); return true; } // Backward compat: restore from legacy entries written by the old extensions. for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (!entry) continue; // a hole in the persisted array is not a restore candidate if (entry.type !== "custom") continue; if (entry.customType === "plan-mode") { const data = entry.data as { mode?: string; activePlanFile?: string | null } | undefined; if (data?.mode === "plan" || data?.mode === "execute") { state.mode = data.mode as AgentMode; state.activePlanFile = normalizePlanFile(data.activePlanFile); state.refining = false; syncGlobalThis(); return true; } } if (entry.customType === "chat-mode") { const data = entry.data as { mode?: string } | undefined; if (data?.mode === "chat") { state.mode = "chat"; state.activePlanFile = null; state.refining = false; syncGlobalThis(); return true; } } } return false; } export function resetState(): void { state.mode = "off"; state.activePlanFile = null; state.restoringModel = null; state.refining = false; state.refineCount = 0; syncGlobalThis(); } /** The directory the current session lives in (`.pi/plans/` is resolved against this). */ export function getSessionCwd(): string { return state.sessionCwd; } export function setSessionCwd(cwd: string): void { if (cwd) state.sessionCwd = cwd; }