feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
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<ExtensionCommandContext["newSession"]>[0]
|
||||
>;
|
||||
type ReplacementContext = Parameters<
|
||||
NonNullable<NewSessionOptions["withSession"]>
|
||||
>[0];
|
||||
|
||||
export interface ContextTransitionRuntime {
|
||||
store: WorkflowStore;
|
||||
engineForContext(ctx: ExtensionContext): Promise<WorkflowEngine> | 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 <run-id>.",
|
||||
"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 <message>.",
|
||||
"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 <run-id>.",
|
||||
"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<ExtensionAPI, "getActiveTools">,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user