- Install with
pnpm install --frozen-lockfile;pnpm-lock.yamlis canonical. Bun is still required because build and setup scripts run through it—do not substitutebun install. - Full verification:
pnpm build && pnpm typecheck && pnpm lint && pnpm test. - Focus a Node test with
pnpm test --project node test/path.test.ts; add-t "test name"for one case. Tests run under thenodeproject with abun:sqliteshim. tscis TypeScript 7 (native, devDependency aliastypescript-7). Thetypescriptpackage is an alias for@typescript/typescript6because typedoc needs the JavaScript compiler API, which TypeScript 7.0 does not ship; keep both aliases until typedoc supports 7.pnpm typecheckcoverssrc/, not tests or scripts. Oxlint also ignores tests.- Test fixtures come from
test/helpers/: test databases fromcreateTestDbintest/helpers/test-db.ts(real migrations; never hand-writeCREATE TABLEoutsidetest/storage-migrations.test.tsandtest/scripts/cleanup-loop.test.ts), loggers fromtest/helpers/logger.ts, loop state/runtime/execution fixtures fromtest/helpers/loop-fixtures.ts, loop handoff payloads and recording fromtest/helpers/loop-handoff.ts, ForgeClients fromtest/helpers/fake-client.ts.
- Server plugin
src/index.ts— published as root (.) and./serveralias. TUI pluginsrc/tui.tsx. Installer CLIsrc/install/cli.ts(bin). src/index.tsis the server entry; the host-neutral composition root iscreateForgeCoreinsrc/host/forge-core.ts, wrapped by the OpenCode 2.x adapter insrc/host/v2.ts. Forge supports OpenCode 2.x only. Core boundaries:src/loop/(runtime/state machine),src/storage/(database and repositories),src/tools/(OpenCode tools),src/agents/(agent definitions).
pnpm buildrewritessrc/version.tsfrompackage.json; never edit it directly.pnpm buildremoves the output directory before compiling (scripts/build.ts), so deleting or renaming source modules cannot leave stale output indist/.scripts/build.tsrunstscfirst, thenBun.buildoverwritesdist/index.js(server) anddist/tui.js(TUI) with self-contained bundles. Both must stay bundled so the plugin loads without resolvingnode_modules; the vendored installer mode (bunx opencode-forge --vendor) depends on it.@opentui/*,@opencode/plugin/tui,solid-js, andbun:sqlitestay external because opencode's runtime provides them. Import other@opencode/*packages type-only (theFORGE_RPCcontract is a plain object checked withsatisfies) so their runtime stays out of the bundles.resolveShippedRootinsrc/utils/shipped-paths.tsis the only way to locate shipped files on disk. Never derive paths fromimport.meta.urldirectly: bundling collapses it, which would silently break the migration SQL loader, prompt loading, and bundled-asset sync.- Bundled prompts (
src/prompts/) and skills (skills/) sync on every plugin load, preserving user edits. The standalone installer handles conflicts and orphan pruning. docs/api/is generated bypnpm docs:api(typedoc,cleanOutputDir: true), including thedocs/api/_media/copies ofdocs/*.md. Editdocs/*.mdand regenerate; never hand-edit or hand-copy anything underdocs/api/.- Loop phases advance only through persisted handoffs:
Loop.recordHandoffinsrc/loop/runtime.tsis the single validator forloop-handoff/loop-complete; tool names come only fromLOOP_PHASE_TOOL_NAMESinsrc/constants/loop.ts; the runtime never parses assistant text.
MAX_TOTAL_SECTIONSinsrc/constants/loop.tsis the single section cap. Update thearchitect.mdprose when it changes.PLAN_AUTHORING_TOOL_NAMESandFORGE_MANAGED_PERMISSIONS/FORGE_REQUIRED_PERMISSIONSinsrc/constants/loop.tsare the single sources for tool-exclude lists, permission rulesets, and the audit ruleset. Do not re-list tool names by hand.- Persisted review findings are the only channel from the auditor to the coding agent. Coder prompts inline each finding in full; auditor response prose is never injected into a coder prompt, and the runtime reads only the auditor's
loop-handoffarguments.formatFindingDetailsinsrc/utils/review-format.tsis the single finding renderer for bothreview-readand every coder prompt.lastAuditResultis persisted for the TUI dashboard andloop-statusdisplay only. - Watchdog has exactly two progress signals (
recordActivity,recordSessionContent);busyis not one of them. Content signal is fed fromloop.tickfirst branch, must stay O(1). Busy ceiling (busyStallTimeoutMs) is measured from the newest of both across loop and subagent sessions. - Multiple plugin instances share one process, but OpenCode loads a separate copy of the module graph per location, so module-level state is per location. State that must be one per process (host sandbox controllers, idle-gate markers, prompt in-flight guard, loop registry, loop state locks, internal-abort markers, pending code-graph snapshot markers, V2 session status cache, pending shell-sandbox markers, V2 location tool registry, TUI event emitters) goes through
processSharedinsrc/utils/process-shared.ts; a plain module-levelMap/Setsilently splits between the host and worktree instances. OpenCode 2.x registers plugin tools process-wide, soregisterForgeToolsV2routes each call to the instance registered for the calling session's location (fails closed when none is registered); never execute a Forge tool against the registering instance's own context. Loop-scoped shared entries are keyed byprojectLoopKey(projectId, loopName)because one process can host several projects. Plugin init supervises only loops in the loop registry (started or restarted in this process), never loops persisted before it. Ownership gate:ownsLoopWorktreeinsrc/loop/runtime.tscomparesdirectorytoworktreeDirviacanonicalizePath.startWatchdogandsession.errorhandler gate on it — fail-safe, never a hang. Do not extend the gate to the idle handler. The one idle suppression is a queued session inbox item (isPromptQueued); it stays fail-safe because acancelledinbox event that empties the queue replays the suppressed idle in the owning instance,session.deletedclears the entries, and entries older thanQUEUED_PROMPT_MAX_AGE_MSexpire. The ungated idle handler is safe only because each instance receives just its own location's session events. OpenCode 2.x (verified on 2.0.15 and 2.2.2) delivers every location's events to every plugin subscription, socreateV2SessionOwnershipinsrc/host/v2-events.tsdrops session events whose session lives in another location beforecore.onEvent. Without it, a host instance and a worktree instance of the same project share the Forge DB, both match the loop, and every phase transition creates two sessions. A failed session lookup delivers the event (fail-safe, never a hang). - Busy/idle/retry come only from
session.execution.*andsession.retry.scheduled, mapped innormalizeV2Event(src/host/v2-events.ts). OpenCode 2.x never publishessession.status/session.idle; never map them as well, or a release that emits both would double-rotate phases. LoopService.resolveActiveLoopForSessionis the only correct "is this session inside a running loop" check.resolveLoopNameonly reads the persistedrunningrow for the session and does not confirm the in-memory active state.
src/sandbox/msb.tsis the sole TypeScript runtime/lifecycle facade andmsbCLI argument owner; route runtime operations through itsSandboxRuntimefacade. The one required exception is the generated shell shim (src/sandbox/shell-shim.ts), which invokesmsb execdirectly when an agent shell command must run inside a sandbox.src/sandbox/process.tsis the only child-process spawner; all TypeScript shell execution goes throughrunCommand.- OpenCode's
shell.hook('create.before')carries no session identity. Loop sandboxes route there by working directory; host session sandboxes route through theshelltool wrapper insrc/host/v2-hooks.ts, which prefixes the command with a one-offforge-sandbox-required-<uuid> &&marker that the shell hook strips. An unstripped marker must fail the command ("command not found"), never run it on the host. Commands the user runs directly (!, terminals) do not pass through the wrapper and stay on the host. buildSandboxWorkspacescanonicalizes the host side of every mount and leavescontainerDiras the original path. msb refuses a host path that traverses a symlink and fails the whole sandbox withENOTDIR, which on macOS breaks everyos.tmpdir()mount because/varis a symlink toprivate/var. Never canonicalize the container side: absolute paths handed to the agent must resolve identically inside the sandbox.- Every named disk
buildMsbCreateArgsmounts must also be removed byremoveSandbox(named volumes survivemsb rm); a test pins the two sets equal, so a new disk needs both sides./opt/forge/cacheis the cache disk: the create-timeprepareCacheDiskchown+chmod is memoized per container and re-applied on adoption, and must never become a per-tool-call exec. The cache path must stay free of.: msb derives a named disk's id from its guest mount path and rejects ids that are not[A-Za-z0-9_-](msb >= 0.7.0), failing sandbox start withmanaged additional disk has an invalid or reserved device id. getSandboxStateis the only liveness primitive; five states:running,stopped(reusable, never create/evict),transient(real but not directly executable:Created/Starting/Draining/Pausedmap here),unknown(query failed),missing(may create or evict).Stopped/Crashedmap to the reusablestoppedstate becausemsb execstarts them in place.registerActiveSandboxis the only place a usable sandbox is recorded.container/Dockerfilemust derive from a plain OCI base and keep the finalUSER agent;ENTRYPOINT/CMDare ignored because msb runsagentdas PID 1.
- The TUI's
context.locationis a live getter, not a startup constant: OpenCode's session and project picker moves one TUI between locations and projects. Resolve the directory withresolveV2TuiDirectoryper use, and key anything derived from it (project id, RPC location, project client, execution context cache) by directory; never cache it for the TUI lifetime.createFakeV2TuiContextintest/tui/v2-tui.test.tsmodels this withsetLocation, so a location-dependent feature needs a test that switches location. - The TUI dashboard (
src/tui/dashboard/) keeps all state instore.tsbecause pages remount on every navigation; pure computations live inview-model.tsandmachine-diagram.ts, and every value comes fromFORGE_RPC(src/host/forge-rpc-dashboard.ts).
- Storage migrations are registered explicitly, in execution order, in the lowercase
migrationsarray insrc/storage/migrations/index.ts; they are not discovered from filenames. resolveForgeDbPathinsrc/utils/opencode-paths.tsis the only place<dataDir>/forge.dbis built.