import { existsSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import type { McpServer } from "@agentclientprotocol/sdk"; import { type AssistantMessage, type AssistantMessageEventStream, type Context, calculateCost, createAssistantMessageEventStream, getModels, type Model, type SimpleStreamOptions, } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { type AcpBackend, type ClaudeSettingSource, cancelActivePrompt, cleanupBridgeSessionProcess, closeBridgeSession, DEFAULT_CODEX_DISABLED_FEATURES, describeBridgeSession, ensureBridgeSession, getBridgeErrorDetails, isTranscriptPoisonError, type McpServerInputMap, normalizeMcpServers, sendPrompt, setActivePromptHandler, } from "./acp-bridge.js"; import { loadEngraving } from "./engraving.js"; import { type AcpPiStreamState, applyBridgePromptEvent, finalizeAcpStreamState } from "./event-mapper.js"; import { buildPiContextAugment } from "./pi-context-augment.js"; import { ENTWURF_SENT_MESSAGE_TYPE } from "./protocol.js"; const PROVIDER_ID = "pi-shell-acp"; const REGISTERED_SYMBOL = Symbol.for("pi-shell-acp:registered"); const EMACS_AGENT_SOCKET_FLAG = "emacs-agent-socket"; function debugLoggingEnabled(): boolean { const value = process.env.PI_SHELL_ACP_DEBUG?.trim().toLowerCase(); return value === "1" || value === "true" || value === "yes"; } function logBridgeDiagnostic(label: string, payload: Record): void { if (!debugLoggingEnabled()) return; console.error(`[pi-shell-acp] ${label} ${JSON.stringify(payload)}`); } function isRegisteredOnRuntime(pi: ExtensionAPI): boolean { return Boolean((pi as unknown as Record)[REGISTERED_SYMBOL]); } function markRegisteredOnRuntime(pi: ExtensionAPI): void { Object.defineProperty(pi as object, REGISTERED_SYMBOL, { value: true, configurable: false, enumerable: false, writable: false, }); } function getStringFlag(pi: ExtensionAPI, name: string): string | undefined { const value = pi.getFlag?.(name); if (typeof value === "string" && value.trim()) return value.trim(); const argv = process.argv.slice(2); const flag = `--${name}`; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === flag) { const next = argv[i + 1]; return next && !next.startsWith("--") ? next.trim() || undefined : undefined; } if (arg.startsWith(`${flag}=`)) { const inline = arg.slice(flag.length + 1).trim(); return inline || undefined; } } return undefined; } const GLOBAL_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json"); type ProviderSettings = { backend?: AcpBackend; appendSystemPrompt?: boolean; settingSources?: ClaudeSettingSource[]; strictMcpConfig?: boolean; showToolNotifications?: boolean; mcpServers?: McpServerInputMap; /** Built-in Claude Code tools to expose. Defaults to pi baseline (Read/Bash/Edit/Write). Only consumed by the Claude backend; codex ignores it. */ tools?: string[]; /** Absolute paths to Claude Code plugin directories. Each path is injected as `{ type: "local", path }` into the Claude SDK `plugins` option so skills can be delivered explicitly without opening `settingSources`. Claude-only. */ skillPlugins?: string[]; /** Wildcard rules passed to the SDK as `Options.settings.permissions.allow`. Defaults to allowing the pi-baseline tools and any MCP. Claude-only. */ permissionAllow?: string[]; /** Tool names passed to the SDK as `Options.disallowedTools`. Defaults to the SDK's deferred-tool set (Cron+Task+Worktree+PlanMode families plus WebFetch, WebSearch, Monitor, PushNotification, RemoteTrigger, NotebookEdit, AskUserQuestion) so they cannot leak past the explicit `tools` filter via the SDK's deferred-advertisement surface. Set to `[]` to opt out entirely. Claude-only. */ disallowedTools?: string[]; /** codex-rs feature keys to disable at codex-acp launch via `-c features.=false`. Defaults to `DEFAULT_CODEX_DISABLED_FEATURES` (image_generation, tool_suggest, tool_search, multi_agent, apps) so the codex tool surface aligns with pi's advertised baseline. Set to `[]` to opt out entirely. Codex-only — Claude ignores it. Mirror of `disallowedTools` on the codex side. */ codexDisabledFeatures?: string[]; }; type ResolvedProviderSettings = { backend: AcpBackend; backendSource: "explicit" | "inferred"; appendSystemPrompt: boolean; settingSources: ClaudeSettingSource[]; strictMcpConfig: boolean; showToolNotifications: boolean; mcpServers: McpServer[]; tools: string[]; skillPlugins: string[]; permissionAllow: string[]; disallowedTools: string[]; codexDisabledFeatures: string[]; bridgeConfigSignature: string; }; // pi baseline — matches what `coding-agent/src/core/system-prompt.ts` advertises // as `Available tools:` (lowercase pi names map 1:1 to capitalized Claude Code // tool names). Keeping these aligned is the whole point of the tool-surface // constraint: the agent's stated tools and actual tools are identical. const DEFAULT_CLAUDE_TOOLS: readonly string[] = ["Read", "Bash", "Edit", "Write"]; // Default permission allowlist mirrors the pi-baseline tool surface plus // `mcp__*` so anything reaching us via the bridge MCP servers is auto-allowed. // claude-agent-acp resolves `permissionMode` from the user's filesystem // `~/.claude/settings.json`'s `permissions.defaultMode` (we cannot override // that via `_meta`); combined with this explicit allow list, even a `default` // or `auto` mode lets these tools through without prompts. const DEFAULT_CLAUDE_PERMISSION_ALLOW: readonly string[] = ["Read(*)", "Bash(*)", "Edit(*)", "Write(*)", "mcp__*"]; // SDK 0.2.119 advertises a set of deferred tools via a system-reminder // block ("The following deferred tools are now available via ToolSearch"). // `Options.tools` only filters the immediate function list — the deferred // advertisement is a separate surface that slips through. Pi advertises a // fixed 4–5 tool baseline in its system prompt, so the deferred set creates // the same declared-vs-actual mismatch the explicit `tools` field was meant // to prevent. // // We therefore disallow the full deferred set by default. claude-agent-acp // already prunes one entry (`AskUserQuestion`) via its own disallowedTools // list (acp-agent.ts:1719) — we re-include it for explicitness; the // downstream spread (acp-agent.ts:1768) merges idempotently. Pi's own // equivalents already cover every capability disallowed here: // // Cron* → /schedule skill // WebFetch/WebSearch → brave-search MCP, summarize / medium-extractor // EnterPlanMode/... → pi's plan model (separate) // EnterWorktree/... → operator's git // Monitor/PushNotif… → pi's tmux/session mechanisms // NotebookEdit → covered by Edit // Task*/RemoteTrigger → entwurf + control-socket bridge // // When the SDK adds a new deferred tool, this list must follow. const DEFAULT_CLAUDE_DISALLOWED_TOOLS: readonly string[] = [ "AskUserQuestion", "CronCreate", "CronDelete", "CronList", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "Monitor", "NotebookEdit", "PushNotification", "RemoteTrigger", "TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate", "WebFetch", "WebSearch", ]; // pi-shell-acp is an ACP BRIDGE provider, not a general-purpose OpenAI/Anthropic // provider. It should NOT expose the full pi-ai model registry. Users who pick // `pi-shell-acp/` are choosing a specific bridge path (Claude Code ACP, // codex-acp, or gemini --acp), so the surface is intentionally curated: // // - Claude backend: the two current frontier sonnet/opus we actually test against. // - Codex backend: only the "agentic coding" gpt-5.x line in the openai-codex // source, which is what codex-acp spawns — NOT the openai source, whose // context/cost values reflect the Chat Completions API, not codex. // - Gemini backend: the subscription-backed high-quality ACP target // (`gemini-3.1-pro-preview`) only. `gemini --acp` may default to the flash // line when the bridge does not force a model, but pi-shell-acp always applies // the requested curated model via `unstable_setSessionModel`. 2.5 / Flash / // Lite / custom-tools variants stay off the curated surface until separately // requested and tested. // // Adding a model here means we commit to checking it across both Axis 1 // (protocol smoke) and Axis 2 (agent interview). Do not extend casually. const SUPPORTED_ANTHROPIC_MODEL_IDS: readonly string[] = ["claude-sonnet-4-6", "claude-opus-4-7"] as const; const SUPPORTED_CODEX_MODEL_IDS: readonly string[] = ["gpt-5.4", "gpt-5.4-mini", "gpt-5.5"] as const; const SUPPORTED_GEMINI_MODEL_IDS: readonly string[] = ["gemini-3.1-pro-preview"] as const; const SUPPORTED_ANTHROPIC_SET = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS); const SUPPORTED_CODEX_SET = new Set(SUPPORTED_CODEX_MODEL_IDS); const SUPPORTED_GEMINI_SET = new Set(SUPPORTED_GEMINI_MODEL_IDS); // Codex metadata must come from `openai-codex` (not `openai`). The two sources // diverge: `openai/gpt-5.5` declares 1,050,000 context (Chat Completions tier), // while the `openai-codex` line declares the capacity codex-acp actually // delivers (272,000 across the gpt-5.x line as of pi-ai 0.70.2). Reading from // `openai` causes pi-shell-acp to advertise context it cannot serve — a // concrete bug that showed up as "pi-shell-acp/gpt-5.5 ctx=1.1M" in // --list-models. const ANTHROPIC_MODELS_ALL = getModels("anthropic"); const CODEX_MODELS_ALL = getModels("openai-codex"); // Gemini metadata comes from the `google` source — pi-ai does not split the // Gemini line into a separate `google-acp` registry the way it splits openai // into openai vs openai-codex. The context window pi-ai reports (1,048,576 ≈ // 1M) reflects the underlying model capacity; gemini --acp does not narrow // it the way codex-acp narrows gpt-5.x to 272K. Operators who hit the cap in // practice can still inline a tighter override via PI_SHELL_ACP_GEMINI_CONTEXT // (mirrors PI_SHELL_ACP_CLAUDE_CONTEXT — see resolveGeminiModelContextWindow). const GEMINI_MODELS_ALL = getModels("google"); const ANTHROPIC_MODEL_IDS = new Set(ANTHROPIC_MODELS_ALL.map((m) => m.id)); const CODEX_MODEL_IDS = new Set(CODEX_MODELS_ALL.map((m) => m.id)); const GEMINI_MODEL_IDS = new Set(GEMINI_MODELS_ALL.map((m) => m.id)); // Anthropic's registry reports 1_000_000 for Claude 4.6+ models, but our // public pi-shell-acp surface deliberately distinguishes Sonnet vs Opus: // - sonnet-4-6 stays at 200K by default // - opus-4-6 / opus-4-7 surface at 1M by default // Operators can still override the Claude cap globally via // PI_SHELL_ACP_CLAUDE_CONTEXT when they need to pin a different value. const CLAUDE_CONTEXT_DEFAULT = 1_000_000; const CLAUDE_SONNET_DEFAULT = 200_000; function resolveClaudeContextCap(): number | null { const raw = process.env.PI_SHELL_ACP_CLAUDE_CONTEXT?.trim(); if (!raw) return null; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } const CLAUDE_CONTEXT_OVERRIDE = resolveClaudeContextCap(); function resolveClaudeModelContextWindow(model: { id: string; contextWindow: number }): number { if (CLAUDE_CONTEXT_OVERRIDE) return Math.min(model.contextWindow, CLAUDE_CONTEXT_OVERRIDE); const defaultCap = model.id === "claude-sonnet-4-6" ? CLAUDE_SONNET_DEFAULT : CLAUDE_CONTEXT_DEFAULT; return Math.min(model.contextWindow, defaultCap); } // Gemini context cap mirror of the Claude resolver. The pi-ai `google` source // reports the underlying model capacity (1,048,576 ≈ 1M for the 3-flash and // 2.5 lines), and gemini --acp does not narrow it the way codex-acp narrows // gpt-5.x. The default surface therefore exposes the full model capacity; // operators who want a tighter ceiling for cost / context-management reasons // inline PI_SHELL_ACP_GEMINI_CONTEXT= at process start. Same shape // as PI_SHELL_ACP_CLAUDE_CONTEXT — invalid / non-positive values fall through // silently to the model default. function resolveGeminiContextCap(): number | null { const raw = process.env.PI_SHELL_ACP_GEMINI_CONTEXT?.trim(); if (!raw) return null; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } const GEMINI_CONTEXT_OVERRIDE = resolveGeminiContextCap(); function resolveGeminiModelContextWindow(model: { id: string; contextWindow: number }): number { if (GEMINI_CONTEXT_OVERRIDE) return Math.min(model.contextWindow, GEMINI_CONTEXT_OVERRIDE); return model.contextWindow; } type AnthropicRegistryModel = (typeof ANTHROPIC_MODELS_ALL)[number]; type CodexRegistryModel = (typeof CODEX_MODELS_ALL)[number]; type GeminiRegistryModel = (typeof GEMINI_MODELS_ALL)[number]; function requireRegistryModel( models: T[], id: string, ): T { const model = models.find((m) => m.id === id); if (!model) throw new Error(`Required base model is missing from pi-ai registry: ${id}`); return model; } function curatedAnthropicModels(): AnthropicRegistryModel[] { const models = ANTHROPIC_MODELS_ALL.filter((m) => SUPPORTED_ANTHROPIC_SET.has(m.id)); if (!models.some((m) => m.id === "claude-opus-4-7")) { const base = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-opus-4-6"); models.push({ ...base, id: "claude-opus-4-7", name: "Claude Opus 4.7", contextWindow: 1_000_000, }); } return models; } function curatedCodexModels(): CodexRegistryModel[] { const models = CODEX_MODELS_ALL.filter((m) => SUPPORTED_CODEX_SET.has(m.id)); if (!models.some((m) => m.id === "gpt-5.5")) { const base = requireRegistryModel(CODEX_MODELS_ALL, "gpt-5.4"); models.push({ ...base, id: "gpt-5.5", name: "GPT-5.5", contextWindow: 272_000, }); } return models; } function curatedGeminiModels(): GeminiRegistryModel[] { const models = GEMINI_MODELS_ALL.filter((m) => SUPPORTED_GEMINI_SET.has(m.id)); // gemini-3.1-pro-preview is present in pi-ai 0.73.0's google source. If a // future pi-ai bump drops the preview id (the way provider registries // occasionally retire snapshots), inject a placeholder so pi-shell-acp's // curated surface stays stable. Base falls back to the nearest same-family // google pro model because ACP still speaks the same gemini --acp protocol; // this mirrors the claude-opus-4-7 / gpt-5.5 placeholders above until the // registry catches up. if (!models.some((m) => m.id === "gemini-3.1-pro-preview")) { const base = GEMINI_MODELS_ALL.find((m) => m.id === "gemini-3-pro-preview") ?? requireRegistryModel(GEMINI_MODELS_ALL, "gemini-2.5-pro"); models.push({ ...base, id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview", contextWindow: 1_048_576, maxTokens: 65_536, }); } return models; } const CURATED_ANTHROPIC_MODELS = curatedAnthropicModels(); const CURATED_CODEX_MODELS = curatedCodexModels(); const CURATED_GEMINI_MODELS = curatedGeminiModels(); const MODELS = Array.from( new Map( [...CURATED_ANTHROPIC_MODELS, ...CURATED_CODEX_MODELS, ...CURATED_GEMINI_MODELS].map((model) => [ model.id, { id: model.id, name: model.name, reasoning: model.reasoning, input: model.input, cost: model.cost, contextWindow: SUPPORTED_ANTHROPIC_SET.has(model.id) ? resolveClaudeModelContextWindow(model) : SUPPORTED_GEMINI_SET.has(model.id) ? resolveGeminiModelContextWindow(model) : model.contextWindow, maxTokens: model.maxTokens, }, ]), ).values(), ); function createEmptyUsage() { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } function createOutputMessage(model: Model): AssistantMessage { return { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: createEmptyUsage(), stopReason: "stop", timestamp: Date.now(), }; } function resolveSessionKey(options: SimpleStreamOptions | undefined, cwd: string): string { const sessionId = (options as { sessionId?: string } | undefined)?.sessionId; return sessionId ? `pi:${sessionId}` : `cwd:${cwd}`; } function messageContentSignature(content: any): string { if (typeof content === "string") return `text:${content}`; if (!Array.isArray(content)) return ""; return content .map((block) => { if (!block || typeof block !== "object") return ""; switch (block.type) { case "text": return `text:${String(block.text ?? "")}`; case "image": return `image:${String(block.mimeType ?? block.source?.mimeType ?? block.source?.mediaType ?? "")}:${String(block.uri ?? block.source?.url ?? "")}`; case "thinking": return `thinking:${String(block.thinking ?? "")}`; case "toolCall": return `tool:${String(block.name ?? "")}:${JSON.stringify(block.arguments ?? {})}`; default: return `${String(block.type ?? "unknown")}:${JSON.stringify(block)}`; } }) .join("|"); } function getContextMessageSignatures(context: Context): string[] { return context.messages.map((message: any) => `${message.role}:${messageContentSignature(message.content)}`); } function settingsConfigError(filePath: string, message: string): Error { return new Error(`${filePath}: invalid piShellAcpProvider settings: ${message}`); } function assertOptionalBoolean(settings: Record, key: string, filePath: string): boolean | undefined { const value = settings[key]; if (value === undefined) return undefined; if (typeof value !== "boolean") throw settingsConfigError(filePath, `${key} must be a boolean`); return value; } function readSettingsFile(filePath: string): ProviderSettings { if (!existsSync(filePath)) return {}; const raw = readFileSync(filePath, "utf-8"); let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { throw settingsConfigError(filePath, `malformed JSON (${error instanceof Error ? error.message : String(error)})`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw settingsConfigError(filePath, "settings file root must be an object"); } const root = parsed as Record; const settingsBlock = root["piShellAcpProvider"]; if (settingsBlock === undefined) return {}; if (!settingsBlock || typeof settingsBlock !== "object" || Array.isArray(settingsBlock)) { throw settingsConfigError(filePath, "piShellAcpProvider must be an object"); } const settings = settingsBlock as Record; const backend = settings["backend"]; if (backend !== undefined && backend !== "claude" && backend !== "codex" && backend !== "gemini") { throw settingsConfigError(filePath, "backend must be one of: claude, codex, gemini"); } const appendSystemPrompt = assertOptionalBoolean(settings, "appendSystemPrompt", filePath); const strictMcpConfig = assertOptionalBoolean(settings, "strictMcpConfig", filePath); const showToolNotifications = assertOptionalBoolean(settings, "showToolNotifications", filePath); const settingSourcesRaw = settings["settingSources"]; let settingSources: ClaudeSettingSource[] | undefined; if (settingSourcesRaw !== undefined) { if (!Array.isArray(settingSourcesRaw)) { throw settingsConfigError(filePath, "settingSources must be an array"); } if ( !settingSourcesRaw.every( (value) => typeof value === "string" && (value === "user" || value === "project" || value === "local"), ) ) { throw settingsConfigError(filePath, "settingSources entries must be one of: user, project, local"); } settingSources = settingSourcesRaw as ClaudeSettingSource[]; } const mcpServersRaw = settings["mcpServers"]; let mcpServers: McpServerInputMap | undefined; if (mcpServersRaw !== undefined) { if (!mcpServersRaw || typeof mcpServersRaw !== "object" || Array.isArray(mcpServersRaw)) { throw settingsConfigError(filePath, "mcpServers must be an object"); } mcpServers = mcpServersRaw as McpServerInputMap; } const tools = parseStringArray(settings, "tools", filePath); const skillPlugins = parseStringArray(settings, "skillPlugins", filePath); if (skillPlugins) validateSkillPluginPaths(skillPlugins, filePath); const permissionAllow = parseStringArray(settings, "permissionAllow", filePath); const disallowedTools = parseStringArray(settings, "disallowedTools", filePath); const codexDisabledFeatures = parseStringArray(settings, "codexDisabledFeatures", filePath); return { backend: backend as AcpBackend | undefined, appendSystemPrompt, settingSources, strictMcpConfig, showToolNotifications, mcpServers, tools, skillPlugins, permissionAllow, disallowedTools, codexDisabledFeatures, }; } function parseStringArray(settings: Record, key: string, filePath: string): string[] | undefined { const value = settings[key]; if (value === undefined) return undefined; if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { throw settingsConfigError(filePath, `${key} must be an array of strings`); } return value as string[]; } // `skillPlugins` is the Claude-backend install surface for custom skills. The // bridge owns the contract — entries must be absolute paths to existing // directories that contain `.claude-plugin/plugin.json`. Anything else is // silently dropped by the Claude Agent SDK at session-spawn time, which leaves // the operator's skill invisible without any failure signal — exactly the // "warnings make agents flail" anti-pattern §Code Principle calls out. Fail // fast here so a typo in a path or a missing plugin manifest surfaces before // the session spawns. // // Backend scoping: this validator runs at settings parse time regardless of // the configured backend, but only the Claude path in `buildClaudeSessionMeta` // actually consumes `skillPlugins`. Codex and Gemini ignore the field entirely // (they expose skills through `~/.{backend}/skills/` passthrough instead), so // strict validation here cannot regress Codex/Gemini sessions — it only stops // a malformed Claude install from booting silently. function validateSkillPluginPaths(paths: string[], filePath: string): void { for (let index = 0; index < paths.length; index++) { const pluginPath = paths[index]; const label = `skillPlugins[${index}]`; if (!isAbsolute(pluginPath)) { throw settingsConfigError(filePath, `${label} must be an absolute path (got ${JSON.stringify(pluginPath)})`); } let stat: ReturnType; try { stat = statSync(pluginPath); } catch (_error) { throw settingsConfigError(filePath, `${label} does not exist: ${pluginPath}`); } if (!stat.isDirectory()) { throw settingsConfigError(filePath, `${label} must point at a directory: ${pluginPath}`); } const manifestPath = join(pluginPath, ".claude-plugin", "plugin.json"); if (!existsSync(manifestPath)) { throw settingsConfigError( filePath, `${label} is missing .claude-plugin/plugin.json — expected ${manifestPath}. ` + `See README §Custom Skills for the minimum plugin shape.`, ); } } } // Once-per-process flag so the warning fires on first bootstrap but does not // repeat on every prompt or model switch. let codexFeatureGatingWarningEmitted = false; // Detect the explicit-empty-array case (`"codexDisabledFeatures": []`) and // warn the operator. The semantics differ from "absent" in a way that has // already caused one regression downstream (agent-config 0.2.1-era workaround // that survived the 0.2.2 nullish-guard fix and silently flipped the codex // tool surface from fail-closed to fail-open). Absent → DEFAULT applies → // 5 features disabled. Explicit `[]` → operator opt-out → all gating off, // codex native multi_agent / apps / image_generation / tool_suggest / // tool_search become callable. The warning lets operators who set `[]` by // accident see the divergence at session start instead of discovering it // turns later via a tool-availability surprise. function warnIfCodexFeatureGatingDisabled(rawValue: readonly string[] | undefined): void { if (codexFeatureGatingWarningEmitted) return; if (rawValue === undefined) return; if (rawValue.length !== 0) return; codexFeatureGatingWarningEmitted = true; console.error( `[pi-shell-acp:warn] codexDisabledFeatures=[] in settings.json explicitly opts out of bridge feature gating; codex native multi_agent (spawn_agent et al.), apps (mcp__codex_apps__*), image_generation, tool_suggest, and tool_search are enabled. To restore the fail-closed default (${DEFAULT_CODEX_DISABLED_FEATURES.join(", ")}), remove the codexDisabledFeatures key. To gate a subset, list only the keys you want disabled.`, ); } function inferBackendFromModel(model: Model): AcpBackend { // Curated-first: the allowlist determines routing deterministically. if (SUPPORTED_CODEX_SET.has(model.id)) return "codex"; if (SUPPORTED_ANTHROPIC_SET.has(model.id)) return "claude"; if (SUPPORTED_GEMINI_SET.has(model.id)) return "gemini"; // Fallback: if an ID outside the allowlist somehow reaches here (e.g. a // non-curated model was passed via explicit settings), consult the broader // pi-ai registry. This is a safety net, not the primary path. if (CODEX_MODEL_IDS.has(model.id)) return "codex"; if (ANTHROPIC_MODEL_IDS.has(model.id)) return "claude"; if (GEMINI_MODEL_IDS.has(model.id)) return "gemini"; // Last-resort prefix routing — keeps explicit-backend operators productive // when they pass a model id outside any registered source. gpt-/o-/codex- // → codex; gemini- → gemini; everything else → claude. if (model.id.startsWith("gpt-") || model.id.startsWith("o") || model.id.startsWith("codex")) return "codex"; if (model.id.startsWith("gemini-")) return "gemini"; return "claude"; } function loadProviderSettings(cwd: string, model: Model): ResolvedProviderSettings { const globalSettings = readSettingsFile(GLOBAL_SETTINGS_PATH); const projectSettings = readSettingsFile(join(cwd, ".pi", "settings.json")); // Project overrides global, but only for keys the project actually sets — // readSettingsFile returns undefined for missing keys and JS spread treats // undefined as an override, which would silently nuke global values. const projectDefined = Object.fromEntries( Object.entries(projectSettings).filter(([, v]) => v !== undefined), ) as ProviderSettings; const merged = { ...globalSettings, ...projectDefined }; const backend = merged.backend ?? inferBackendFromModel(model); const backendSource = merged.backend ? "explicit" : "inferred"; const appendSystemPrompt = merged.appendSystemPrompt ?? false; // settingSources defaults to [] (SDK isolation) — pi-shell-acp does not // inherit the user's filesystem Claude Code settings (MCP, hooks, env, // plugins, skills). Skills are delivered explicitly via skillPlugins; MCP // via mcpServers. Operators who want native filesystem inheritance can // opt in by setting `settingSources: ["user"]` (or "project"/"local"). const settingSources = merged.settingSources ?? []; // strictMcpConfig defaults to true — only the MCP servers we provide via // `mcpServers` reach the backend. The user's `~/.mcp.json`, project // `.mcp.json`, and `~/.claude/settings.json` MCP entries are ignored. const strictMcpConfig = merged.strictMcpConfig ?? true; // Default `true` since 0.7.0 — interactive pi / debugging / day-to-day work // benefits from progress visibility. NEXT.md "plugin spawn-level // tool-notification trace handling" entry records the rationale: forcing // `false` to hide `[tool:*]` lines from downstream channels (OpenClaw // final-answer, Telegram bot, etc.) is a downstream-delivery concern, not // a bridge-default concern. Callers that need final-text-only output // should filter at the delivery layer instead of darkening the source. const showToolNotifications = merged.showToolNotifications ?? true; // Tool surface defaults to the pi baseline so the system prompt's // "Available tools:" line and the SDK's actual tools align (Read, Bash, // Edit, Write). MCP tools (mcp__*) are exposed independently via // mcpServers and are auto-allowed by the default permissionAllow. const skillPlugins = merged.skillPlugins ?? []; const baseTools = merged.tools ?? [...DEFAULT_CLAUDE_TOOLS]; const baseAllow = merged.permissionAllow ?? [...DEFAULT_CLAUDE_PERMISSION_ALLOW]; // When skillPlugins is non-empty, ensure the SDK's "Skill" tool is in the // surface. The SDK's skill-listing emitter (SN1 in claude-agent-sdk) is // gated on `tools.some(name === "Skill")`: without it, the listing returns // empty and skills never reach the system prompt — even though the plugin // loaded all skills//SKILL.md into memory. Verified against // claude-agent-sdk 0.2.114 and 0.2.119; the gate is identical in both, so // this is independent of the dep bump in 32a3dee. We also auto-allow // `Skill(*)` so the listing surface is not silently denied at the // permission layer. const tools = skillPlugins.length > 0 && !baseTools.includes("Skill") ? [...baseTools, "Skill"] : baseTools; const permissionAllow = skillPlugins.length > 0 && !baseAllow.includes("Skill(*)") ? [...baseAllow, "Skill(*)"] : baseAllow; const disallowedTools = merged.disallowedTools ?? [...DEFAULT_CLAUDE_DISALLOWED_TOOLS]; // Distinguish absent (apply default fail-closed gating) from explicit `[]` // (operator opts fully out of bridge feature gating). The `??` collapses // both to a value, so we read the pre-merge field directly to detect the // explicit-empty case and warn — see warnIfCodexFeatureGatingDisabled(). const codexDisabledFeatures = merged.codexDisabledFeatures ?? [...DEFAULT_CODEX_DISABLED_FEATURES]; warnIfCodexFeatureGatingDisabled(merged.codexDisabledFeatures); const mergedMcpServersRaw: McpServerInputMap = { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}), }; const { servers: mcpServers, hash: mcpServersHash } = normalizeMcpServers(mergedMcpServersRaw); return { backend, backendSource, appendSystemPrompt, settingSources, strictMcpConfig, showToolNotifications, mcpServers, tools, skillPlugins, permissionAllow, disallowedTools, codexDisabledFeatures, bridgeConfigSignature: JSON.stringify({ backend, appendSystemPrompt, settingSources, strictMcpConfig, mcpServersHash, tools, skillPlugins, permissionAllow, disallowedTools, codexDisabledFeatures, }), }; } function extractPromptBlocks( context: Context, ): Array<{ type: "text"; text: string } | { type: "image"; data?: string; mimeType?: string; uri?: string }> { // Find the first user message after the last assistant message. // pi injects hook messages (e.g., SessionStart "device=..., time_kst=...") as additional // user messages AFTER the real prompt. Using reverse().find() would pick the hook message // instead of the actual prompt. By taking the first user message in the trailing group, // we reliably get the real prompt in both single-turn (-p) and multi-turn modes. let lastAssistantIdx = -1; for (let i = context.messages.length - 1; i >= 0; i--) { if (context.messages[i].role === "assistant") { lastAssistantIdx = i; break; } } const latestUserMessage = context.messages .slice(lastAssistantIdx + 1) .find((message) => message.role === "user") as any; if (!latestUserMessage) { return [{ type: "text", text: "" }]; } const blocks: Array< { type: "text"; text: string } | { type: "image"; data?: string; mimeType?: string; uri?: string } > = []; for (const block of latestUserMessage.content ?? []) { if (block?.type === "text") { blocks.push({ type: "text", text: String(block.text ?? "") }); continue; } if (block?.type === "image") { const source = block.source ?? {}; if (source.type === "base64") { blocks.push({ type: "image", data: source.data, mimeType: source.mediaType ?? source.mimeType, }); } else if (source.type === "url") { blocks.push({ type: "image", uri: source.url, }); } } } if (blocks.length === 0) { blocks.push({ type: "text", text: "" }); } return blocks; } // pi-shell-acp follows the same context-meter convention as peer ACP clients // (zed, obsidian-agent-client, openclaw-acpx): display the backend's // `usage_update.used / size` directly. Both supported backends emit per-turn // occupancy — claude-agent-acp via `input + output + cache_read + // cache_creation` of the last assistant result, codex-acp via // `tokens_in_context_window()`. event-mapper.ts records that value into // `output.usage.totalTokens` while streaming; this function only fills the // per-component fields from `PromptResponse.usage` so cost accounting and // pi's BackendUsage stats line up. // // Two meter modes are surfaced in the diagnostic so audits can tell which // number the footer is showing: // - `meter=acpUsageUpdate source=backend` — `usage_update` arrived during // streaming and the backend's per-turn occupancy was used. // - `meter=componentSum source=promptResponse` — no `usage_update` arrived // (some backends skip emitting on tool-only turns); the footer falls // back to summing PromptResponse components so it still has a value. // // IMPORTANT — semantic difference vs native pi: // In pi-shell-acp the footer percentage follows the ACP backend's reported // occupancy, not pi's visible-transcript estimate. The two values can differ // because the backend counts its own prompt/cache/tool/session state on top // of the visible transcript. A small pi conversation can map to a large ACP // footer; that is a backend-overflow risk signal, not a meter bug. function applyPromptUsage( model: Model, output: AssistantMessage, promptResponse: any, backend: AcpBackend, acpUsageSeen: boolean, acpUsageSize: number | undefined, ): void { const usage = promptResponse?.usage; const hasUsage = usage && typeof usage === "object"; const rawInput = hasUsage ? Number(usage.inputTokens ?? 0) : 0; const rawOutput = hasUsage ? Number(usage.outputTokens ?? 0) : 0; const rawCacheRead = hasUsage ? Number(usage.cachedReadTokens ?? 0) : 0; const rawCacheWrite = hasUsage ? Number(usage.cachedWriteTokens ?? 0) : 0; output.usage.input = rawInput; output.usage.output = rawOutput; output.usage.cacheRead = rawCacheRead; output.usage.cacheWrite = rawCacheWrite; // Pick meter mode by whether usage_update arrived, NOT by totalTokens > 0. // `used = 0` is a legitimate backend value (codex-acp clamps with // `.max(0)`, fresh-session edges can report zero) so a numeric check would // silently misclassify legitimate "occupancy is zero" reports as fallback. let meter: "acpUsageUpdate" | "componentSum"; let source: "backend" | "promptResponse"; if (acpUsageSeen) { meter = "acpUsageUpdate"; source = "backend"; } else { output.usage.totalTokens = rawInput + rawOutput + rawCacheRead + rawCacheWrite; meter = "componentSum"; source = "promptResponse"; } calculateCost(model, output.usage); const used = output.usage.totalTokens; const size = acpUsageSize ?? model.contextWindow; console.error( `[pi-shell-acp:usage] meter=${meter} source=${source} backend=${backend} used=${used} size=${size} ` + `raw: input=${rawInput} output=${rawOutput} cacheRead=${rawCacheRead} cacheWrite=${rawCacheWrite}`, ); } function mapPromptStopReason(stopReason: string | undefined): AssistantMessage["stopReason"] { switch (stopReason) { case "max_tokens": return "length"; case "cancelled": return "aborted"; default: return "stop"; } } function streamShellAcp( pi: ExtensionAPI, model: Model, context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream { const stream = createAssistantMessageEventStream(); (async () => { const output = createOutputMessage(model); const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd(); // Forward the raw pi sessionId structurally to the backend child env // (PI_SESSION_ID). Previously we relied on the entwurf-control // extension writing process.env.PI_SESSION_ID before backend spawn, // which was an ordering accident — `./run.sh check-bridge` and live // entwurf_send failed when the extension hook ran after spawn. See // EnsureBridgeSessionParams.piSessionId comment for the full story. const piSessionId = (options as { sessionId?: string } | undefined)?.sessionId; const sessionKey = resolveSessionKey(options, cwd); const providerSettings = loadProviderSettings(cwd, model); const emacsAgentSocket = getStringFlag(pi, EMACS_AGENT_SOCKET_FLAG); const bridgeConfigSignature = JSON.stringify({ base: providerSettings.bridgeConfigSignature, emacsAgentSocket: emacsAgentSocket ?? null, }); let bridgeSession: Awaited> | undefined; // Issue #8 note (2026-05-16): ACP sender-side `[entwurf sent →]` // customMessage promotion is intentionally disabled. The 0.4.15 attempt // buffered observed entwurf_send calls during the ACP stream and appended a // customMessage after stream.end(), because pi.sendMessage() is not a // passive append while AgentSession.isStreaming is true. That preserved LLM // context hygiene but made sync sends appear *after* the long tool call and // final assistant response, which reads like a fresh late send. Keep the // receive-side renderer and context filter below for native/tool results and // old transcripts; revisit ACP in #8 only after pi has an in-stream passive // UI append/update path. Until then ACP entwurf_send stays as ordinary tool // notification text at the tool event position. const streamState: AcpPiStreamState = { stream, output, showToolNotifications: providerSettings.showToolNotifications, }; // Start the pi stream before ACP bootstrap. Resume/load can take noticeable // time, and delaying `start` until after ensureBridgeSession() makes the UI // look stuck even though work is already in progress. stream.push({ type: "start", partial: output }); try { const baseSystemPrompt = providerSettings.appendSystemPrompt ? context.systemPrompt : undefined; // Engraving — self-recognition prompt from prompts/engraving.md, // rendered once here and delivered per-backend at the highest // stable identity-carrier surface each backend exposes: // // - Claude: sent as `_meta.systemPrompt = ` so // claude-agent-acp performs full preset replacement against the // string-form Options.systemPrompt union (sdk.d.ts:1695). The // claude_code preset disappears from the system prompt; the // engraving sits directly above the SDK's hard-wired minimum // identity prefix. // - Codex: sent as `-c developer_instructions="<...>"` at // codex-acp child spawn time, landing inside the codex // `developer` role between the binary's `permissions` / // `apps` / `skills` instruction blocks. codex-acp does not // honor `_meta.systemPrompt`, so this is the highest stable // carrier the codex stack exposes. // - Gemini: written into the overlay's `system.md` (target of // `GEMINI_SYSTEM_MD` env), which gemini-cli's // `getCoreSystemPrompt` reads as a full replacement of the // native "Instruction and Memory Files" body // (packages/core/src/prompts/promptProvider.ts:111). Equivalent // in role to Claude's `_meta.systemPrompt` replacement — fully // shadows the bundled native body so the operator's identity // text is the only system-level content the model sees. // // The rendered engraving is stable across turns (pure function of // template × backend × mcpServerNames). On Claude it travels in // `systemPromptAppend`; on Codex it travels in // `codexDeveloperInstructions`; on Gemini it travels in // `geminiSystemPromptText` (written into the overlay's system.md // by ensureGeminiConfigOverlay at every spawn). All three fields // are part of session compatibility checks — changing any forces // a fresh bridge session so the new engraving is actually // delivered to the model. const engraving = loadEngraving({ backend: providerSettings.backend, mcpServerNames: providerSettings.mcpServers.map((s) => s.name), }); const claudeEngraving = providerSettings.backend === "claude" ? engraving : null; const codexEngraving = providerSettings.backend === "codex" ? engraving : null; const geminiEngraving = providerSettings.backend === "gemini" ? engraving : null; const systemPromptParts = [baseSystemPrompt, claudeEngraving ?? undefined].filter( (part): part is string => typeof part === "string" && part.length > 0, ); const mergedSystemPromptAppend = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; const codexDeveloperInstructions = codexEngraving && codexEngraving.length > 0 ? codexEngraving : undefined; const geminiSystemPromptText = geminiEngraving && geminiEngraving.length > 0 ? geminiEngraving : undefined; // pi-context augment — bridge identity narrative + pi base intro + // ~/AGENTS.md + cwd/AGENTS.md + date/cwd. Delivered as a // ContentBlock prepend on the first user message of a new bridge // session, NOT in the system-prompt carrier — keeps the // `_meta.systemPrompt` payload small enough to stay inside // Anthropic subscription billing while still giving the agent // project context. Entwurf-spawned sessions skip the prepend // (de-dup check in `acp-bridge.ts:sendPrompt` against the // caller-supplied `` block). Same shape on all // three backends now: the engraving rides the system-prompt-shape // carrier (per-backend; see above), the augment rides the // first-user message. const bootstrapPromptAugment = buildPiContextAugment({ backend: providerSettings.backend, cwd, mcpServerNames: providerSettings.mcpServers.map((s) => s.name), emacsAgentSocket, }); bridgeSession = await ensureBridgeSession({ sessionKey, cwd, backend: providerSettings.backend, modelId: model.id, piSessionId, systemPromptAppend: mergedSystemPromptAppend, bootstrapPromptAugment, codexDeveloperInstructions, geminiSystemPromptText, emacsAgentSocket, settingSources: providerSettings.settingSources, strictMcpConfig: providerSettings.strictMcpConfig, mcpServers: providerSettings.mcpServers, tools: providerSettings.tools, skillPlugins: providerSettings.skillPlugins, permissionAllow: providerSettings.permissionAllow, disallowedTools: providerSettings.disallowedTools, codexDisabledFeatures: providerSettings.codexDisabledFeatures, bridgeConfigSignature, contextMessageSignatures: getContextMessageSignatures(context), }); logBridgeDiagnostic("session", { ...describeBridgeSession(bridgeSession), backendSource: providerSettings.backendSource, }); setActivePromptHandler(bridgeSession, async (event) => { if (event.type === "session_notification" && event.notification?.sessionId !== bridgeSession?.acpSessionId) { return; } applyBridgePromptEvent(streamState, event as any); }); const promptBlocks = extractPromptBlocks(context); // ESC / abort handling. // // We race the actual prompt against the abort signal. The abort // branch resolves to null after dispatching cancel to the backend, // so the stream closes immediately on ESC instead of blocking on a // backend that may take seconds (or never) to acknowledge cancel. // // The sendPrompt promise can still resolve or reject after we've // already taken the abort branch — we attach a no-op `.catch` so // late rejections don't surface as unhandledRejection, and the // finally block clears setActivePromptHandler so any late // session_notification updates are dropped on the floor. const sendPromise = sendPrompt(bridgeSession, promptBlocks); sendPromise.catch(() => { // late rejection after abort; intentionally swallowed }); const abortPromise = new Promise((resolve) => { const signal = options?.signal; if (!signal) return; // never resolves — race falls back to sendPromise const dispatchCancel = () => { if (bridgeSession) { void cancelActivePrompt(bridgeSession).catch(() => { // cancel best-effort; backend may not implement it }); } resolve(null); }; if (signal.aborted) { dispatchCancel(); return; } signal.addEventListener("abort", dispatchCancel, { once: true }); }); const promptResponse = await Promise.race([sendPromise, abortPromise]); if (promptResponse === null) { output.stopReason = "aborted"; output.errorMessage = "Operation aborted"; finalizeAcpStreamState(streamState); stream.push({ type: "error", reason: "aborted", error: output }); stream.end(); return; } applyPromptUsage( model, output, promptResponse, providerSettings.backend, streamState.acpUsageSeen === true, streamState.acpUsageSize, ); output.stopReason = mapPromptStopReason(promptResponse?.stopReason); finalizeAcpStreamState(streamState); if (options?.signal?.aborted || output.stopReason === "aborted") { output.errorMessage = "Operation aborted"; stream.push({ type: "error", reason: "aborted", error: output }); stream.end(); return; } stream.push({ type: "done", reason: output.stopReason === "length" ? "length" : "stop", message: output, }); stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = getBridgeErrorDetails(error, bridgeSession); finalizeAcpStreamState(streamState); stream.push({ type: "error", reason: output.stopReason === "aborted" ? "aborted" : "error", error: output, }); stream.end(); if (output.stopReason === "error" && bridgeSession) { // When a resumed/loaded session's prompt fails with an // Anthropic transcript-validity 400 (cache_control on empty // text block, or "text content blocks must be non-empty"), // the saved persisted record points at a backend transcript // that will reject every subsequent resume the same way. // Drop the mapping so any subsequent bootstrap — including // host re-entry within the same CLI invocation — fires the // existing resume → load → new ladder against an empty // persisted record and lands on path=new naturally. The // bridge does NOT force a same-turn retry on its own; that // recovery is the host's normal re-entry pattern. See // pi-shell-acp#12. const transcriptPoison = isTranscriptPoisonError(error) && bridgeSession.bootstrapPath !== "new"; if (transcriptPoison) { console.error( `[pi-shell-acp:prompt-error] reason=transcript_poison sessionKey=${bridgeSession.key} bootstrapPath=${bridgeSession.bootstrapPath} acpSessionId=${bridgeSession.acpSessionId}`, ); } try { await closeBridgeSession(bridgeSession.key, { closeRemote: true, invalidatePersisted: transcriptPoison, }); } catch { // best-effort cleanup; already reported via stream error } } } finally { // abort listener uses { once: true } and self-removes after firing, // so no manual removeEventListener is needed here. setActivePromptHandler // is cleared below so any session_notification arriving after we // returned via the abort path is dropped instead of pushed into a // closed stream. if (bridgeSession) { setActivePromptHandler(bridgeSession, undefined); } } })(); return stream; } export default function (pi: ExtensionAPI) { if (isRegisteredOnRuntime(pi)) { return; } markRegisteredOnRuntime(pi); ( pi as ExtensionAPI & { registerFlag?: (name: string, options: { description: string; type: "string" }) => void } ).registerFlag?.(EMACS_AGENT_SOCKET_FLAG, { description: "Optional Emacs server socket name for agent Emacs operations (exported as PI_EMACS_AGENT_SOCKET)", type: "string", }); const on = pi.on as unknown as ( event: string, handler: ( event: Record, ctx: { sessionManager?: { getSessionId?: () => string } }, ) => unknown | Promise, ) => void; // Provider-owned context filter for ACP sender-side UI echoes. The bridge // itself emits ENTWURF_SENT_MESSAGE_TYPE, so the filter must live here too — // not only in the optional --entwurf-control extension. Otherwise a // pi-shell-acp session without --entwurf-control could append the UI echo and // let convertToLlm see it on the next turn. on("context", async (event) => ({ messages: Array.isArray(event.messages) ? event.messages.filter((m) => { const cm = m as { customType?: string }; return cm.customType !== ENTWURF_SENT_MESSAGE_TYPE; }) : event.messages, })); on("session_shutdown", async (_event, ctx) => { const sessionId = ctx?.sessionManager?.getSessionId?.(); if (!sessionId) return; await cleanupBridgeSessionProcess(`pi:${sessionId}`); }); // Block pi-side (host) compaction at session_before_compact. // // pi runs four compaction paths, all going through this event: // 1. silent overflow recovery (`isContextOverflow` Case 2) // 2. threshold compaction (`shouldCompact(...)`) // 3. explicit error overflow recovery // 4. manual `/compact` invoked by the operator // // 0.5.0 policy: pi-side compact stays blocked by default for ACP // sessions. The reason is honest and worth surfacing — pi-side // summaries do NOT reduce the ACP backend transcript. The backend // has its own session and its own compaction interface; running a // pi-side summary just shrinks pi's view of the conversation while // the backend keeps growing. That is not what the operator wants // when they hit context pressure on an ACP session. // // Backend-native compaction is no longer blocked by the bridge // (see acp-bridge.ts). When a backend can compact natively, the pi // session survives that. If the operator types `/compact` and // actually wants the backend to compact, they should send it as a // backend prompt (or let the backend auto-compact). The message // below names this next action — same tone as // "entwurf already exists → use entwurf_resume". // // Escape: `PI_SHELL_ACP_ALLOW_PI_COMPACTION=1` opts back into pi- // side compaction (rare; useful only when the operator accepts // that the backend transcript will stay untouched). on("session_before_compact", () => { if (isPiCompactionAllowedByOperator()) { return; } console.error( [ "[pi-shell-acp:compaction] pi-side compact is blocked for ACP sessions —", " it does not reduce the backend transcript (only pi's local JSONL view).", "", " Backend-native compaction is handled by the ACP backend itself.", " Send `/compact` to the backend as a regular prompt, or let the", " backend auto-compact when it hits its own threshold.", "", " To opt back into pi-side compact anyway (accepting that the", " backend transcript stays untouched), set PI_SHELL_ACP_ALLOW_PI_COMPACTION=1.", ].join("\n"), ); return { cancel: true }; }); pi.registerProvider(PROVIDER_ID, { baseUrl: "pi-shell-acp", apiKey: "ANTHROPIC_API_KEY", api: "pi-shell-acp", models: MODELS, streamSimple: (model, context, options) => streamShellAcp(pi, model, context, options), }); } function isPiCompactionAllowedByOperator(): boolean { const v = process.env.PI_SHELL_ACP_ALLOW_PI_COMPACTION?.trim().toLowerCase(); return v === "1" || v === "true" || v === "yes"; }