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 plugin package: it exports 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, execution dialog, loop restart dialog |
src/index.ts default-exports the OpenCode 2.x module (id + setup) built with define from @opencode/plugin/promise/plugin; src/tui.tsx exports { id, setup } for the V2 TUI surface in src/tui/v2.tsx. The server and TUI surfaces stay in separate entry files because OpenCode reads each export separately.
Both are thin adapters over one host-neutral core (src/host/forge-core.ts), so the loop runtime, storage, sandbox, and tools stay single-sourced:
| Surface | Adapter files | Role |
|---|---|---|
| Server | src/host/v2.ts |
Runs setup(ctx), registers tools/agents/commands/hooks, and pumps normalized events into the core |
| TUI | src/tui/v2.tsx |
Registers the sidebar, execution dialog, loop restart, sandbox build, and host-sandbox toggle |
The server side is split by concern: src/client/v2-adapter.ts implements the ForgeClient port over the V2 context, src/client/v2-workspaces.ts adapts V2 worktrees and locations, src/host/v2-events.ts normalizes V2 events into Forge's event shape, and src/host/v2-config.ts, src/host/v2-tools.ts, and src/host/v2-hooks.ts handle agent/command registration, tool registration, and hooks.
The server plugin is the core of the plugin. It:
- Initializes services (database, loop runtime, sandbox manager)
- Registers tools for OpenCode to use
- Registers agents and commands
- 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:
setupForgeV2(ctx: Plugin.Context)- OpenCode 2.xsetupentrycreateParentSessionLookup(options)- Resolves parent sessions across worktreescreateSessionDirectoryLookup(options)- Resolves session directory across worktreesPluginConfig,CompactionConfig- Configuration typesVERSION- Plugin version
Each opencode attach --dir <worktree> invokes setupForgeV2 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 listing the project's loops (up to three: running first, then most recent finished)
- The current session's msb state next to the Forge title when sandboxing is configured
- An execution dialog with mode, model, and variant selection, also used to restart a loop
- Command palette integration (
Execute plan,Execute pasted plan,Restart loop,Open dashboard,Build sandbox template,Toggle host sandbox) - Model selection with recent-model tracking
The TUI talks to the server through the V2 plugin RPC port (FORGE_RPC): executePlan for plan launches, plus a toast/sessionDelete event bus for server-pushed notifications.
The codebase is organized into these module groups under src/:
| Module | Purpose | Key Files |
|---|---|---|
host/ |
Host-neutral core plus the V2 adapter | forge-core.ts, v2.ts, v2-events.ts, v2-hooks.ts, v2-tools.ts, v2-config.ts, forge-rpc.ts |
client/ |
ForgeClient port and the V2 adapter |
port.ts, v2-adapter.ts, v2-workspaces.ts, errors.ts |
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, group orchestrator) | index.ts, session.ts, loop.ts, plan-capture.ts, plan-approval.ts, watchdog.ts, sandbox-tools.ts, sandbox-message.ts, forge-session-attach.ts, loop-permission.ts, host-side-effects.ts, group-orchestrator.ts, tool-hook-types.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, section-bootstrap.ts, plan-capture.ts, group-orchestrator.ts, group-scheduler.ts, tui-loop-restart-controller.ts, unified-sandbox-resolver.ts, worktree-log.ts |
sandbox/ |
msb sandbox management | msb.ts, manager.ts, context.ts, reconcile.ts, session-controller.ts, shell-shim.ts, exec-fs.ts, env-probe.ts, process.ts, template.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, plan-authoring.ts, plan-adjust.ts, section-read.ts, group.ts, tool.ts |
workspace/ |
Git worktree / workspace management | forge-adapter.ts, forge-worktree.ts, forge-naming.ts, forge-workspace-metadata.ts, pending-teardown.ts, worktree-commit.ts, worktree-opencode-config.ts, classify-stale.ts, remove-with-context.ts, sweep-stale.ts |
utils/ |
Shared utility modules (~40 files) | logger.ts, lru-cache.ts, model-fallback.ts, git-service.ts, toast.ts, etc. |
tui/ |
TUI-specific components | v2.tsx, host.tsx, execute-plan-panel.tsx, plan-commands.ts, host-sandbox.ts, session-sandbox-store.ts, sandbox-build-dialog.tsx, session-follow.ts, project-client.ts, v2-client.ts, options.ts |
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 and controlled by sandbox.enabled (default true) with driver sandbox.mode = 'msb'. When enabled, a sandbox is provisioned automatically. If the msb CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop is rolled back rather than falling back to the host; set sandbox.enabled: false to run worktree-only.
- SandboxRuntime (
sandbox/msb.ts) -msbCLI 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 - SandboxMessage (
hooks/sandbox-message.ts) - Tells the agent its tool calls run in a container - SessionSandboxController (
sandbox/session-controller.ts) - Host-session sandbox selection and reconciliation - Shell shim (
sandbox/shell-shim.ts) - Generated shim routing the nativeshelltool throughmsb exec
- When a sandbox loop starts, an
msbsandbox is created - The worktree directory is mounted at its identical host path inside the sandbox
- Shell commands and search tools run inside the sandbox:
shellthrough the generated shell shim,globandgrepthrough the sandbox tool hooks — both backed bymsb exec - File operations (
read,write,edit,patch) operate on the host directly, fenced by the sandbox tool hook to the sandbox mounts (read-only mounts refuse mutation) - On loop completion, the sandbox is stopped and removed
The sandbox state model has five states. running and stopped are both usable: msb suspends idle microVMs to stopped and msb exec resumes them in place, so forge never recreates a merely-stopped sandbox. transient covers msb's Created/Starting/Draining/Paused statuses — real but not directly executable, and never collapsed into unknown. unknown means the state query failed and says nothing about the sandbox, so forge fails closed and refuses to create or remove on that basis. missing is the one confirmed-absent state, and the only one in which forge creates a sandbox.
shell and the search tools reach the sandbox through two different mechanisms:
shellis redirected out of band, not through a tool hook. For a sandboxed session the tool wrapper prefixes the command with a one-offforge-sandbox-required-<uuid> &▮ theshell.hook('create.before')strips the marker, pointsevent.shellat theforge-shellshim (sandbox/shell-shim.ts), and setsFORGE_SANDBOX_CONTAINER. The shimexecsmsb exec --quiet "$FORGE_SANDBOX_CONTAINER" --no-tty -w "$PWD" -- bash "$@". Tool arguments are never rewritten, and an unstripped marker fails with "command not found".globandgrepuse output replacement.tool.hook('execute.before')runs the equivalentrgcommand inside the container and stores the result bycallID;tool.hook('execute.after')overwritesoutput.outputwith it. Because the before-hook cannot 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. setup(ctx) registers the core handlers through V2's hook API.
The V2 adapter registers the shared core handlers through V2's hook API:
tool.hook('execute.before')/tool.hook('execute.after')— sandbox tool redirection and logging, with V2 tool names (shell,subagent) mapped back to Forge's names (bash,task)shell.hook('create.before')— sandbox shell routing, keyed by the loop worktree location or the one-off sandbox markersession.hook('prompt')— plan capture from submitted promptssession.hook('context')— system context injection and the architect remindersession.hook('compaction')— compaction instructions
session.hook('prompt')- Inject memory into context, handle session eventssession.hook('compaction')- Custom compaction behavior for session continuity
src/host/v2-hooks.ts 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 and subagent while retaining the shell tool for read-only inspection plus plan-read, plan-write, and plan-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, createLoopPermissionPatcher 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 (external directories included; in a sandbox the mounts are the boundary), and explicit structural denies forreview-write,review-delete,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 blanket allow 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, plus the shared plan/loop structural denies).
onEvent- Handle normalized events (session execution, session creation, session deletion)location.shutdown- Run cleanup when the location shuts down- Plan approval events via
createPlanApprovalEventHook - Plan capture from streaming message parts 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 on streamingmessage.part.updatedevents. - 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 - Group Orchestrator (
src/hooks/group-orchestrator.ts) - Advances queued features when a group loop terminates
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 |
FeatureGroupsRepo |
Feature-group state for grouped execution | FeatureGroupsRepo |
LoopAttemptsRepo |
Durable audit-attempt history | LoopAttemptsRepo |
SessionSandboxPreferencesRepo |
Desired/applied host-session sandbox state | SessionSandboxPreferencesRepo |
TuiLoopRestartRepo |
TUI loop-restart request/acknowledgement handoff | TuiLoopRestartRepo |
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 createForgeCore():
- Logger - Always first (
createLogger()) - Sandbox Manager - msb sandbox management (optional; initialization fails the plugin when sandboxing is enabled)
- Pending Teardown Registry - Track worktree teardown contexts
- Workspace Adapter - Register the forge workspace adapter
- Database - Initialize SQLite storage (
initializeDatabase()) - Repositories - Create typed repos (loops, plans, reviewFindings, sectionPlans, loopSessionUsage, featureGroups, transitions, planAmendments, attempts, sessionSandboxPreferences, tuiLoopRestart)
- Loop Event Handler - Connect loop runtime to events and state management
- Session Sandbox Controller - Reconcile the host-session sandbox selection
- Group Orchestrator - Manage grouped execution
- 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 (location.shutdown event):
- Release the shared session-sandbox controller
- Stop all active sandboxes
- Clear retry timeouts
- Close database connections
graph TD
TUI["TUI Plugin (tui/v2.tsx)"] --> RPC["V2 Plugin RPC"]
RPC --> Server["Server Plugin (host/v2.ts)"]
Server --> Core["Forge Core (host/forge-core.ts)"]
Server --> V2Client["V2 ForgeClient (client/v2-adapter.ts)"]
V2Client --> Core
subgraph Core
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 --> Msb["msb Sandbox"]
SQLite --> LoopsRepo["Loops Repo"]
SQLite --> PlansRepo["Plans Repo"]
SQLite --> ReviewRepo["Review Findings Repo"]