import { randomUUID } from "node:crypto"; 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 { isValidContextContinuation } from "./continuation.ts"; import { deriveRunLabel, deriveRunLabels } from "./run-label.ts"; import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts"; import { workflowInput, workflowSelect } from "./tui.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 workflow run ${deriveRunLabel(view)}.`, `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: "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 ${deriveRunLabel(current.view)} 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 workflow run ${deriveRunLabel(view)}.`, `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; } } function isInteractive(ctx: ExtensionCommandContext) { return ctx.hasUI && ctx.mode === "tui"; } function belongsToActiveParent(run: WorkflowRun, sessionId: string) { return run.lease?.activeParentSessionId === sessionId; } function canContinue(run: WorkflowRun, currentSessionId: string) { return isValidContextContinuation( { ...run, openGate: run.gates.find((gate) => gate.status === "open"), }, currentSessionId, ); } async function selectRun( ctx: ExtensionCommandContext, title: string, runs: WorkflowRun[], ) { const labels = deriveRunLabels(runs); return workflowSelect( ctx, title, runs.map((run, index) => ({ value: run, label: labels[index]!, description: run.status, })), ); } async function continueRun( runtime: ContextTransitionRuntime, runId: string, ctx: ExtensionCommandContext, ) { await ctx.waitForIdle(); let view: WorkflowView; try { ({ view } = await inspect(runtime, ctx, runId)); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Workflow Run ${runId} could not be loaded: ${reason}`); } 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 ${deriveRunLabel(view)} has no pending context continuation`, ); } async function handleContinue( runtime: ContextTransitionRuntime, runId: string | undefined, ctx: ExtensionCommandContext, ) { if (runId) { await continueRun(runtime, runId, ctx); return; } if (!isInteractive(ctx)) { ctx.ui.notify( "Selecting a Workflow Run to continue requires interactive UI. Use /workflow continue .", "error", ); return; } const currentSessionId = sessionId(ctx); const runs = (await runtime.store.listRuns()).filter((run) => canContinue(run, currentSessionId), ); if (runs.length === 0) { ctx.ui.notify("No Workflow Runs are ready to continue.", "info"); return; } const selected = await selectRun(ctx, "Continue Workflow Run", runs); if (selected) await continueRun(runtime, selected.runId, ctx); } async function handleStart( pi: ExtensionAPI, request: string, ctx: ExtensionCommandContext, ) { let initialRequest = request.trim(); if (!initialRequest) { if (!isInteractive(ctx)) { ctx.ui.notify( "A workflow request is required outside interactive UI. Use /workflow start .", "error", ); return; } initialRequest = (await workflowInput( ctx, "Start Workflow Run", "What do you want to start working on?", ))?.trim() ?? ""; if (!initialRequest) return; } await ctx.waitForIdle(); await pi.sendUserMessage( [ "Start a managed workflow for the request below.", "Use the request text verbatim as the initial workflow request, choose a descriptive lowercase kebab-case featureName, create a unique idempotencyKey, and invoke workflow with action \"start\" through the normal assistant tool path.", "", initialRequest, ].join("\n"), ); } async function handleStop( runtime: ContextTransitionRuntime, requestedRunId: string | undefined, ctx: ExtensionCommandContext, ) { const currentSessionId = sessionId(ctx); const activeRuns = (await runtime.store.listRuns()).filter( (run) => (run.status === "running" || run.status === "waiting") && belongsToActiveParent(run, currentSessionId), ); let selected = requestedRunId ? activeRuns.find((run) => run.runId === requestedRunId) : activeRuns.length === 1 ? activeRuns[0] : undefined; if (requestedRunId && !selected) { ctx.ui.notify( `Workflow Run ${requestedRunId} is not active for this session.`, "error", ); return; } if (!requestedRunId && activeRuns.length === 0) { ctx.ui.notify("No active Workflow Runs for this session.", "info"); return; } if (!selected && !isInteractive(ctx)) { ctx.ui.notify( "Selecting a Workflow Run to stop requires interactive UI. Use /workflow stop .", "error", ); return; } selected ??= await selectRun(ctx, "Stop Workflow Run", activeRuns); if (!selected) return; await ctx.waitForIdle(); const engine = await runtime.engineForContext(ctx); const current = await engine.execute({ action: "inspect", runId: selected.runId }); await engine.execute({ action: "abort", runId: current.runId, expectedRevision: current.revision, idempotencyKey: `workflow-stop-${current.runId}-${current.revision}-${randomUUID()}`, }); ctx.ui.notify(`Workflow ${deriveRunLabel(current)} stopped.`, "info"); } 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: "Start, stop, inspect, or continue persisted workflow runs", handler: async (args, ctx) => { try { const input = args.trim(); const command = input.match(/^(start|stop|continue|resume)(?:\s+([\s\S]*))?$/); if (command?.[1] === "start") { await handleStart(pi, command[2] ?? "", ctx); return; } if (command?.[1] === "stop") { const runId = command[2]?.trim(); if (runId && /\s/.test(runId)) { ctx.ui.notify("Usage: /workflow stop [run-id]", "error"); return; } await handleStop(runtime, runId || undefined, ctx); return; } if (command?.[1] === "continue" || command?.[1] === "resume") { const runId = command[2]?.trim(); if (runId && /\s/.test(runId)) { ctx.ui.notify( `Usage: /workflow ${command[1]} [run-id]`, "error", ); return; } await handleContinue(runtime, runId || undefined, ctx); return; } if (openDashboard) { const result = await openDashboard(input, ctx); if (result.continueRunId) { await handleContinue(runtime, result.continueRunId, ctx); } return; } await handleContinue(runtime, undefined, 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; }