import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ArtifactDescriptor, TeamRunManifest } from "../../state/types.ts"; import { findRepoRoot } from "../../utils/paths.ts"; import { extractSessionId } from "../../utils/session-utils.ts"; import { listRecentRuns } from "../run-index.ts"; export interface RegisterCompactionGuardOptions { foregroundControllers: Map; foregroundTeamRunControllers: Map; } const TRIGGER_RATIO = 0.75; const HARD_RATIO = 0.95; const DEFAULT_CONTEXT_WINDOW = 200_000; const MAX_ARTIFACT_INDEX_RUNS = 10; const MAX_ARTIFACT_INDEX_ITEMS = 80; /** Run statuses that mean the run is still in-flight and may need resuming. */ const IN_FLIGHT_RUN_STATUSES = new Set(["queued", "planning", "running"]); function contextWindow(ctx: { model?: { contextWindow?: number } }): number { const value = ctx.model?.contextWindow; return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : DEFAULT_CONTEXT_WINDOW; } function usageRatio(ctx: { getContextUsage(): { tokens: number | null } | undefined; model?: { contextWindow?: number }; }): number | undefined { const tokens = ctx.getContextUsage()?.tokens; if (tokens === null || tokens === undefined || !Number.isFinite(tokens)) return undefined; return tokens / contextWindow(ctx); } interface CrewArtifactIndexEntry { runId: string; status: TeamRunManifest["status"]; team: string; workflow?: string; goal: string; artifact: Pick; } function collectCrewArtifactIndex(cwd: string): CrewArtifactIndexEntry[] { const entries: CrewArtifactIndexEntry[] = []; for (const run of listRecentRuns(cwd, MAX_ARTIFACT_INDEX_RUNS)) { for (const artifact of run.artifacts) { entries.push({ runId: run.runId, status: run.status, team: run.team, workflow: run.workflow, goal: run.goal, artifact: { kind: artifact.kind, path: artifact.path, producer: artifact.producer, sizeBytes: artifact.sizeBytes, createdAt: artifact.createdAt, }, }); if (entries.length >= MAX_ARTIFACT_INDEX_ITEMS) return entries; } } return entries; } function formatCrewArtifactIndex(entries: CrewArtifactIndexEntry[]): string { if (!entries.length) return ""; const lines = ["", "# pi-crew artifact index", "Preserve these run artifact references in the compaction summary:"]; for (const entry of entries) { lines.push( `- ${entry.artifact.kind}: ${entry.artifact.path} (run=${entry.runId}, status=${entry.status}, team=${entry.team}, workflow=${entry.workflow ?? "none"}, producer=${entry.artifact.producer})`, ); } return lines.join("\n"); } /** * Project-scope filter: keep `run` only if it belongs to the SAME repo as * `queryCwd` (or is a user-level / legacy run with no repo). This is the * version-independent, reliable barrier against cross-project leaks: even * when the session-id filter below cannot fire (ctx.sessionId is absent on * some pi versions — observed on pi 0.79.6 ExtensionContext), the cwd filter * stops another project's in-flight runs (e.g. edge-ai-agent) from bleeding * into this project's ambient status or compaction-resume directive. * * Note: listRecentRuns already scopes its filesystem scan via scopedRunRoots, * BUT it ALSO merges the GLOBAL activeRunEntries() registry (the cross- * project dashboard view). That global merge is intentional for the * dashboard but wrong for "what should THIS project's session do" — hence * this filter at the consumption site. */ function isInProjectScope(run: TeamRunManifest, queryCwd: string): boolean { const queryRepo = findRepoRoot(queryCwd); if (queryRepo === undefined) return true; // viewer not in a repo → user-level view const runRepo = typeof run.cwd === "string" && run.cwd.length > 0 ? findRepoRoot(run.cwd) : undefined; if (runRepo === undefined) return true; // run is user-level / legacy / not a repo → include return runRepo === queryRepo; // same project only } /** * Collect in-flight (non-terminal) crew runs that must be resumable after * compaction. These are runs the agent was actively working on or awaiting. * * @param cwd - project working directory (shared, per-project state root). * @param currentSessionId - if provided, restrict to runs OWNED BY THIS * session (`run.ownerSessionId === currentSessionId`). The state store is * per-PROJECT, not per-SESSION — multiple sessions share `.crew/state/runs/`. * Without this filter, Session B's compaction would pick up Session A's * in-flight runs and wrongly resume them. Legacy runs with no * `ownerSessionId` are excluded under filtering (strict): a run with no * declared owner must not be auto-resumed by an arbitrary session; true * orphans are handled separately by crash-recovery. When omitted, returns * ALL in-flight runs (back-compat for callers that deliberately want the * cross-session view, e.g. diagnostics). */ export function collectInFlightRuns(cwd: string, currentSessionId?: string): TeamRunManifest[] { return listRecentRuns(cwd, MAX_ARTIFACT_INDEX_RUNS).filter((run) => { if (!IN_FLIGHT_RUN_STATUSES.has(run.status)) return false; // Reliable barrier (2026-06-17): never leak another project's runs into // THIS project's resume directive / ambient status, regardless of // whether the session-id filter is available. This fixes the live // cross-session leak that persisted after 4bd6f5b because ctx.sessionId // is absent on pi 0.79.6. if (!isInProjectScope(run, cwd)) return false; if (currentSessionId === undefined) return true; // no session filter → back-compat (still project-scoped) return run.ownerSessionId === currentSessionId; // strict: legacy ownerless runs excluded }); } /** * Build an explicit resume directive that survives compaction. This is the * core of O10 (compaction resilience): after compaction, the agent MUST know * what crew tasks were in-flight and how to continue them. */ function formatResumeDirective(runs: TeamRunManifest[]): string { if (!runs.length) return ""; const lines = [ "", "# pi-crew in-flight task resume directive (CRITICAL — do not drop)", "The following pi-crew runs were in progress when the context was compacted.", "After compaction, you MUST continue these tasks — do NOT consider them finished.", ]; for (const run of runs) { const wf = run.workflow ? `, workflow=${run.workflow}` : ""; lines.push(`- runId=${run.runId} (status=${run.status}, team=${run.team}${wf}): ${run.goal}`); } lines.push(""); lines.push("To resume: call the `team` tool with action='status' to check progress, then"); lines.push("action='wait' (to join a still-running task) or action='summary' / action='get'"); lines.push("to retrieve results. If a task was mid-execution and the worker is still alive,"); lines.push("it continues independently — just re-attach. Do not restart completed work."); return lines.join("\n"); } /** * Build a short continuation prompt for sendUserMessage. This is what actually * makes the agent resume after compaction — Pi's threshold compaction does NOT * auto-retry (it stops and waits for user input). By injecting this as a user * message that triggers a turn, the agent automatically continues the in-flight * crew task instead of stalling until the user types "continue". */ export function buildContinuationPrompt(runs: TeamRunManifest[]): string { if (!runs.length) return ""; const lines = ["[pi-crew] Context was compacted while crew tasks were still in-flight. Continue the work — do not wait for me."]; for (const run of runs) { const wf = run.workflow ? `, workflow=${run.workflow}` : ""; lines.push(`- runId=${run.runId} (status=${run.status}, team=${run.team}${wf}): ${run.goal}`); } lines.push(""); lines.push( "Resume: call `team` with action='status' to check progress, then action='wait' (join a running task), action='summary', or action='get' as appropriate. If a worker is still alive it continues independently — just re-attach. Do NOT restart completed work.", ); return lines.join("\n"); } /** * Trigger automatic agent continuation after compaction. Fire-and-forget the * promise — never block the compaction flow. The sendUserMessage type is * declared `void` but the runtime returns a Promise (it triggers an agent turn). * * During compaction the agent may still be mid-processing, so Pi can reject * the queued message with "Agent is already processing a prompt...". This is * BENIGN — the in-flight worker continues independently regardless — so we * detect that specific race and downgrade it to a silent debug log instead of * surfacing a scary warning to the user. Other errors still notify. */ export function triggerContinuation(pi: ExtensionAPI, ctx: ExtensionContext, runs: TeamRunManifest[]): void { if (!runs.length) return; const prompt = buildContinuationPrompt(runs); const isBenignProcessingRace = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err ?? ""); return /already processing a prompt/i.test(msg) || /use steer\(\) or followUp\(\)/i.test(msg); }; try { const result = pi.sendUserMessage(prompt) as unknown; Promise.resolve(result).catch((err: unknown) => { // Benign race: the worker keeps running independently — no need to alarm. if (isBenignProcessingRace(err)) return; // Real failure: surface a hint so the user can resume manually. try { ctx.ui.notify("pi-crew: auto-continuation after compaction failed — use team status to resume manually.", "warning"); } catch { // swallow } }); } catch (err: unknown) { // Synchronous throw — same benign-race handling. if (isBenignProcessingRace(err)) return; // best-effort } } /** Combined customInstructions injected into proactive compaction summaries. */ function buildCompactionInstructions(cwd: string, currentSessionId?: string): string { const artifactIndex = collectCrewArtifactIndex(cwd); const inFlight = collectInFlightRuns(cwd, currentSessionId); const parts = [ "Prioritize keeping pi-crew run state, task results, artifact references, run IDs, and next actions. Keep completed-task detail concise.", ]; if (artifactIndex.length > 0) parts.push(formatCrewArtifactIndex(artifactIndex)); if (inFlight.length > 0) parts.push(formatResumeDirective(inFlight)); return parts.join("\n"); } export function registerCompactionGuard(pi: ExtensionAPI, options: RegisterCompactionGuardOptions): void { let pendingCompactReason: string | null = null; let compactionInProgress = false; const startCompact = (ctx: ExtensionContext, reason: string): void => { if (compactionInProgress) return; compactionInProgress = true; const sessionId = extractSessionId(ctx); const customInstructions = buildCompactionInstructions(ctx.cwd, sessionId); // Append a durable resume entry so it appears in the post-compaction // context regardless of how summarization treats customInstructions. const inFlight = collectInFlightRuns(ctx.cwd, sessionId); if (inFlight.length > 0) { pi.appendEntry("crew:resume-directive", { reason, createdAt: new Date().toISOString(), runs: inFlight.map((r) => ({ runId: r.runId, status: r.status, team: r.team, workflow: r.workflow, goal: r.goal, })), }); } ctx.compact({ customInstructions, onComplete: () => { compactionInProgress = false; // O10 FIX: Pi's threshold compaction does NOT auto-retry — it // stops and waits for user input. Trigger automatic // continuation so the agent resumes the in-flight crew task. const runs = collectInFlightRuns(ctx.cwd, extractSessionId(ctx)); triggerContinuation(pi, ctx, runs); ctx.ui.notify(reason === "deferred" ? "Deferred compaction completed" : "Auto-compacted context during team run", "info"); }, onError: (error) => { compactionInProgress = false; ctx.ui.notify(`${reason === "deferred" ? "Deferred" : "Auto"} compaction failed: ${error.message}`, "error"); }, }); }; // Allow compaction to proceed. pi-crew state is preserved via the // customInstructions + resume-directive entry appended in startCompact, // and re-injected post-compaction by the session_compact handler below. pi.on("session_before_compact", async (_event, _ctx) => { return; }); // O10: After ANY compaction (proactive OR reactive/Pi-triggered), detect // in-flight crew runs and trigger automatic continuation. This is the // critical fix: Pi's threshold compaction does NOT auto-retry — it stops // and waits for user input. By injecting a continuation user message that // triggers a turn, the agent automatically resumes the in-flight crew task. // This covers the common case where Pi auto-compacts without going through // our proactive startCompact path. pi.on("session_compact", (_event, ctx) => { try { const sessionId = extractSessionId(ctx); const inFlight = collectInFlightRuns(ctx.cwd, sessionId); if (inFlight.length === 0) return; // Re-append the resume directive entry for durable record. pi.appendEntry("crew:resume-directive", { reason: "post-compaction-continuation", createdAt: new Date().toISOString(), runs: inFlight.map((r) => ({ runId: r.runId, status: r.status, team: r.team, workflow: r.workflow, goal: r.goal, })), }); ctx.ui.notify(`Context compacted. ${inFlight.length} pi-crew run(s) still in-flight — auto-resuming.`, "info"); // THE FIX: trigger automatic continuation. Without this, Pi stops // after threshold compaction and the user must type "continue". triggerContinuation(pi, ctx, inFlight); } catch { // best-effort: never block compaction completion } }); // Proactive compaction with dynamic threshold. pi.on("turn_end", (_event, ctx) => { if (compactionInProgress) return; const hasActiveForeground = options.foregroundControllers.size > 0 || options.foregroundTeamRunControllers.size > 0; const ratio = usageRatio(ctx); // If deferred compaction is pending and foreground just ended, check if still needed if (!hasActiveForeground && pendingCompactReason) { pendingCompactReason = null; if (ratio === undefined || ratio < TRIGGER_RATIO) return; startCompact(ctx, "deferred"); return; } if (ratio === undefined || ratio < TRIGGER_RATIO) return; // During foreground run: defer unless context is critically full if (hasActiveForeground) { if (ratio >= HARD_RATIO) { startCompact(ctx, "critical"); } else { pendingCompactReason = "threshold-during-foreground-run"; } return; } startCompact(ctx, "threshold"); }); }