How PAI-OpenCode implements the Personal AI Infrastructure using OpenCode's plugin architecture
Architecture Decision: ADR-001 - Hooks → Plugins Architecture
OpenCode uses a plugin system to extend functionality, while Claude Code uses hooks. Both achieve the same goals—context injection, security validation, lifecycle management—but with different implementation patterns.
PAI-OpenCode implements all PAI functionality in a single unified plugin (plugins/pai-unified.ts) that handles multiple lifecycle events.
| Aspect | Claude Code (Hooks) | OpenCode (Plugins) |
|---|---|---|
| Architecture | Multiple separate hook files | Single unified plugin file |
| Execution | Subprocess (Bun + exit codes) | In-process (TypeScript function) |
| Blocking | exit(2) blocks execution |
throw Error() blocks execution |
| Communication | JSON via stdout | Direct function return |
| Configuration | .claude/hooks/ directory |
opencode.json plugins array |
| Logging | stdout/stderr | File logging (/tmp/pai-opencode-debug.log) |
OpenCode plugins subscribe to events (lifecycle hooks). The PAI unified plugin uses these:
Event: experimental.chat.system.transform
Purpose: Inject CORE skill context at session start
Equivalent to: load-core-context.ts hook
"experimental.chat.system.transform": async (input, output) => {
const result = await loadContext();
if (result.success && result.context) {
output.system.push(result.context);
}
}What it does:
- Reads
skills/CORE/SKILL.md - Loads system documentation (
SYSTEM/*.md) - Loads user context (
USER/TELOS/*.md) - Injects combined context into chat system
Event: tool.execute.before
Purpose: Block dangerous commands before execution
Equivalent to: security-validator.ts hook
"tool.execute.before": async (input, output) => {
const result = await validateSecurity({
tool: input.tool,
args: output.args
});
if (result.action === "block") {
throw new Error(`[PAI Security] ${result.reason}`);
}
}Blocking behavior:
throw Error()→ Command blocked (hard stop)- Return normally → Command allowed
Event: tool.execute.after
Purpose: Capture tool execution for learning and observability
"tool.execute.after": async (input, output) => {
fileLog(`Tool completed: ${input.tool}`);
// Future: Learning capture, event logging
}import type { Plugin, Hooks } from "@opencode-ai/plugin";
export const MyPlugin: Plugin = async (ctx) => {
const hooks: Hooks = {
// Event handlers here
};
return hooks;
};
export default MyPlugin;export interface Hooks {
// Universal event subscriber — all 16+ Bus events
event?: (input: { event: BusEvent }) => Promise<void>
// Custom tools added to AI toolkit
tool?: { [key: string]: ToolDefinition }
// Provider authentication (Copilot, Codex, etc.)
auth?: AuthHook
// Inject env vars into EVERY bash call (stateless shell fix)
"shell.env"?: (input: ShellEnvInput, output: ShellEnvOutput) => Promise<void>
// Intercept tools before execution (can block with throw)
"tool.execute.before"?: (input, output) => Promise<void>
// React after tool execution
"tool.execute.after"?: (input, output) => Promise<void>
// Modify tool descriptions sent to LLM
"tool.definition"?: (input, output) => Promise<void>
// Override permission decisions
"permission.ask"?: (info, output) => Promise<void>
// Modify LLM parameters (temperature, max tokens, etc.)
"chat.parameters"?: (input, output) => Promise<void>
}| Event | Payload | PAI Usage |
|---|---|---|
session.created |
{ info: { id, title, directory } } |
Work session start |
session.updated |
{ info: { title } } |
Title tracking |
session.error |
{ error, sessionID } |
Error diagnostics |
session.compacted |
— | 🔴 CRITICAL: Learning rescue |
message.updated |
message data | Sentiment, ISC validation |
tool.execute.before |
tool, args | Security validation |
tool.execute.after |
tool, result | PRD sync, observability |
file.edited |
filepath, diff | PRD auto-sync |
file.watcher.updated |
filepath, event | External change detection |
command.executed |
name, arguments | /command tracking |
permission.asked |
id, permission, patterns | Full audit log |
permission.replied |
— | Response tracking |
lsp.client.diagnostics |
diagnostics | Code error detection |
installation.update.available |
version | Update notification |
tui.prompt.append |
text | TUI injection |
pty.created/updated/exited |
pty data | Terminal events |
Architecture Decision: ADR-010 - Shell.env Two-Layer System
OpenCode Bash is stateless — every call spawns a fresh process. The shell.env hook runs before EACH bash call and injects context:
"shell.env": async (input, output) => {
output.env = output.env || {};
// Runtime context (computed per call — not in .env)
output.env["PAI_CONTEXT"] = "1";
output.env["PAI_SESSION_ID"] = input.sessionID ?? "unknown";
output.env["PAI_WORK_DIR"] = input.cwd ?? "";
output.env["PAI_VERSION"] = "3.0";
// Explicit passthrough for bash scripts that need these keys
// API keys come from .opencode/.env → process.env (Bun auto-loads)
const PASSTHROUGH_KEYS = ["GOOGLE_API_KEY", "TTS_PROVIDER", "DA", "TIME_ZONE"];
for (const key of PASSTHROUGH_KEYS) {
if (process.env[key]) output.env[key] = process.env[key];
}
}Two-layer env system:
.envlayer: API keys → Bun loads at startup →process.env→ TypeScript code reads directlyshell.envlayer: Runtime context + selected passthrough → each bash child process
Architecture Decision: ADR-004 - Plugin Logging (File-Based)
NEVER use console.log() in plugins! It corrupts the TUI.
import { fileLog, fileLogError } from "./lib/file-logger";
fileLog("Normal message");
fileLog("Warning message", "warn");
fileLog("Error message", "error");
fileLogError("Error with stack trace", error);Logs appear in: /tmp/pai-opencode-debug.log
Add to opencode.json:
{
"plugins": [
".opencode/plugins/pai-unified.ts",
".opencode/plugins/my-custom-plugin.ts"
]
}Plugins should fail-open for usability:
try {
const result = await validateSecurity(input);
if (result.action === "block") {
throw new Error("Blocked");
}
} catch (error) {
// Log error but don't block on plugin failure
fileLogError("Validation failed", error);
// Continue execution
}Exception: Security validation should fail-closed (block on error) when validating commands.
Always validate input before processing:
const tool = input.tool || "unknown";
const args = output.args ?? {}; // Args are in OUTPUT, not input!
if (!tool || typeof tool !== "string") {
fileLog("Invalid tool input", "warn");
return;
}Quirk: Tool arguments are in output.args, not input.args.
"tool.execute.before": async (input, output) => {
// WRONG: input.args (doesn't exist)
// RIGHT: output.args
const command = output.args?.command;
}Some hooks are marked experimental.* and may change:
experimental.chat.system.transform(context injection)
Always check OpenCode release notes when updating.
# Start OpenCode
opencode
# Tail the debug log in another terminal
tail -f /tmp/pai-opencode-debug.logOpenCode reloads plugins on each session start:
- Edit plugin file
- Exit OpenCode (
Ctrl+C) - Restart OpenCode
- Check debug log for errors
// Add debug logging
fileLog(`DEBUG: input = ${JSON.stringify(input)}`, "debug");
fileLog(`DEBUG: output = ${JSON.stringify(output)}`, "debug");Check /tmp/pai-opencode-debug.log:
cat /tmp/pai-opencode-debug.log | grep DEBUGPAI-OpenCode uses one plugin for all functionality with 25 handlers:
plugins/
├── pai-unified.ts # Main plugin — all hooks + event routing
├── handlers/
│ │
│ ├── ── CORE ──
│ ├── context-loader.ts # Context injection at session start
│ ├── security-validator.ts # Security validation before commands
│ │
│ ├── ── LEARNING ──
│ ├── rating-capture.ts # User rating capture (1-10)
│ ├── isc-validator.ts # ISC criteria validation
│ ├── learning-capture.ts # Learning to MEMORY/LEARNING/
│ ├── last-response-cache.ts # Cache last assistant response [WP-A]
│ │
│ ├── ── OBSERVABILITY ──
│ ├── work-tracker.ts # Work session tracking
│ ├── agent-capture.ts # Agent output capture
│ ├── response-capture.ts # ISC tracking + learning
│ ├── observability-emitter.ts # Fire-and-forget event emission [v1.2]
│ │
│ ├── ── SESSION LIFECYCLE ──
│ ├── session-cleanup.ts # Mark COMPLETED, clear state [WP-A]
│ ├── prd-sync.ts # Sync PRD frontmatter → registry [WP-A]
│ ├── question-tracking.ts # Record AskUserQuestion Q&A [WP-A]
│ ├── relationship-memory.ts # Extract W/B/O notes → MEMORY/ [WP-A]
│ │
│ ├── ── UX ──
│ ├── voice-notification.ts # TTS (ElevenLabs/Google/macOS) [v1.1]
│ ├── implicit-sentiment.ts # Sentiment detection [v1.1]
│ ├── tab-state.ts # Kitty terminal tab updates [v1.1]
│ ├── update-counts.ts # Skill/workflow counting [v1.1]
│ │
│ ├── ── MAINTENANCE ──
│ ├── skill-restore.ts # Skill context restore
│ ├── check-version.ts # GitHub release update check [v2.0]
│ ├── integrity-check.ts # System health validation [v2.0]
│ │
│ └── ── ALGORITHM ──
│ ├── algorithm-tracker.ts # Phase & ISC tracking [v2.0]
│ ├── format-reminder.ts # 8-tier effort level detection [v2.0]
│ ├── agent-execution-guard.ts # Agent pattern validation [v2.0]
│ └── skill-guard.ts # Skill invocation validation [v2.0]
│
├── adapters/
│ └── types.ts # Shared type definitions
└── lib/
├── file-logger.ts # Logging utilities (NEVER console.log!)
├── paths.ts # Path resolution
├── identity.ts # User/AI identity
├── time.ts # Timestamp utilities [v1.1]
├── model-config.ts # Model configuration
└── learning-utils.ts # Learning capture helpers
Why unified?
- Single configuration point in
opencode.json - Shared state between handlers (
sessionUserMessages,sessionAssistantMessages) - All 16 Bus events handled in one place
- Easier to reason about execution order
| Category | Handlers | Key Events |
|---|---|---|
| Core | context-loader, security-validator | experimental.chat.system.transform, tool.execute.before |
| Learning | rating-capture, learning-capture, isc-validator, last-response-cache | message.updated |
| Observability | work-tracker, agent-capture, response-capture, observability-emitter | tool.execute.after, message.updated |
| Session Lifecycle | session-cleanup, prd-sync, question-tracking, relationship-memory | session.ended, session.compacted, tool.execute.after |
| UX | voice-notification, tab-state, implicit-sentiment | message.updated, session.created |
| Maintenance | skill-restore, update-counts, check-version, integrity-check | session.ended |
| Algorithm | algorithm-tracker, format-reminder, agent-execution-guard, skill-guard | tool.execute.before/after |
| Hook | Purpose | Status |
|---|---|---|
event |
Routes all 16 Bus events to handlers | ✅ Active |
tool.execute.before |
Security validation, guard checks | ✅ Active |
experimental.chat.system.transform |
Context injection | ✅ Active |
shell.env |
PAI context + key passthrough per bash call | ✅ Active (WP-A) |
If you have custom Claude Code hooks:
| Hook File | Plugin Event | Handler |
|---|---|---|
load-core-context.ts |
experimental.chat.system.transform |
context-loader.ts |
security-validator.ts |
tool.execute.before |
security-validator.ts |
initialize-session.ts |
event (session.created) |
Event handler |
stop-hook.ts |
event (session.ended) |
Event handler |
Process:
- Extract logic from hook file
- Create handler in
plugins/handlers/ - Add event subscription to
pai-unified.ts - Convert exit codes to throw/return
- Replace console.log with fileLog
See MIGRATION.md for detailed converter tool usage.
- PAI-ADAPTATIONS.md - What we changed from vanilla PAI 2.4
- MIGRATION.md - Migrating from Claude Code PAI
Provides TTS feedback for task completion and events.
Backends (in priority order):
- ElevenLabs via Voice Server -
localhost:8888/notify - Google Cloud TTS - Requires credentials
- macOS
say- Automatic fallback
Usage in skills:
curl -s -X POST http://localhost:8888/notify \
-H "Content-Type: application/json" \
-d '{"message": "Task completed", "voice_id": "..."}'Detects user satisfaction from natural language without explicit ratings.
How it works:
- Analyzes user messages using Haiku inference (fast, cheap)
- Detects positive/negative sentiment
- Captures low satisfaction (<6) as learning opportunities
Example triggers:
- "This is exactly what I needed" → High satisfaction
- "That's not what I asked for" → Low satisfaction
Updates Kitty terminal tab title and color based on task context.
Features:
- Subject-first summaries (e.g., "Auth bug fixed")
- Color coding by task type
- Graceful fallback if not using Kitty
Counts system components at session end.
What it counts:
- Skills
- Workflows
- Plugins
- Signals
- Files
Updates settings.json with current counts.
Tracks ISC criteria satisfaction and captures learnings.
Features:
- Extracts ISC criteria from responses
- Tracks satisfaction per criterion
- Updates THREAD.md for work items
- Captures learnings to MEMORY/LEARNING/
PAI-OpenCode v2.0 — 20 Handlers, Full PAI v3.0 Algorithm (v1.8.0), 8-Tier Effort Levels, Wisdom Frames, Verify Completion Gate

