// Run-owned agent scratch provisioning and hidden model guidance (contracts.md §8.1). // // This module is guidance, not enforcement: it registers no tool and changes no process-global // temp environment. Eligible write-capable model turns receive the repository-relative current-run // path after the confined directory has been established. The context filter removes inherited or // stale direct scratch custom blocks. A compaction summary may quote old prose/path text; that is // not a live guidance delivery or authoritative provenance, and is deliberately left intact. // // Delivery dedup reads Pi's OWN live context projection (`pi/v1/contextEvidence.ts`) and requires // EXACT identity: a `custom` message of this customType whose string content equals the current // run's rendered block byte-for-byte. Nothing looser counts — not a text-part array, a user quote, // a marker-only match, changed bytes, a parent run's block, or plain `custom` state (`data.content` // is state, never model delivery). A projection read failure escapes the hook to Pi's hook-error // reporting; no guessed copy is injected. import { relative, sep } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { activeContextMessages, type ContextMessage } from "../pi/v1/contextEvidence.ts"; import { type ReportTarget, report } from "../surfaces/report.ts"; import { agentScratchDir, ensureAgentScratch } from "./cache.ts"; import { activeSessionRunId, type SessionDataCtx } from "./sessionData.ts"; export const AGENT_SCRATCH_CONTEXT_TYPE = "perk:agent-scratch"; export interface AgentScratchBlock { runId: string; /** Repository-relative POSIX-style path carried in model context. */ path: string; marker: string; content: string; } export type AgentScratchContext = SessionDataCtx & ReportTarget; /** Render the exact run-aware hidden block; provisioning stays in the resolver below. */ export function renderAgentScratchBlock(cwd: string, runId: string): AgentScratchBlock { const path = relative(cwd, agentScratchDir(cwd, runId)).split(sep).join("/"); const marker = `[PERK AGENT SCRATCH run=${runId} path=${path}]`; const content = [ marker, `Put disposable command/model intermediate files for this run in \`${path}/\` instead of shared \`/tmp\`.`, "Use descriptive, non-colliding names. These files are non-authoritative: re-read canonical repository or backend sources before making durable decisions.", ].join("\n"); return { runId, path, marker, content }; } export interface AgentScratchProvisioner { resolve(ctx: AgentScratchContext): AgentScratchBlock | null; } /** * Build one extension-activation-scoped resolver. Failures warn once per run but are retried on * every call; one success clears suppression so a later regression is reported again. */ export function createAgentScratchProvisioner( deps: { ensure?: typeof ensureAgentScratch; warn?: (ctx: AgentScratchContext, runId: string, error: unknown) => void; } = {}, ): AgentScratchProvisioner { const ensure = deps.ensure ?? ensureAgentScratch; const warn = deps.warn ?? ((ctx: AgentScratchContext, runId: string, error: unknown) => { report( ctx, "agent scratch", "warning", `could not provision scratch for run ${runId}: ${String(error)}`, { alsoLog: true }, ); }); const suppressedRuns = new Set(); return { resolve(ctx): AgentScratchBlock | null { const runId = activeSessionRunId(ctx); if (runId === null) return null; try { ensure(ctx.cwd, runId); } catch (error) { if (!suppressedRuns.has(runId)) { suppressedRuns.add(runId); warn(ctx, runId, error); } return null; } suppressedRuns.delete(runId); return renderAgentScratchBlock(ctx.cwd, runId); }, }; } /** Whether this exact current-run block is still directly delivered in Pi's live projection. */ function contextHasBlock(messages: readonly ContextMessage[], block: AgentScratchBlock): boolean { return messages.some( (message) => message.role === "custom" && message.customType === AGENT_SCRATCH_CONTEXT_TYPE && message.content === block.content, ); } /** * Register eligible-turn delivery and direct scratch-custom context hygiene. `eligible` is the * composition root's `!gate && !runner` — a runner child (every perk report child) never * provisions scratch; the module knows nothing about agent names. */ export function registerAgentScratch( pi: ExtensionAPI, provisioner: AgentScratchProvisioner, eligible: () => boolean, ): void { pi.on("before_agent_start", async (_event, ctx) => { if (!eligible()) return; // Provision before dedup: an externally deleted directory is repaired even while live // context still carries this run's exact guidance block (and before any projection read). const block = provisioner.resolve(ctx); if (block === null) return; if (contextHasBlock(activeContextMessages(ctx), block)) return; return { message: { customType: AGENT_SCRATCH_CONTEXT_TYPE, content: block.content, display: false, }, }; }); pi.on("context", async (event, ctx) => { const block = eligible() ? provisioner.resolve(ctx) : null; let keptCurrent = false; return { messages: event.messages.filter((message) => { const candidate = message as { customType?: string; content?: unknown }; if (candidate.customType !== AGENT_SCRATCH_CONTEXT_TYPE) return true; if (block === null || candidate.content !== block.content || keptCurrent) return false; keptCurrent = true; return true; }), }; }); }