7b7b1d24ae8aea40e0dc77548624ffdaf3156208

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Remove opencode support

Diff

This diff is truncated to protect this page.

  1diff --git a/AGENTS.md b/AGENTS.md
  2index d3e0d1e817eaeb7f70a693ef871bb4db39701bfa..4dc50c1befccb890aa3c2ed984e264b2622629f1 100644
  3--- a/AGENTS.md
  4+++ b/AGENTS.md
  5@@ -2,22 +2,20 @@
  6 
  7 ## Purpose
  8 
  9-This package provides policy plugins for Pi and OpenCode. It combines host-specific prechecks, deterministic rules, a JSONL decision cache, and an LLM reviewer. Decisions are `allow`, `deny`, or `ask`.
 10+This package provides a policy plugin for Pi. It combines host-specific prechecks, deterministic rules, a JSONL decision cache, and an LLM reviewer. Decisions are `allow`, `deny`, or `ask`.
 11 
 12 ## Layout
 13 
 14-- `src/core/` — shared rules, deterministic checks, pipeline, reviewer, providers, cache, audit, normalization, and configuration.
 15-- `src/pi/` — Pi extension, static permissions, Bash parsing, credentials, UI, and session Bash overrides.
 16-- `src/opencode/` — OpenCode plugin, configuration, credentials, and permission event conversion.
 17-- `test/core/`, `test/pi/`, `test/opencode/` — unit and extension tests.
 18+- `src/core/` — shared rules, deterministic checks, pipeline, reviewer, llama.cpp provider, cache, audit, normalization, and configuration.
 19+- `src/pi/` — Pi extension, static permissions, Bash parsing, UI, and session Bash overrides.
 20+- `test/core/`, `test/pi/` — unit and extension tests.
 21 
 22-The package root exports the OpenCode plugin. `./pi` exports the Pi extension. `src/index.ts` re-exports `./pi/index` for Pi extension loading.
 23+The package root and `./pi` export the Pi extension. `src/index.ts` re-exports `./pi/index` for Pi extension loading.
 24 
 25 ## Policy flow
 26 
 27 Pi evaluates session Bash overrides and static permissions before deterministic rules. It parses valid Bash into command nodes before evaluation. Unparsed Bash does not receive a deterministic allow and falls back to LLM review.
 28 
 29-OpenCode converts each `permission.asked` pattern to a policy request. It has no Pi prechecks.
 30 
 31diff --git a/README.md b/README.md
 32index 3184a8c26fd2a27daf4ee187960acf690196a22c..1daee77771150311dd4ecf52139fc5ca5801edb2 100644
 33--- a/README.md
 34+++ b/README.md
 35@@ -1,6 +1,6 @@
 36 # agent-policy-engine
 37 
 38-A policy engine for Pi and OpenCode. It evaluates tool operations in this order:
 39+A policy engine for Pi. It evaluates tool operations in this order:
 40 
 41 1. Host prechecks
 42 2. Deterministic policy rules
 43@@ -24,31 +24,26 @@ bun install
 44 pi -e ./src/pi/index.ts
 45 ```
 46 
 47-### OpenCode
 48-
 49-Install this package as an OpenCode plugin. The package root exports `src/opencode/index.ts`.
 50-
 51-OpenCode must ask for operations that this policy engine should evaluate. OpenCode operations that native permissions allow do not reach the plugin.
 52-
 53 ## Reviewer configuration
 54 
 55diff --git a/bun.lock b/bun.lock
 56index 75cbb01c0ce84273bb6c4f2a56ab2dfe24b85953..a2298473651561f90a6c4f14cf9c3915dd551850 100644
 57--- a/bun.lock
 58+++ b/bun.lock
 59@@ -5,7 +5,6 @@
 60     "": {
 61       "name": "agent-policy-engine",
 62       "dependencies": {
 63-        "@opencode-ai/plugin": "1.18.22",
 64         "tree-sitter-bash": "0.25.1",
 65         "web-tree-sitter": "0.26.13",
 66       },
 67@@ -23,8 +22,6 @@
 68     },
 69   },
 70   "packages": {
 71-    "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
 72-
 73diff --git a/package.json b/package.json
 74index d31ca83b817017de319db7f2cac6a701a61e151e..ae2566315559f0718a2170b77083f9e09302ba22 100644
 75--- a/package.json
 76+++ b/package.json
 77@@ -2,9 +2,9 @@
 78   "name": "agent-policy-engine",
 79   "version": "3.0.0",
 80   "type": "module",
 81-  "main": "./src/opencode/index.ts",
 82+  "main": "./src/pi/index.ts",
 83   "exports": {
 84-    ".": "./src/opencode/index.ts",
 85+    ".": "./src/pi/index.ts",
 86     "./pi": "./src/pi/index.ts",
 87     "./core": "./src/core/index.ts"
 88   },
 89@@ -28,7 +28,6 @@
 90     "typescript-eslint": "^8.58.0"
 91   },
 92   "dependencies": {
 93-    "@opencode-ai/plugin": "1.18.22",
 94     "tree-sitter-bash": "0.25.1",
 95     "web-tree-sitter": "0.26.13"
 96   }
 97diff --git a/src/core/config.ts b/src/core/config.ts
 98index b7437a880941e5048add7bd7fc402850f518de99..52276a12a5d0f11804cdfac620ecba34a76d33ae 100644
 99--- a/src/core/config.ts
100+++ b/src/core/config.ts
101@@ -1,8 +1,4 @@
102-import type { LlamaReasoningFormat, OpenAIReasoningEffort, ReviewerConfig } from "./types"
103-
104-const DEFAULT_OPENAI_MODEL = "gpt-5.4-nano"
105-const DEFAULT_LLAMA_BASE_URL = "http://127.0.0.1:9931"
106-const DEFAULT_LLAMA_MODEL = "reviewer"
107+import type { LlamaReasoningFormat, PiReasoningEffort, ReviewerConfig } from "./types"
108 
109 function objectValue(value: unknown): Record<string, unknown> | undefined {
110   return value && typeof value === "object" && !Array.isArray(value)
111@@ -14,13 +10,10 @@ function stringValue(value: unknown): string | undefined {
112   return typeof value === "string" && value.trim() ? value.trim() : undefined
113 }
114 
115-function apiKeyEnvironment(value: unknown, field: string): string | undefined {
116-  const name = stringValue(value)
117-  if (!name) return undefined
118-  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
119-    throw new Error(`${field} must be an environment variable name`)
120-  }
121-  return name
122+function booleanValue(value: unknown, field: string, fallback: boolean): boolean {
123+  if (value === undefined) return fallback
124+  if (typeof value === "boolean") return value
125+  throw new Error(`${field} must be a boolean`)
126 }
127 
128 function llamaReasoningFormat(value: unknown): LlamaReasoningFormat {
129@@ -29,66 +22,41 @@ function llamaReasoningFormat(value: unknown): LlamaReasoningFormat {
130   throw new Error('reviewer.reasoningFormat must be "none", "deepseek", or "deepseek-legacy"')
131 }
132 
133-function openAIReasoningEffort(value: unknown): OpenAIReasoningEffort | undefined {
134+function reasoningEffort(value: unknown): PiReasoningEffort | undefined {
135   if (value === undefined) return undefined
136-  if (value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
137+  if (value === "none" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
138     return value
139   }
140-  throw new Error('reviewer.reasoningEffort must be "none", "low", "medium", "high", "xhigh", or "max"')
141-}
142-
143-function booleanValue(value: unknown, field: string, fallback: boolean): boolean {
144-  if (value === undefined) return fallback
145-  if (typeof value === "boolean") return value
146-  throw new Error(`${field} must be a boolean`)
147-}
148-
149-function openAIConfig(value: Record<string, unknown>): ReviewerConfig {
150-  return {
151-    kind: "openai",
152-    model: stringValue(value.model) ?? process.env.OPENAI_SMALL_FAST_MODEL ?? DEFAULT_OPENAI_MODEL,
153-    reasoningEffort: openAIReasoningEffort(value.reasoningEffort),
154-  }
155-}
156-
157-function llamaConfig(value: Record<string, unknown>): ReviewerConfig {
158-  return {
159-    kind: "llama.cpp",
160-    baseUrl: stringValue(value.baseUrl) ?? DEFAULT_LLAMA_BASE_URL,
161-    model: stringValue(value.model) ?? DEFAULT_LLAMA_MODEL,
162-    enableThinking: booleanValue(value.enableThinking, "reviewer.enableThinking", false),
163-    reasoningFormat: llamaReasoningFormat(value.reasoningFormat),
164-  }
165-}
166-
167-function compatibleConfig(value: Record<string, unknown>, field: string): ReviewerConfig {
168-  const baseUrl = stringValue(value.baseUrl)
169-  const model = stringValue(value.model)
170-  if (!baseUrl) throw new Error(`${field}.baseUrl must be a non-empty string`)
171-  if (!model) throw new Error(`${field}.model must be a non-empty string`)
172-  return {
173-    kind: "openai-compatible",
174-    baseUrl,
175-    model,
176-    apiKeyEnv: apiKeyEnvironment(value.apiKeyEnv, `${field}.apiKeyEnv`),
177-  }
178+  throw new Error('reviewer.reasoningEffort must be "none", "minimal", "low", "medium", "high", "xhigh", or "max"')
179 }
180 
181 export function parseReviewerConfig(value: unknown): ReviewerConfig {
182   const data = objectValue(value)
183-  if (!data) return openAIConfig({})
184-
185-  const reviewer = objectValue(data.reviewer)
186-  if (reviewer) {
187-    const kind = reviewer.kind
188-    if (kind === "openai") return openAIConfig(reviewer)
189-    if (kind === "llama.cpp") return llamaConfig(reviewer)
190-    if (kind === "openai-compatible") return compatibleConfig(reviewer, "reviewer")
191-    throw new Error('reviewer.kind must be "openai", "llama.cpp", or "openai-compatible"')
192+  const reviewer = data && objectValue(data.reviewer)
193+  if (!reviewer) {
194+    return {
195+      kind: "pi",
196+      provider: "openai-codex",
197+      model: "gpt-5.6-luna",
198+      reasoningEffort: "minimal",
199+    }
200+  }
201diff --git a/src/core/providers.ts b/src/core/providers.ts
202index aecf623ff47a23d50dce4099355aaadbd37b7a76..52c526e50e2bcae2e6a7534dc7b20989e93b56ce 100644
203--- a/src/core/providers.ts
204+++ b/src/core/providers.ts
205@@ -1,12 +1,6 @@
206+import type { ChatMessage } from "./review"
207 import type { ReviewerConfig } from "./types"
208 
209-export type ChatMessage = {
210-  role: "system" | "user"
211-  content: string
212-}
213-
214-export type ApiKeyResolver = (sessionId?: string) => string
215-
216 type LlamaResponse = {
217   choices?: {
218     message?: {
219@@ -19,103 +13,39 @@ function textValue(value: unknown): string | undefined {
220   return typeof value === "string" && value.trim() ? value : undefined
221 }
222 
223-function llamaResponseText(data: LlamaResponse): string | undefined {
224-  const choice = data.choices?.[0]
225-  return textValue(choice?.message?.content)
226-}
227-
228 function completionUrl(baseUrl: string): string {
229   const normalized = baseUrl.replace(/\/+$/, "")
230   const root = normalized.endsWith("/v1") ? normalized : `${normalized}/v1`
231   return `${root}/chat/completions`
232 }
233 
234-async function responseText(response: Response, name: string): Promise<string> {
235-  if (!response.ok) throw new Error(`${name} API ${response.status}`)
236-  const data = await response.json() as { choices?: { message?: { content?: string | null } }[] }
237-  return data.choices?.[0]?.message?.content ?? ""
238-}
239-
240-async function callOpenAI(
241-  baseUrl: string,
242-  model: string,
243-  messages: ChatMessage[],
244-  maxTokens: number,
245-  apiKey?: string,
246-  reasoningEffort?: "none" | "low" | "medium" | "high" | "xhigh" | "max",
247-): Promise<string> {
248-  const response = await fetch(completionUrl(baseUrl), {
249-    method: "POST",
250-    headers: {
251-      "content-type": "application/json",
252-      ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
253-    },
254-    body: JSON.stringify({
255-      model,
256-      max_completion_tokens: maxTokens,
257-      messages,
258-      temperature: 0,
259-      ...(reasoningEffort && { reasoning_effort: reasoningEffort }),
260-    }),
261-  })
262-  return responseText(response, "OpenAI")
263+async function responseError(response: Response): Promise<never> {
264+  const detail = (await response.text()).trim().slice(0, 1000)
265+  throw new Error(`llama-server API ${response.status}${detail ? `: ${detail}` : ""}`)
266 }
267 
268-async function callLlama(
269-  baseUrl: string,
270-  model: string,
271+export async function callLlamaReviewer(
272+  config: Extract<ReviewerConfig, { kind: "llama.cpp" }>,
273   messages: ChatMessage[],
274   maxTokens: number,
275-  enableThinking: boolean,
276-  reasoningFormat: "none" | "deepseek" | "deepseek-legacy",
277 ): Promise<string> {
278-  const response = await fetch(completionUrl(baseUrl), {
279+  const response = await fetch(completionUrl(config.baseUrl), {
280     method: "POST",
281     headers: { "content-type": "application/json" },
282     body: JSON.stringify({
283-      model,
284+      model: config.model,
285       max_tokens: maxTokens,
286       messages,
287       stream: false,
288       temperature: 0,
289       response_format: { type: "json_object" },
290-      chat_template_kwargs: { enable_thinking: enableThinking },
291-      reasoning_format: reasoningFormat,
292+      chat_template_kwargs: { enable_thinking: config.enableThinking },
293+      reasoning_format: config.reasoningFormat,
294     }),
295   })
296-  if (!response.ok) throw new Error(`llama-server API ${response.status}`)
297-  const text = llamaResponseText(await response.json() as LlamaResponse)
298+  if (!response.ok) return responseError(response)
299+  const data = await response.json() as LlamaResponse
300+  const text = textValue(data.choices?.[0]?.message?.content)
301   if (!text) throw new Error("llama-server response had no generated text")
302   return text
303 }
304-
305diff --git a/src/core/review.ts b/src/core/review.ts
306index 5c10e3d6a2fda2a118130c72eda9a4cc5ee4fc7e..0747e1b142d86dc4ff924481e4bf81f9fc71634d 100644
307--- a/src/core/review.ts
308+++ b/src/core/review.ts
309@@ -1,5 +1,5 @@
310+import { callLlamaReviewer } from "./providers"
311 import { llmPolicyPrompt } from "./rules"
312-import { callReviewer, type ApiKeyResolver } from "./providers"
313 import {
314   isDecisionCategory,
315   type Decision,
316@@ -10,6 +10,18 @@ import {
317   type ReviewerConfig,
318 } from "./types"
319 
320+export type ChatMessage = {
321+  role: "system" | "user"
322+  content: string
323+}
324+
325+export type PiReviewerCaller = (
326+  config: Extract<ReviewerConfig, { kind: "pi" }>,
327+  messages: ChatMessage[],
328+  maxTokens: number,
329+  context: PolicyContext,
330+) => Promise<string>
331+
332 const ASK_FALLBACK: Decision = {
333   decision: "ask",
334   reason: "Policy engine error",
335@@ -35,6 +47,10 @@ export function parseLLMResponse(content: string): Decision | undefined {
336   }
337 }
338 
339+function errorReason(error: unknown): string {
340+  return `Policy engine error: ${error instanceof Error ? error.message : String(error)}`
341+}
342+
343 function reviewRequest(request: PolicyRequest, context: PolicyContext): string {
344   return `<tool_request>\n${JSON.stringify({
345     cwd: context.cwd,
346@@ -45,22 +61,19 @@ function reviewRequest(request: PolicyRequest, context: PolicyContext): string {
347 
348 export function createPolicyReviewer(
349   config: ReviewerConfig,
350-  resolveApiKey: ApiKeyResolver,
351+  callPiReviewer: PiReviewerCaller,
352   externalDirectories?: readonly string[],
353 ): PolicyReviewer {
354   return {
355     async evaluate(request: PolicyRequest, context: PolicyContext): Promise<LLMEvaluationResult> {
356       try {
357-        const rawResponse = await callReviewer(
358-          config,
359-          [
360-            { role: "system", content: llmPolicyPrompt(externalDirectories) },
361-            { role: "user", content: reviewRequest(request, context) },
362-          ],
363-          512,
364-          context.sessionId,
365-          resolveApiKey,
366-        )
367+        const messages = [
368+          { role: "system" as const, content: llmPolicyPrompt(externalDirectories) },
369+          { role: "user" as const, content: reviewRequest(request, context) },
370+        ]
371+        const rawResponse = config.kind === "pi"
372+          ? await callPiReviewer(config, messages, 512, context)
373+          : await callLlamaReviewer(config, messages, 512)
374         const decision = parseLLMResponse(rawResponse)
375         if (decision) return { decision, rawResponse }
376         return {
377@@ -68,10 +81,11 @@ export function createPolicyReviewer(
378           rawResponse,
379           error: "Failed to parse response JSON",
380         }
381-      } catch {
382+      } catch (error) {
383+        const reason = errorReason(error)
384         return {
385-          decision: ASK_FALLBACK,
386-          error: "Policy engine error",
387+          decision: { ...ASK_FALLBACK, reason },
388+          error: reason,
389         }
390       }
391     },
392diff --git a/src/core/rules.ts b/src/core/rules.ts
393index 2a04b35b333d744b5f73cb61c12d5c5afeed36fe..da132e85e378f9282c8fe34bbf5bb5eedd35483f 100644
394--- a/src/core/rules.ts
395+++ b/src/core/rules.ts
396@@ -114,8 +114,7 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
397   /^(ba)?sh\s+-n(?:\s+\S+)*\s*$/,
398 ];
399 
400-// Broader prefix patterns ported from the OpenCode config.
401-// Still guarded by SHELL_CONTROL_RE (no >, <, ${).
402+// Broader prefix patterns remain guarded by SHELL_CONTROL_RE (no >, <, ${).
403 export const CONFIG_ALLOW_PATTERNS: RegExp[] = [
404   // Tools without stricter HARD_ALLOW equivalents
405   /^echo\s/,
406diff --git a/src/core/types.ts b/src/core/types.ts
407index 7acf2fe6c34f03073af6228d2e740b22c8548965..f7161620a65fa476168a042a4591fcac863ceadd 100644
408--- a/src/core/types.ts
409+++ b/src/core/types.ts
410@@ -37,22 +37,6 @@ export type Decision = {
411   category: DecisionCategory
412 }
413 
414-export type PolicyRequest = {
415-  toolName: string
416-  input: Record<string, unknown>
417-}
418-
419-export type PolicyContext = {
420-  sessionId?: string
421-  cwd?: string
422-  signal?: AbortSignal
423-}
424-
425-export type PolicyPrecheck = {
426-  source: "session" | "static"
427-  decide(request: PolicyRequest, context: PolicyContext): Promise<Decision | undefined>
428-}
429-
430 export type LLMEvaluationResult = {
431   decision: Decision
432   rawResponse?: string
433@@ -63,11 +47,16 @@ export type PolicyReviewer = {
434   evaluate(request: PolicyRequest, context: PolicyContext): Promise<LLMEvaluationResult>
435 }
436 
437+export type PiReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
438 export type LlamaReasoningFormat = "none" | "deepseek" | "deepseek-legacy"
439-export type OpenAIReasoningEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max"
440 
441 export type ReviewerConfig =
442-  | { kind: "openai"; model: string; reasoningEffort?: OpenAIReasoningEffort }
443+  | {
444+    kind: "pi"
445+    provider: string
446+    model: string
447+    reasoningEffort?: PiReasoningEffort
448+  }
449   | {
450     kind: "llama.cpp"
451     baseUrl: string
452@@ -75,7 +64,6 @@ export type ReviewerConfig =
453     enableThinking: boolean
454     reasoningFormat: LlamaReasoningFormat
455   }
456-  | { kind: "openai-compatible"; baseUrl: string; model: string; apiKeyEnv?: string }
457 
458 export type CacheEntry = {
459   key: string
460@@ -113,3 +101,19 @@ export type PolicyEvaluation = {
461   error?: string
462   approvalRequests?: PolicyRequest[]
463 }
464+
465+export type PolicyRequest = {
466+  toolName: string
467+  input: Record<string, unknown>
468+}
469+
470+export type PolicyContext = {
471+  sessionId?: string
472+  cwd?: string
473+  signal?: AbortSignal
474+}
475+
476+export type PolicyPrecheck = {
477+  source: Exclude<DecisionSource, "llm" | "cache" | "deterministic">
478+  decide(request: PolicyRequest, context: PolicyContext): Promise<Decision | undefined>
479+}
480diff --git a/src/opencode/config.ts b/src/opencode/config.ts
481deleted file mode 100644
482index fbe7283c9b343ce04da4a067d8036370d77430a6..0000000000000000000000000000000000000000
483--- a/src/opencode/config.ts
484+++ /dev/null
485@@ -1,29 +0,0 @@
486-import { existsSync, readFileSync } from "node:fs"
487-import { homedir } from "node:os"
488-import { join } from "node:path"
489-import { parseReviewerConfig } from "../core/config"
490-import type { ReviewerConfig } from "../core/types"
491-
492-export type OpenCodePolicyEngineConfig = {
493-  reviewer: ReviewerConfig
494-}
495-
496-function configPath(): string {
497-  return process.env.OPENCODE_POLICY_ENGINE_CONFIG
498-    ?? join(homedir(), ".config", "opencode", "policy-engine.json")
499-}
500-
501-export function openCodePolicyPaths(): { cacheFile: string; auditFile: string } {
502-  const directory = process.env.OPENCODE_POLICY_ENGINE_DIR
503-    ?? join(homedir(), ".config", "opencode", "policy-engine")
504-  return {
505-    cacheFile: join(directory, "cache", "decisions.jsonl"),
506-    auditFile: join(directory, "logs", "policy.jsonl"),
507-  }
508-}
509-
510-export function loadOpenCodePolicyConfig(): OpenCodePolicyEngineConfig {
511-  const path = configPath()
512-  if (!existsSync(path)) return { reviewer: parseReviewerConfig({}) }
513-  return { reviewer: parseReviewerConfig(JSON.parse(readFileSync(path, "utf8"))) }
514-}
515diff --git a/src/opencode/credentials.ts b/src/opencode/credentials.ts
516deleted file mode 100644
517index 100e16ac4d8d089163a40d8ea9f981c8f7bb582f..0000000000000000000000000000000000000000
518--- a/src/opencode/credentials.ts
519+++ /dev/null
520@@ -1,67 +0,0 @@
521-import { readFileSync } from "node:fs"
522-
523-type ProviderContextLike = {
524-  id?: unknown
525-  key?: unknown
526-  options?: Record<string, unknown>
527-  info?: {
528-    id?: unknown
529-    key?: unknown
530-    options?: Record<string, unknown>
531-  }
532-}
533-
534-const sessionKeys = new Map<string, string>()
535-
536-function stringValue(value: unknown): string | undefined {
537-  return typeof value === "string" ? value : undefined
538-}
539-
540-function usableKey(value: string | undefined): string | undefined {
541-  if (!value || value.startsWith("opencode-")) return undefined
542-  return value
543-}
544-
545-function providerKey(provider: ProviderContextLike): string | undefined {
546-  return usableKey(stringValue(provider.key))
547-    ?? usableKey(stringValue(provider.info?.key))
548-    ?? usableKey(stringValue(provider.options?.apiKey))
549-    ?? usableKey(stringValue(provider.info?.options?.apiKey))
550-}
551-
552-export function captureOpenCodeCredentials(sessionId: string, provider: unknown): void {
553-  if (!provider || typeof provider !== "object") return
554-  const context = provider as ProviderContextLike
555-  const providerId = context.id ?? context.info?.id
556-  if (providerId !== "openai") return
557-  const key = providerKey(context)
558-  if (key) sessionKeys.set(sessionId, key)
559-}
560-
561-function resolvePlaceholder(value: string): string {
562-  const trimmed = value.trim()
563-  const environment = trimmed.match(/^\{env:([^}]+)\}$/)
564-  if (environment) return process.env[environment[1]]?.trim() ?? ""
565-
566-  const file = trimmed.match(/^\{file:([^}]+)\}$/)
567-  if (file) {
568-    try {
569-      return readFileSync(file[1], "utf8").trim()
570-    } catch {
571-      return ""
572-    }
573-  }
574-  return trimmed
575-}
576-
577-export function resolveOpenCodeOpenAIKey(sessionId?: string): string {
578-  const captured = usableKey(resolvePlaceholder(sessionId ? sessionKeys.get(sessionId) ?? "" : ""))
579-  if (captured) return captured
580-  const environment = usableKey(process.env.OPENAI_API_KEY?.trim())
581-  if (environment) return environment
582-  throw new Error("No OpenAI API key available")
583-}
584-
585-export function clearOpenCodeCredentials(): void {
586-  sessionKeys.clear()
587-}
588diff --git a/src/opencode/index.ts b/src/opencode/index.ts
589deleted file mode 100644
590index 4dfae52fddb4d6e52b9f344f26b4d075abb3009f..0000000000000000000000000000000000000000
591--- a/src/opencode/index.ts
592+++ /dev/null
593@@ -1,54 +0,0 @@
594-import type { Plugin, PluginModule } from "@opencode-ai/plugin"
595-import { createJsonlDecisionAudit } from "../core/audit"
596-import { createJsonlDecisionCache } from "../core/cache"
597-import { checkDeterministic } from "../core/deterministic"
598-import { auditInputSummary, cacheKey, normalizeRequest } from "../core/normalize"
599-import { createPolicyPipeline } from "../core/pipeline"
600-import { createPolicyReviewer } from "../core/review"
601-import { loadOpenCodePolicyConfig, openCodePolicyPaths } from "./config"
602-import { captureOpenCodeCredentials, resolveOpenCodeOpenAIKey } from "./credentials"
603-import { isOpenCodePermissionAsked, openCodePolicyRequests } from "./permission-events"
604-
605-export const PolicyEngine: Plugin = async (ctx) => {
606-  const config = loadOpenCodePolicyConfig()
607-  const paths = openCodePolicyPaths()
608-  const pipeline = createPolicyPipeline({
609-    deterministic: (request) => checkDeterministic(request.toolName, request.input),
610-    cache: createJsonlDecisionCache(paths.cacheFile),
611-    audit: createJsonlDecisionAudit(paths.auditFile),
612-    reviewer: createPolicyReviewer(config.reviewer, resolveOpenCodeOpenAIKey),
613-    normalize: (request, context) => `${context.cwd ?? ""}\n${normalizeRequest(request.toolName, request.input)}`,
614-    cacheKey,
615-    inputSummary: auditInputSummary,
616-  })
617-
618-  return {
619-    "chat.params": async (input) => {
620-      captureOpenCodeCredentials(input.sessionID, input.provider)
621-    },
622-    event: async ({ event }) => {
623-      const receivedEvent: unknown = event
624-      if (!isOpenCodePermissionAsked(receivedEvent)) return
625-
626-      const requests = openCodePolicyRequests(receivedEvent)
627-      const result = await pipeline.evaluateMany(requests, {
628-        sessionId: receivedEvent.properties.sessionID,
629-        cwd: ctx.directory,
630-      })
631-      if (result.decision.decision === "ask") return
632-
633-      await ctx.client.postSessionIdPermissionsPermissionId({
634-        path: {
635-          id: receivedEvent.properties.sessionID,
636-          permissionID: receivedEvent.properties.id,
637-        },
638-        body: { response: result.decision.decision === "allow" ? "once" : "reject" },
639-      })
640-    },
641-  }
642-}
643-
644-export default {
645-  id: "policy-engine",
646-  server: PolicyEngine,
647-} satisfies PluginModule
648diff --git a/src/opencode/permission-events.ts b/src/opencode/permission-events.ts
649deleted file mode 100644
650index 7a307e85fbbcb179f9f272cf2a092c18a80c8db4..0000000000000000000000000000000000000000
651--- a/src/opencode/permission-events.ts
652+++ /dev/null
653@@ -1,43 +0,0 @@
654-import type { PolicyRequest } from "../core/types"
655-
656-export type OpenCodePermissionAsked = {
657-  type: "permission.asked"
658-  properties: {
659-    id: string
660-    sessionID: string
661-    permission: string
662-    patterns: string[]
663-    metadata: Record<string, unknown>
664-    always: string[]
665-  }
666-}
667-
668-export function isOpenCodePermissionAsked(event: unknown): event is OpenCodePermissionAsked {
669-  if (!event || typeof event !== "object") return false
670-  const record = event as { type?: unknown; properties?: unknown }
671-  if (record.type !== "permission.asked" || !record.properties || typeof record.properties !== "object") return false
672-  const properties = record.properties as Record<string, unknown>
673-  return typeof properties.id === "string"
674-    && typeof properties.sessionID === "string"
675-    && typeof properties.permission === "string"
676-    && Array.isArray(properties.patterns)
677-    && properties.patterns.every((pattern) => typeof pattern === "string")
678-}
679-
680-function inputForPattern(
681-  toolName: string,
682-  pattern: string,
683-  metadata: Record<string, unknown>,
684-): Record<string, unknown> {
685-  if (toolName === "bash") return { command: pattern }
686-  if (toolName === "webfetch") return { url: pattern }
687-  return { ...metadata, pattern }
688-}
689-
690-export function openCodePolicyRequests(event: OpenCodePermissionAsked): PolicyRequest[] {
691-  const { permission, patterns, metadata } = event.properties
692-  return patterns.map((pattern) => ({
693-    toolName: permission,
694-    input: inputForPattern(permission, pattern, metadata),
695-  }))
696-}
697diff --git a/src/pi/credentials.ts b/src/pi/credentials.ts
698deleted file mode 100644
699index 1089aa0a901115eb1cabf8e87326a9076c311210..0000000000000000000000000000000000000000
700--- a/src/pi/credentials.ts
701+++ /dev/null
702@@ -1,32 +0,0 @@
703-import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
704-
705-const sessionKeys = new Map<string, string>()
706-
707-function apiKey(value: unknown): string | undefined {
708-  if (!value || typeof value !== "object") return undefined
709-  const record = value as Record<string, unknown>
710-  if (typeof record.apiKey === "string") return record.apiKey
711-  if (typeof record.key === "string") return record.key
712-  return apiKey(record.auth) ?? apiKey(record.credentials)
713-}
714-
715-export async function capturePiCredentials(ctx: ExtensionContext): Promise<void> {
716-  try {
717-    const auth = await ctx.modelRegistry.getProviderAuth("openai")
718-    const key = apiKey(auth)
719-    if (key) sessionKeys.set(ctx.sessionManager.getSessionId(), key)
720-  } catch {
721-  }
722-}
723-
724-export function resolvePiOpenAIKey(sessionId?: string): string {
725-  const captured = sessionId ? sessionKeys.get(sessionId)?.trim() : undefined
726-  if (captured) return captured
727-  const environment = process.env.OPENAI_API_KEY?.trim()
728-  if (environment) return environment
729-  throw new Error("No OpenAI API key available")
730-}
731-
732-export function clearPiCredentials(): void {
733-  sessionKeys.clear()
734-}
735diff --git a/src/pi/index.ts b/src/pi/index.ts
736index 2aae435efbf215447ee4f73263b2d8427494fcba..0e1145053d48ec697657c9eaea14eb774628b151 100644
737--- a/src/pi/index.ts
738+++ b/src/pi/index.ts
739@@ -1,15 +1,15 @@
740-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
741+import type { UserMessage } from "@earendil-works/pi-ai"
742+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
743 import { createJsonlDecisionAudit } from "../core/audit"
744 import { createJsonlDecisionCache } from "../core/cache"
745 import { checkDeterministic } from "../core/deterministic"
746 import { auditInputSummary, cacheKey, normalizeRequest } from "../core/normalize"
747 import { createPolicyPipeline } from "../core/pipeline"
748-import { createPolicyReviewer } from "../core/review"
749+import { createPolicyReviewer, type PiReviewerCaller } from "../core/review"
750 import type { PolicyContext, PolicyEvaluation, PolicyRequest } from "../core/types"
751 import { splitBashCommand, type BashSplitResult } from "./bash-split"
752 import { loadPiPolicyConfig, piPolicyPaths } from "./config"
753 import { PolicyApprovalDialog } from "./policy-approval"
754-import { capturePiCredentials, resolvePiOpenAIKey } from "./credentials"
755 import {
756   createSessionBashAllowOverride,
757   matchesSessionBashAllowOverride,
758@@ -51,9 +51,37 @@ function bypassesPiPolicy(toolName: string): boolean {
759   return toolName === "mcp" || toolName.startsWith("mcp__")
760 }
761 
762+function piReviewerCaller(ctx: ExtensionContext): PiReviewerCaller {
763diff --git a/test/core/llm.test.ts b/test/core/llm.test.ts
764index de8857d020bb3adaf47a1ba490ac27dc0b6dc515..b04f9795e133d00c7b524f4e73545bcc69578610 100644
765--- a/test/core/llm.test.ts
766+++ b/test/core/llm.test.ts
767@@ -19,8 +19,8 @@ const reviewer =
768       model,
769       enableThinking: true,
770       reasoningFormat: "deepseek",
771-    }, () => {
772-        throw new Error("local llama.cpp reviewer does not use an API key");
773+    }, async () => {
774+        throw new Error("Pi reviewer must not be used for llama.cpp evaluation");
775       })
776     : undefined;
777 
778diff --git a/test/opencode/extension.test.ts b/test/opencode/extension.test.ts
779deleted file mode 100644
780index b2bf2fa4d72fab45d17914e0f98ec6fe47762157..0000000000000000000000000000000000000000
781--- a/test/opencode/extension.test.ts
782+++ /dev/null
783@@ -1,60 +0,0 @@
784-import { afterEach, beforeEach, describe, expect, test } from "bun:test"
785-import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
786-import { tmpdir } from "node:os"
787-import { join } from "node:path"
788-import { PolicyEngine } from "../../src/opencode/index"
789-
790-const originalConfig = process.env.OPENCODE_POLICY_ENGINE_CONFIG
791-const originalDirectory = process.env.OPENCODE_POLICY_ENGINE_DIR
792-const originalKey = process.env.OPENAI_API_KEY
793-const originalFetch = globalThis.fetch
794-let directory: string
795-
796-beforeEach(() => {
797-  directory = mkdtempSync(join(tmpdir(), "opencode-policy-engine-"))
798-  process.env.OPENCODE_POLICY_ENGINE_CONFIG = join(directory, "config.json")
799-  process.env.OPENCODE_POLICY_ENGINE_DIR = directory
800-  process.env.OPENAI_API_KEY = "test-key"
801-  writeFileSync(process.env.OPENCODE_POLICY_ENGINE_CONFIG, "{}")
802-})
803-
804-afterEach(() => {
805-  globalThis.fetch = originalFetch
806-  rmSync(directory, { recursive: true, force: true })
807-  if (originalConfig === undefined) delete process.env.OPENCODE_POLICY_ENGINE_CONFIG
808-  else process.env.OPENCODE_POLICY_ENGINE_CONFIG = originalConfig
809-  if (originalDirectory === undefined) delete process.env.OPENCODE_POLICY_ENGINE_DIR
810-  else process.env.OPENCODE_POLICY_ENGINE_DIR = originalDirectory
811-  if (originalKey === undefined) delete process.env.OPENAI_API_KEY
812-  else process.env.OPENAI_API_KEY = originalKey
813-})
814-
815-function asked(patterns: string[], id: string) {
816-  return {
817-    type: "permission.asked",
818-    properties: { id, sessionID: "session-1", permission: "bash", patterns, metadata: {}, always: [] },
819-  }
820-}
821-
822-describe("OpenCode policy plugin", () => {
823-  test("routes allow, ask, deny, and unrelated events through the host API", async () => {
824-    const replies: unknown[] = []
825-    globalThis.fetch = (async () => Response.json({
826-      choices: [{ message: { content: '{"decision":"deny","reason":"blocked","category":"dangerous"}' } }],
827-    })) as unknown as typeof fetch
828-    const plugin = await PolicyEngine({
829-      directory,
830-      client: { postSessionIdPermissionsPermissionId: async (input: unknown) => { replies.push(input) } },
831-    } as never)
832-
833-    await plugin.event?.({ event: asked(["git status"], "allow") } as never)
834-    await plugin.event?.({ event: asked(["git status", "sudo id"], "ask") } as never)
835-    await plugin.event?.({ event: asked(["unknown-command"], "deny") } as never)
836-    await plugin.event?.({ event: { type: "session.idle", properties: { sessionID: "session-1" } } } as never)
837-
838-    expect(replies).toEqual([
839-      { path: { id: "session-1", permissionID: "allow" }, body: { response: "once" } },
840-      { path: { id: "session-1", permissionID: "deny" }, body: { response: "reject" } },
841-    ])
842-  })
843-})
844diff --git a/test/pi/extension.test.ts b/test/pi/extension.test.ts
845index 6888f46910823eb663bccd20b051508380ff10e5..f791a24b825a843fc82ef8cc76caa0f9c1f04bfb 100644
846--- a/test/pi/extension.test.ts
847+++ b/test/pi/extension.test.ts
848@@ -64,7 +64,17 @@ function context(
849   return {
850     hasUI,
851     model: undefined,
852-    modelRegistry: {},
853+    modelRegistry: {
854+      find(provider: string, id: string) {
855+        return { provider, id }
856+      },
857+      async complete() {
858+        return {
859+          stopReason: "stop",
860+          content: [{ type: "text", text: '{"decision":"ask","reason":"reviewed","category":"uncertain"}' }],
861+        }
862+      },
863+    },
864     cwd,
865     sessionManager: { getSessionId: () => "test-session", getBranch: () => [] },
866     ui: { confirm, notify() {} },
867@@ -110,13 +120,6 @@ describe("Pi policy extension", () => {
868       externalDirectories: [externalDirectory],
869     }))
870 
871-    let reviews = 0
872-    let prompt = ""
873-    globalThis.fetch = (async (_input, init) => {
874-      reviews += 1
875-      prompt = JSON.parse(String(init?.body)).messages[0].content
876-      return Response.json({ choices: [{ message: { content: '{"decision":"ask","reason":"reviewed","category":"uncertain"}' } }] })
877-    }) as typeof fetch
878 
879     const extension = loadExtension()
880     await expect(extension.toolCall({ toolName: "custom_tool", input: {} }, context())).resolves.toBeUndefined()
881@@ -127,7 +130,24 @@ describe("Pi policy extension", () => {
882     await expect(extension.toolCall({ toolName: "mcp", input: {} }, context())).resolves.toBeUndefined()
883     await expect(extension.toolCall({ toolName: "read", input: { path: join(externalDirectory, "file") } }, context())).resolves.toBeUndefined()
884 
885-    const confirmed = context(true, async () => true)
886+    let reviews = 0
887+    let prompt = ""
888+    const confirmed = {
889+      ...context(true, async () => true),
890+      modelRegistry: {
891+        find(provider: string, id: string) {
892+          return { provider, id }
893+        },
894+        async complete(_model: unknown, request: { systemPrompt?: string }) {
895+          reviews += 1
896+          prompt = request.systemPrompt ?? ""
897+          return {
898+            stopReason: "stop",
899+            content: [{ type: "text", text: '{"decision":"ask","reason":"reviewed","category":"uncertain"}' }],
900+          }
901+        },
902+      },
903+    }
904     const externalFile = join(tmpdir(), `not-listed-${Date.now()}`, "file")
905     await expect(extension.toolCall({ toolName: "read", input: { path: externalFile } }, confirmed)).resolves.toBeUndefined()
906     await expect(extension.toolCall({ toolName: "read", input: { path: externalFile } }, confirmed)).resolves.toBeUndefined()
907@@ -143,24 +163,93 @@ describe("Pi policy extension", () => {
908   })
909 
910   test("shows an unparseable LLM response in the approval prompt", async () => {
911-    globalThis.fetch = (async (_input, _init) => {
912-      return Response.json({ choices: [{ message: { content: "not JSON" } }] })
913-    }) as typeof fetch
914     let message = ""
915 
916     const extension = loadExtension()
917     await extension.toolCall(
918       { toolName: "unknown_tool", input: {} },
919-      context(true, async (_title: string, value: string) => {
920-        message = value
921-        return false
922-      }),
923+      {
924+        ...context(true, async (_title: string, value: string) => {
925+          message = value
926+          return false
927+        }),
928+        modelRegistry: {
929+          find(provider: string, id: string) {
930+            return { provider, id }
931+          },
932+          async complete() {
933+            return { stopReason: "stop", content: [{ type: "text", text: "not JSON" }] }
934+          },
935+        },
936+      },
937     )
938 
939     expect(message).toContain("Unparseable LLM response")
940     expect(message).toContain("LLM response:\nnot JSON")
941   })
942 
943+  test("uses Pi model completion for Pi reviewers", async () => {
944+    writeFileSync(join(tempDir, "policy-engine.json"), JSON.stringify({
945+      reviewer: {
946+        kind: "pi",
947+        provider: "openai-codex",