import type { ExtensionAPI, ExtensionContext, ExtensionFactory, SessionMessageEntry, } from "@earendil-works/pi-coding-agent"; import type { CoreMessage, NudgeDecision, CompressionBlock } from "acp-kernel"; import { renderNudgeText } from "acp-kernel"; import type { AdapterConfig } from "./config.js"; import { createRuntime, type AcpRuntime } from "./runtime.js"; import { makeCompressTool } from "./compress-tool.js"; import { makeDecompressTool } from "./decompress-tool.js"; import { makeSearchTool } from "./search-tool.js"; import { makeStatusTool } from "./status-tool.js"; import { makeDelegateTool, makeDelegateWaitTool, makeDelegateCancelTool, runningRunsSnapshot } from "./delegate-tool.js"; import { makeCommands } from "./commands.js"; import { coreOutToAgentMessages } from "./messages.js"; import { ACP_SYSTEM_PROMPT, ACP_DELEGATE_PROMPT } from "./system-prompt.js"; import { delegateStatusWidget } from "./fleet-widget.js"; import { wireToolGuardrails } from "./tool-guardrails.js"; import { debug, setDebugEnabled, logError, logInfo, logWarn, logThrow, closeLogStream } from "./log.js"; import { collectCoveredMessageIds, estimateTokens, lastUserMessageId } from "./tokens.js"; import { checkForUpdate } from "./update.js"; import { loadUserConfig, applyUserConfig } from "./user-config.js"; import { formatSystemPromptForEvent } from "./compat.js"; type AgentMessage = SessionMessageEntry["message"]; declare const CURRENT_VERSION: string; export interface AcpOwnershipRouter { /** Return true only when ACP owns context/compaction for the active turn. */ ownsContext(ctx: ExtensionContext): boolean; } const DEFAULT_OWNERSHIP_ROUTER: AcpOwnershipRouter = { ownsContext: () => true, }; export function createAcpExtension( adapter: AdapterConfig = {}, ownership: AcpOwnershipRouter = DEFAULT_OWNERSHIP_ROUTER, ): ExtensionFactory { return (pi: ExtensionAPI) => { const runtime = createRuntime(adapter); wireCompactionDisable(pi, ownership); wireSessionLifecycle(pi, runtime); wireContextTransform(pi, runtime, ownership); wireSystemPrompt(pi, runtime, ownership); wireToolGuardrails(pi, runtime); pi.registerTool(makeCompressTool(runtime)); pi.registerTool(makeDecompressTool(runtime)); pi.registerTool(makeSearchTool(runtime)); pi.registerTool(makeStatusTool(runtime)); for (const { name, options } of makeCommands(runtime)) { pi.registerCommand(name, options); } }; } export default createAcpExtension(); // AILI integration patch: context/compaction/system ownership can be gated by // a turn-frozen canonical provider route while independent delegate tools stay // registered. Upstream default behavior remains unchanged when no router is passed. // ACP owns compression; cancel Pi's built-in auto-compaction entirely (mirrors // opencode-acp requiring opencode's compaction.auto = false). function wireCompactionDisable(pi: ExtensionAPI, ownership: AcpOwnershipRouter): void { pi.on("session_before_compact", (_event, ctx) => ownership.ownsContext(ctx) ? { cancel: true } : undefined); } // (acp_delegate injection is best-effort: sendUserMessage is fire-and-forget // in pi, and interactive/rpc sessions are long-lived so their main loop // consumes the follow-up queue naturally — no shutdown drain needed.) function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void { pi.on("session_start", async (_event, ctx) => { runtime.store.invalidate(); runtime.clearNudgeTracking(); const sid = ctx.sessionManager.getSessionId(); logInfo("session", { event: "start", sid, cwd: ctx.cwd, debug: runtime.adapter.debug ?? null, version: typeof CURRENT_VERSION !== "undefined" ? CURRENT_VERSION : null }); try { const user = await loadUserConfig(ctx.cwd); runtime.setAdapter(applyUserConfig(runtime.adapter, user)); if (runtime.adapter.debug !== undefined) setDebugEnabled(runtime.adapter.debug); } catch (e) { logThrow("config", e, { sid, phase: "session_start" }); } if (runtime.adapter.delegate !== false) { pi.registerTool(makeDelegateTool(pi)); pi.registerTool(makeDelegateWaitTool(pi)); pi.registerTool(makeDelegateCancelTool(pi)); } void checkForUpdate(runtime.adapter.autoUpdate ?? true, (msg) => { if (ctx.hasUI) ctx.ui.notify(msg); }); // AILI owns persistent Worker tool ceilings. The upstream settings patcher // is intentionally not run: production must not mutate user-home settings. // Bind the TUI status widget for async delegates. The widget reads the // in-memory runs Map (via runningRunsSnapshot) and renders a live list of // running delegates below the editor. Only the interactive TUI has a UI; // rpc/json/print have hasUI=false and the call is a no-op. delegateStatusWidget.setContext(ctx, runningRunsSnapshot); }); pi.on("session_shutdown", () => { delegateStatusWidget.dispose(); closeLogStream(); }); } // The core integration: Pi's `context` event fires before every LLM call with the // messages about to be sent. We run acp-kernel's processTurn (prune + ref-tag + // nudge decision) and return the transformed AgentMessage[]. function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, ownership: AcpOwnershipRouter): void { pi.on("context", async (event, ctx) => { if (!ownership.ownsContext(ctx)) return undefined; const sid = ctx.sessionManager.getSessionId(); const release = await runtime.acquireLock(sid); try { const { state, coreMessages, entries } = await runtime.stateFor(ctx, event.messages); const config = runtime.configFor(ctx); const coveredIds = collectCoveredMessageIds(state); // Prefer pi's real token count (anchored on provider usage) over our // chars/4 estimate — it includes the system prompt, tool schemas, and // trailing messages pi has not yet received a usage for. This is what the // footer percentage reflects, so nudge usage/growth will match what the // user sees. const realUsage = ctx.getContextUsage?.(); const estimated = estimateTokens(coreMessages, coveredIds); const tokenCount = realUsage?.tokens && realUsage.tokens > 0 ? realUsage.tokens : estimated; debug.event("context-in", { sid, eventMsgs: event.messages?.length ?? 0, entries: entries.length, coreMsgs: coreMessages.length, tokenCount, estimatedTokens: estimated, realTokens: realUsage?.tokens ?? null, realPercent: realUsage?.percent ?? null, limit: config.modelContextLimit, blocksBefore: state.blocks.length, activeBefore: state.blocks.filter((b) => b.active).length, }); const turn = runtime.core.processTurn({ messages: coreMessages, state, config, tokenCount }); await runtime.save(turn.state, ctx); logInfo("turn", { sid, inMsgs: coreMessages.length, outMsgs: turn.messages.length, tokens: tokenCount, pct: realUsage?.percent ?? (config.modelContextLimit > 0 ? Math.round((tokenCount / config.modelContextLimit) * 100) : null), limit: config.modelContextLimit, nudge: turn.nudge?.shouldInject ? (turn.nudge.breakdown?.emergencyOverride === 1 ? "emergency" : "active") : "idle", nudgeReason: turn.nudge?.reason ?? null, blocks: turn.state.blocks.length, activeBlocks: turn.state.blocks.filter((b) => b.active).length, }); debug.event("processTurn", { outMsgs: turn.messages.length, summaryMsgs: turn.messages.filter((m) => m.id.startsWith("acp_summary")).length, prunedMsgs: coreMessages.length - turn.messages.length + turn.messages.filter((m) => m.id.startsWith("acp_summary")).length, nudgeShouldInject: turn.nudge?.shouldInject ?? false, nudgeReason: turn.nudge?.reason ?? null, nudgeVoice: turn.nudge ? renderNudgeText(turn.nudge).voice : null, nudgePct: turn.nudge ? Math.round(turn.nudge.contextUsage * 100) : null, nudgeTier: turn.nudge?.tier ?? null, nudgeCompressibleCount: turn.nudge?.compressibleRanges.length ?? 0, nudgeProtectedCount: turn.nudge?.protectedRanges?.length ?? 0, nothingToCompress: turn.nudge?.reason?.includes("nothing to compress") ?? false, blocksAfter: turn.state.blocks.length, activeAfter: turn.state.blocks.filter((b) => b.active).length, }); const originalById = collectOriginals(entries); const rebuilt = coreOutToAgentMessages(turn.messages, originalById); const debugOn = debug.enabled; if (turn.nudge?.shouldInject) { // Two independent channels for the nudge: // 1. CONTEXT injection (always on): the nudge is appended to the // messages returned to the LLM so the model sees it and compresses. // This is a per-turn append — the next context event rebuilds the // array from scratch, so it does NOT permanently pollute context. // 2. TERMINAL echo (debug only): when debug is on, also print the exact // text via ctx.ui.notify so the user can observe what is being // injected while debugging. The model never sees terminal output. // Emergency nudges (usage >= 80%) bypass the per-turn dedup so the // overflow warning always reaches the model. Other nudges inject at most // once per turn: pi fires the context event multiple times per assistant // reply (streaming/tool loop), and without this gate the same nudge // would be appended on every event. const emergency = turn.nudge.breakdown?.emergencyOverride === 1; const turnKey = lastUserMessageId(entries) ?? sid; const alreadyShown = !emergency && runtime.nudgeShownFor(turnKey); if (!alreadyShown) { rebuilt.push(nudgeMessage(turn.nudge, turn.state.blocks.filter((b) => b.active))); const rendered = renderNudgeText(turn.nudge); const top = [...turn.nudge.compressibleRanges].sort((a, b) => b.tokens - a.tokens)[0]; const example = top ? `\n\nExample: compress({ content: [{ startId: "${top.startRef}", endId: "${top.endRef}", summary: "..." }] })` : ""; if (emergency) { logWarn("nudge", { sid: ctx.sessionManager.getSessionId(), event: "emergency-inject", pct: Math.round(turn.nudge.contextUsage * 100), voice: rendered.voice, compressible: turn.nudge.compressibleRanges.length }); } if (debugOn && ctx.hasUI) { ctx.ui.notify(`[ACP nudge → context]${emergency ? " [EMERGENCY]" : ""}\n${rendered.text}${example}`); } if (!emergency) runtime.markNudgeShown(turnKey); debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn ? "terminal" : null].filter(Boolean), emergency, turnKey, text: rendered.text + example }); } else { debug.event("nudge-suppressed", { sid: ctx.sessionManager.getSessionId(), turnKey, reason: turn.nudge.reason }); } } // Always return the transformed array: every message needs its [mNNNNN] ref // tag applied, so there is no meaningful "no change" case to short-circuit. debug.event("context-out", { outMsgs: rebuilt.length, injected: turn.nudge?.shouldInject ?? false, emergency: turn.nudge?.breakdown?.emergencyOverride === 1 }); // Also check for updates here (not only on session_start): resuming a // long-running session never re-fires session_start, so an update could // go unnoticed for days. checkForUpdate throttles internally (3 min) and // is guarded against concurrent calls, so firing it per LLM call is safe. void checkForUpdate(runtime.adapter.autoUpdate ?? true, (msg) => { if (ctx.hasUI) ctx.ui.notify(msg); }); return { messages: rebuilt }; } catch (e) { logThrow("context", e, { sid, phase: "transform" }); throw e; } finally { release(); } }); } function wireSystemPrompt(pi: ExtensionAPI, runtime: AcpRuntime, ownership: AcpOwnershipRouter): void { pi.on("before_agent_start", (event, ctx) => { const delegate = runtime.adapter.delegate !== false; if (!ownership.ownsContext(ctx) && !delegate) return undefined; const prompt = ownership.ownsContext(ctx) ? delegate ? `${ACP_SYSTEM_PROMPT}\n${ACP_DELEGATE_PROMPT}` : ACP_SYSTEM_PROMPT : ACP_DELEGATE_PROMPT; return { systemPrompt: formatSystemPromptForEvent(event.systemPrompt, prompt) }; }); } function collectOriginals(entries: Array<{ type: string; id: string; message?: AgentMessage; content?: unknown }>): Map { const map = new Map(); for (const entry of entries) { if (entry.type === "message" && entry.message) { map.set(entry.id, entry.message); } else if (entry.type === "custom_message") { // Pi's convertToLlm projects custom messages as { role: "user", content } // for the LLM. Mirror that here so coreOutToAgentMessages restores a // proper user AgentMessage — using role:"custom" would be dropped by Pi. const content = typeof entry.content === "string" ? [{ type: "text" as const, text: entry.content }] : entry.content; map.set(entry.id, { role: "user", content } as AgentMessage); } } return map; } function nudgeMessage(nudge: NudgeDecision, blocks: CompressionBlock[]): AgentMessage { const rendered = renderNudgeText(nudge); const lines = [rendered.text]; if (blocks.length > 0) { const totalSummary = blocks.reduce((s, b) => s + Math.ceil((b.summary || "").length / 4), 0); const totalCompressed = blocks.reduce((s, b) => s + (b.compressedTokens || 0), 0); const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : `${n}`); const tierCounts: Record = {}; for (const b of blocks) { const t = b.tier ?? 1; tierCounts[t] = (tierCounts[t] || 0) + 1; } const tierStr = Object.keys(tierCounts).map(Number).sort().map((t) => `T${t}:${tierCounts[t]}`).join(" "); const ids = blocks.slice(0, 10).map((b) => b.blockId).join(", "); const extra = blocks.length > 10 ? ` (+${blocks.length - 10} more)` : ""; lines.push(""); lines.push(`Compressed blocks: ${blocks.length} active (${tierStr}) — ${fmt(totalSummary)} summary, ${fmt(totalCompressed)} original compressed. Blocks: ${ids}${extra}.`); } return { role: "user", content: [{ type: "text", text: lines.join("\n") }], timestamp: Date.now(), } as AgentMessage; }