import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import type { WorkflowRun } from "./domain.ts"; import { type CompleteNormalCompactionCommand, type ConsumeContextHandoffCommand, type ContinueContextCommand, type DecideWorkflowCommand, type WorkflowCommand, type WorkflowEngine, type WorkflowView, } from "./engine.ts"; import { readVerifiedPlanArtifact, type WorkflowStore, } from "./store.ts"; const MAX_HANDOFF_BYTES = 32 * 1024; type NewSessionOptions = NonNullable< Parameters[0] >; type ReplacementContext = Parameters< NonNullable >[0]; export interface ContextTransitionRuntime { store: WorkflowStore; engineForContext(ctx: ExtensionContext): Promise | WorkflowEngine; } function sessionId(ctx: ExtensionContext) { const id = ctx.sessionManager.getSessionId(); if (!id) throw new Error("Workflow context transition requires a persisted session ID"); return id; } function sessionFile(ctx: ExtensionContext) { const file = ctx.sessionManager.getSessionFile(); if (!file) throw new Error("Workflow context transition requires a persisted session file"); return file; } function contextCommand(view: WorkflowView) { const context = view.implementationContext; if (!context) throw new Error("Workflow Run has no Implementation Context intent"); return { runId: view.runId, gateId: context.gateId, preparationId: context.preparationId, planRevision: context.planRevision, contextRevision: context.runRevision, expectedRevision: view.revision, }; } async function boundedPlan(view: WorkflowView) { if (!view.planArtifact) throw new Error("Workflow Run has no persisted plan"); const plan = await readVerifiedPlanArtifact(view.planArtifact); let bytes = 0; const characters: string[] = []; for (const character of plan) { const width = Buffer.byteLength(character, "utf8"); if (bytes + width > MAX_HANDOFF_BYTES) break; characters.push(character); bytes += width; } return characters.join(""); } function handoffMessage(view: WorkflowView, plan: string) { return [ `Continue pi-workflow run ${view.runId}.`, `Worktree: ${view.workspace.root ?? "missing"}`, `Plan revision: ${view.planArtifact?.revision ?? "missing"}`, "The workflow state remains authoritative. Inspect it before taking action.", "", plan, ].join("\n"); } async function inspect( runtime: ContextTransitionRuntime, ctx: ExtensionContext, runId: string, ) { const engine = await runtime.engineForContext(ctx); return { engine, view: await engine.execute({ action: "inspect", runId }), }; } async function completeNormalCompaction( runtime: ContextTransitionRuntime, ctx: ExtensionContext, view: WorkflowView, outcome: CompleteNormalCompactionCommand["outcome"], error?: string, ) { const engine = await runtime.engineForContext(ctx); const command: CompleteNormalCompactionCommand = { action: "complete-normal-compaction", ...contextCommand(view), outcome, ...(error ? { error } : {}), }; await engine.execute(command); } async function consumeHandoff( runtime: ContextTransitionRuntime, replacementCtx: ReplacementContext, runId: string, sourceSessionFile: string, ) { const current = await inspect(runtime, replacementCtx, runId); const targetSessionFile = sessionFile(replacementCtx); const plan = await boundedPlan(current.view); await replacementCtx.sendMessage({ customType: "pi-workflow-handoff", content: handoffMessage(current.view, plan), display: true, }); const command: ConsumeContextHandoffCommand = { action: "consume-context-handoff", ...contextCommand(current.view), targetSessionFile, previousSessionFile: sourceSessionFile, }; await current.engine.execute(command); replacementCtx.ui.notify(`Workflow ${runId} context handoff is ready`, "info"); } async function continueFreshSession( runtime: ContextTransitionRuntime, ctx: ExtensionCommandContext, view: WorkflowView, ) { const currentSessionId = sessionId(ctx); const sourceSessionFile = sessionFile(ctx); if (view.implementationContext?.activeParentSessionId !== currentSessionId) { throw new Error("Only the active parent session may continue this Workflow Run"); } const engine = await runtime.engineForContext(ctx); const command: ContinueContextCommand = { action: "continue-context", ...contextCommand(view), sourceSessionFile, }; const pending = await engine.execute(command); const result = await ctx.newSession({ parentSession: sourceSessionFile, withSession: async (replacementCtx) => { await consumeHandoff(runtime, replacementCtx, view.runId, sourceSessionFile); }, }); if (result.cancelled) { const pendingContext = pending.implementationContext; if (pendingContext) { await engine.execute({ action: "recover-context-intent", runId: pending.runId, gateId: pendingContext.gateId, preparationId: pendingContext.preparationId, planRevision: pendingContext.planRevision, contextRevision: pendingContext.runRevision, expectedRevision: pending.revision, reason: "cancelled", }); } ctx.ui.notify("Workflow context handoff was cancelled", "warning"); } } async function continueNormalCompaction( runtime: ContextTransitionRuntime, ctx: ExtensionCommandContext, view: WorkflowView, ) { const plan = await boundedPlan(view); const engine = await runtime.engineForContext(ctx); const command: ContinueContextCommand = { action: "continue-context", ...contextCommand(view), }; const compacting = await engine.execute(command); const instructions = [ `Prepare context for pi-workflow run ${view.runId}.`, `Preserve the worktree path, accepted Decisions, and plan revision ${view.planArtifact?.revision}.`, "Use this approved plan as the implementation focus:", plan, ].join("\n\n"); try { ctx.compact({ customInstructions: instructions, onComplete: () => { void completeNormalCompaction(runtime, ctx, compacting, "succeeded").catch( (error: unknown) => { ctx.ui.notify( `Workflow compaction callback failed: ${error instanceof Error ? error.message : String(error)}`, "error", ); }, ); }, onError: (error) => { void completeNormalCompaction( runtime, ctx, compacting, "failed", error.message || "Normal compaction failed", ).catch((callbackError: unknown) => { ctx.ui.notify( `Workflow compaction callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, "error", ); }); }, }); } catch (error) { await completeNormalCompaction( runtime, ctx, compacting, "failed", error instanceof Error ? error.message : String(error), ); throw error; } } async function handleContinue( runtime: ContextTransitionRuntime, args: string, ctx: ExtensionCommandContext, ) { const match = args.trim().match(/^continue\s+(\S+)$/); if (!match) { ctx.ui.notify("Usage: /workflow continue ", "error"); return; } await ctx.waitForIdle(); const runId = match[1]; if (!runId) throw new Error("Workflow Run ID is missing"); const { view } = await inspect(runtime, ctx, runId); const status = view.implementationContext?.status; if (status === "handoff-pending") { await continueFreshSession(runtime, ctx, view); return; } if (status === "normal-compact-pending") { await continueNormalCompaction(runtime, ctx, view); return; } throw new Error(`Workflow Run ${runId} has no pending context continuation`); } export function executeHostDecision( pi: Pick, engine: WorkflowEngine, command: DecideWorkflowCommand, ) { return engine.execute({ ...command, ...(command.decision.kind === "report-context-preparation" ? { activeToolNames: pi.getActiveTools() } : {}), }); } export function registerWorkflowContextTransitions( pi: ExtensionAPI, runtime: ContextTransitionRuntime, openDashboard?: ( args: string, ctx: ExtensionCommandContext, ) => Promise<{ continueRunId?: string }>, ) { pi.registerCommand("workflow", { description: "Inspect and continue persisted pi-workflow runs", handler: async (args, ctx) => { try { if (/^continue\s+\S+$/.test(args.trim())) { await handleContinue(runtime, args, ctx); return; } if (openDashboard) { const result = await openDashboard(args.trim(), ctx); if (result.continueRunId) { await handleContinue( runtime, `continue ${result.continueRunId}`, ctx, ); } return; } await handleContinue(runtime, args, ctx); } catch (error) { ctx.ui.notify( error instanceof Error ? error.message : String(error), "error", ); } }, }); pi.on("session_start", async (event, ctx) => { const currentSessionFile = ctx.sessionManager.getSessionFile(); if (!currentSessionFile) return; for (const run of await runtime.store.listRuns()) { const context = run.implementationContext; if (run.status !== "waiting" || context?.status !== "claimed-target-awaiting-withSession") { continue; } const immediateTarget = event.reason === "new" && event.previousSessionFile === context.sourceSessionFile && currentSessionFile === context.targetSessionFile && (context.intentExpiresAt === undefined || context.intentExpiresAt >= Date.now()); if (immediateTarget) continue; const engine = await runtime.engineForContext(ctx); try { await engine.execute({ action: "recover-context-intent", runId: run.runId, gateId: context.gateId, preparationId: context.preparationId, planRevision: context.planRevision, contextRevision: context.runRevision, expectedRevision: run.revision, reason: context.intentExpiresAt !== undefined && context.intentExpiresAt < Date.now() ? "expired" : event.reason === "reload" || event.reason === "resume" || event.reason === "fork" ? event.reason : "startup", }); } catch (error) { if ( !(error instanceof Error) || !/owned by live process/.test(error.message) ) { throw error; } } } }); pi.on("session_shutdown", async (event, ctx) => { if (event.reason !== "new" || !event.targetSessionFile) return; const sourceSessionFile = ctx.sessionManager.getSessionFile(); const sourceSessionId = ctx.sessionManager.getSessionId(); if (!sourceSessionFile || !sourceSessionId) return; for (const run of await runtime.store.listRuns()) { const context = run.implementationContext; if ( run.status !== "waiting" || context?.status !== "handoff-pending" || context.sourceParentSessionId !== sourceSessionId || context.sourceSessionFile !== sourceSessionFile ) { continue; } const engine = await runtime.engineForContext(ctx); await engine.execute({ action: "claim-context-handoff", runId: run.runId, gateId: context.gateId, preparationId: context.preparationId, planRevision: context.planRevision, contextRevision: context.runRevision, expectedRevision: run.revision, sourceSessionFile, targetSessionFile: event.targetSessionFile, reason: "new", }); } }); } export type ContextTransitionCommand = Extract< WorkflowCommand, | { action: "continue-context" } | { action: "claim-context-handoff" } | { action: "consume-context-handoff" } | { action: "complete-normal-compaction" } | { action: "recover-context-intent" } >; export function contextTransitionState(run: WorkflowRun) { return run.implementationContext; }