This document provides a high-level overview of the opencode-forge plugin architecture, including its module layout, hook system, storage layer, and initialization flow.
See also: Loop System, Modules, API Reference.
OpenCode Forge is a dual-plugin: it exports both a server plugin (src/index.ts) and a TUI plugin (src/tui.tsx). The package declares both surfaces via the oc-plugin field in package.json.
{
"oc-plugin": ["server", "tui"]
}| Export Path | Source File | Role |
|---|---|---|
. / ./server |
src/index.ts |
Server-side plugin: hooks, tools, agents, config |
./tui |
src/tui.tsx |
TUI plugin: sidebar, plan viewer, execution panel |
The server plugin is the core of the plugin. It:
- Initializes services (database, loop runtime, sandbox manager)
- Registers tools for OpenCode to use
- Registers hooks for session management and event handling
- Manages the lifecycle of loops and sandbox containers
Plugin boot does not reconcile, recover, cancel, or restart any persisted loops. See No boot-time loop recovery and the Loop Lifecycle Rules for details.
Key exports:
createForgePlugin(config: PluginConfig): Plugin- Factory functioncreateParentSessionLookup(options)- Resolves parent sessions across worktreescreateSessionDirectoryLookup(options)- Resolves session directory across worktreesPluginConfig,CompactionConfig- Configuration typesVERSION- Plugin version
Each opencode attach --dir <worktree> invokes createForgePlugin once for that project, even when clients share the same opencode serve process.
- Storage remains project-keyed (SQLite rows include
projectId), so no schema changes are required for multi-project isolation. - Sandbox orphan cleanup is aware of all active worktrees before container cleanup.
The TUI plugin provides a sidebar widget that displays:
- Active and recent loops
- Plan viewer with inline editing (view/edit/execute/export tabs)
- Execution dialog with mode, model, and variant selection
- Loop details dialog with session statistics
- Command palette integration (
Show loops,View plan,Execute plan) - Model selection dialog with recent model tracking
The TUI communicates with the server via RPC over the opencode bus using tui.command.execute events.
The codebase is organized into these module groups under src/:
| Module | Purpose | Key Files |
|---|---|---|
agents/ |
AI agent definitions (code, architect, auditor + auditor-loop variant) | index.ts, code.ts, architect.ts, auditor.ts |
hooks/ |
Plugin event/lifecycle hooks (session, loop events, plan capture, plan approval, watchdog, sandbox, forge-session-attach, loop-permission, host-side-effects) | index.ts, session.ts, loop.ts, plan-capture.ts, plan-approval.ts, watchdog.ts, sandbox-tools.ts, forge-session-attach.ts, loop-permission.ts, host-side-effects.ts |
loop/ |
Core loop state machine and runtime | runtime.ts, service.ts, state.ts, transitions.ts, prompts.ts, restartability.ts, in-flight-guard.ts, token-usage.ts, name-uniqueness.ts |
services/ |
Higher-level orchestration services | execution.ts, session-loop-resolver.ts, deterministic-decomposer.ts, plan-capture.ts, worktree-log.ts |
sandbox/ |
sbx sandbox management | sbx.ts, manager.ts, context.ts, reconcile.ts |
storage/ |
SQLite persistence layer (repos + migrations) | database.ts, repos/*.ts, migrations/*.sql |
tools/ |
Plugin tools callable by AI agents | loop.ts, review.ts, plan-kv.ts, section-read.ts |
workspace/ |
Git worktree / workspace management | forge-adapter.ts, forge-worktree.ts, pending-teardown.ts, classify-stale.ts, remove-with-context.ts, sweep-stale.ts |
utils/ |
Shared utility modules (~25 files) | logger.ts, lru-cache.ts, model-fallback.ts, etc. |
tui/ |
TUI-specific components | execute-plan-panel.tsx |
All external consumers import through barrel files (index.ts) where available. See Modules for full details.
The loop system provides autonomous iterative development with automatic auditing.
See loop-system.md for detailed documentation.
- Loop Runtime (
src/loop/runtime.ts) - Factory for creating Loop instances (createLoop()returns aLoopinterface with ~50 methods) - Loop Service (
src/loop/service.ts) - State management for loops (DB-backed via SQLite) - State Machine (
src/loop/state.ts) - Discriminated unionLoopStatewith 4 phases:coding,auditing,final_auditing,post_action - Transition Table (
src/loop/transitions.ts) - PurenextTransition()function for phase transitions - Termination (
src/loop/termination.ts) - Termination reason mapping and status checks - Prompts (
src/loop/prompts.ts) - Prompt builders for each loop phase (continuation, audit, section) - Idle Gate (
src/loop/idle-gate.ts) - Session busy detection and timeout tracking - Section Summary (
src/loop/section-summary.ts) - Parse audit output markers - LoopEventHandler (
src/hooks/loop.ts) - Event handling, session rotation, watchdog integration
Sandbox is optional. When the sbx daemon is available and sandbox.mode = 'sbx' is configured, a sandbox is provisioned automatically; otherwise loops run in worktree-only mode.
- SandboxRuntime (
sandbox/sbx.ts) -sbxCLI facade (create/exec/remove/list, availability probe) - SandboxManager (
sandbox/manager.ts) - Sandbox lifecycle management - SandboxContext (
sandbox/context.ts) - Tool call redirection - SandboxTools (
hooks/sandbox-tools.ts) - Hooks for sandbox integration
- When a sandbox loop starts, an
sbxsandbox is created - The worktree directory is mounted at its identical host path inside the sandbox
bashruns inside the sandbox;globandgrepresults are produced inside the sandbox- File operations (
read,write,edit) operate on the host directly - On loop completion, the sandbox is stopped and removed
bash and the search tools reach the sandbox through two different mechanisms:
bashis redirected out of band, not through a tool hook. Theconfighook pointscfg.shellat theforge-shellshim (sandbox/shell-shim.ts) and theshell.envhook injectsFORGE_SANDBOX_CONTAINER. The shimexecssbx exec -w "$PWD" <container> bash "$@". Tool arguments are never rewritten.globandgrepuse output replacement.tool.execute.beforeruns the equivalentrgcommand inside the container and stores the result bycallID;tool.execute.afteroverwritesoutput.outputwith it. Becausetool.execute.beforecannot cancel a tool call, the native host search still executes and its result is discarded. The before-hook rejects absolute paths outside the sandbox mounts, so that host execution stays confined to the mounted worktree.
OpenCode Forge integrates with OpenCode through several hook points. The plugin returns a standard Hooks object.
chat.message- Inject memory into context, handle session eventsexperimental.session.compacting- Custom compaction behavior for session continuity
experimental.chat.messages.transform- Appends a compact<system-reminder>to the last user message in interactive architect sessions, reinforcing stored-plan completion, warning-free structure, and canonical approval dispatch. Agent permissions separately deny filesystem mutation tools andtaskwhile retaining Bash for read-only inspection plusplan-read,plan-write, andplan-edit; the autonomous architect also cannot invoke execution, loop, or group tools.
tool.execute.before- Sandbox tool redirection, logging (src/hooks/sandbox-tools.ts)tool.execute.after- Sandbox cleanup and output capture (src/hooks/sandbox-tools.ts)
Loops are autonomous and cannot answer permission prompts, but OpenCode's default subagent ruleset falls back to ask for most tools. To prevent deadlocks, createLoopPermissionRejectHook listens for session.created events. When the new session resolves to an active loop, the hook calls v2.session.update() to overwrite the child session's permission ruleset:
- If the parent session has an allow-all ruleset (e.g. an auditor subagent), the parent's ruleset is inherited so the child stays under the same constraints.
- Otherwise the default loop ruleset from
buildLoopPermissionRuleset()(src/constants/loop.ts) is applied — blanket allow-all inside the worktree, with explicit structural denies forexternal_directory,review-write,review-delete,plan,plan_enter,plan_exit,plan-write,plan-edit,execute-plan,execute-goal,question,loop-cancel,loop-status,launch-group,group-status,group-cancel. User-configuredloop.permissionsrules are layered in after the external-directory allows and before these structural denies (viaresolveLoopPermissionOptions), so they can tailor user tools without overriding a structural deny.
A PATCHED_SESSIONS set deduplicates retries. Audit-only subagents use the stricter buildAuditSessionPermissionRuleset() (blanket allow-all with structural denies for the direct mutation tools edit/write/multiedit/apply_patch, plus the shared plan/loop structural denies).
event- Handle server lifecycle events (e.g.,server.instance.disposed)- Plan approval events via
createPlanApprovalEventHook - Plan capture from streaming message parts and on assistant message completion via
createPlanCaptureEventHook
- Plan Capture (
src/hooks/plan-capture.ts) - Captures the session plan of record. The primary authoring path is theplan-write/plan-edittools, which write directly to the session-scopedplansrow. Marker capture of<!-- forge-plan:start -->...end-->from assistant messages is the fallback path and runs both on streamingmessage.part.updatedevents and on assistant message completion (message.updated). - Forge Session Attach (
src/hooks/forge-session-attach.ts) - Automatically attaches loops when new sessions are created - Watchdog (
src/hooks/watchdog.ts) - Stall detection and recovery for loops
OpenCode Forge uses bun:sqlite for all data persistence. The storage layer is organized into:
initializeDatabase(dataDir, options)- Creates SQLite DB in the data directorycloseDatabase()- Closes database connections on shutdownresolveDataDir()- Resolves platform-appropriate data directory (~/.local/share/opencode/forge)- Migrations are registered explicitly in execution order (ids 100-143; not every id ships a SQL file) and tracked in a
migrationstable
All data access goes through typed repository interfaces created via factory functions:
| Repository | Purpose | Key Types |
|---|---|---|
LoopsRepo |
CRUD for loop rows | LoopRow, LoopLargeFields |
PlansRepo |
CRUD for plans (session-scoped plan of record read by plan-read, the approval hook, execute-plan, and the TUI plan dialog) |
PlanRow, PlansRepo |
ReviewFindingsRepo |
CRUD for review findings | ReviewFindingRow, ReviewFindingsRepo |
SectionPlansRepo |
CRUD for milestone (section) plans used in decomposed loops | SectionPlanRow, SectionPlansRepo |
LoopTransitionsRepo |
Append-only loop phase-transition log | LoopTransitionRow |
PlanAmendmentsRepo |
Append-only audit trail of mid-loop plan amendments | PlanAmendmentRow |
LoopSessionUsageRepo |
Per-session token/cost usage across rotated loop sessions | LoopSessionUsageRow, LoopUsageAggregate |
TuiPrefsRepo |
TUI preferences persistence | TuiPrefsRepo |
Each repository is project-scoped via projectId parameter.
Plugin configuration is stored at ~/.config/opencode/forge-config.jsonc (JSONC format). On first run, a bundled default config is copied if none exists.
The plugin follows this initialization sequence within createForgePlugin():
- Logger - Always first (
createLogger()) - v2 Client - Create OpenCode v2 SDK client for API calls
- Sandbox Manager - sbx sandbox management (optional, fails gracefully)
- Pending Teardown Registry - Track worktree teardown contexts
- Workspace Status Registry - Track workspace connected/disconnected state
- Workspace Adapter - Register forge workspace adapter if experimental workspace API available
- Database - Initialize SQLite storage (
initializeDatabase()) - Repositories - Create typed repos (loops, plans, reviewFindings, sectionPlans, loopSessionUsage)
- Loop Event Handler - Connect loop runtime to events and state management
- Tools and Agents - Register all tools (
createTools()) and agents (buildAgents()) - Hooks - Final registration of all hook points
Plugin initialization does not recover, cancel, or restart loops. Boot initializes storage and runtime services only. Loop continuation requires explicit user intent via loop-status name=<loop> restart=true (optionally force=true for a running loop). Stale forge workspaces are reclaimed by an opportunistic sweep on loop teardown (see src/workspace/sweep-stale.ts), not at boot. See Loop Lifecycle Rules for the full restartability contract.
On plugin shutdown (server.instance.disposed event):
- Stop all active sandboxes
- Terminate all active loops
- Clear retry timeouts
- Close database connections
graph TD
TUI["TUI Plugin (tui.tsx)"] --> RPC["RPC Bus"]
RPC --> Server["Server Plugin (index.ts)"]
subgraph Server
Hooks["Hook System"] --> LoopHandler["Loop Event Handler"]
Hooks --> SessionHooks["Session Hooks"]
Hooks --> ToolHooks["Tool Execution Hooks"]
LoopHandler --> LoopRuntime["Loop Runtime"]
LoopRuntime --> LoopService["Loop Service"]
LoopService --> SQLite["SQLite Storage"]
Tools["Tool Registry"] --> LoopTools["Loop Tools"]
Tools --> ReviewTools["Review Tools"]
Tools --> PlanTools["Plan Tools"]
end
LoopRuntime --> SandboxManager["Sandbox Manager"]
SandboxManager --> Sbx["sbx Sandbox"]
SQLite --> LoopsRepo["Loops Repo"]
SQLite --> PlansRepo["Plans Repo"]
SQLite --> ReviewRepo["Review Findings Repo"]