- Install with
pnpm install --frozen-lockfile;pnpm-lock.yamlis canonical. Bun is still required because build, setup, and dashboard scripts run through it—do not substitutebun install. - Full verification:
pnpm build && pnpm typecheck && pnpm lint && pnpm test. Build must precede tests becausetest/dashboard/app-bundle.test.tsrejects a stale source hash. - Focus a Node test with
pnpm test --project node test/path.test.ts; add-t "test name"for one case. Run dashboard DOM tests withpnpm test --project dom test/dashboard/app-dom.test.ts. Thedomproject is the only happy-dom suite; all other tests use thenodeproject and abun:sqliteshim. pnpm typecheckcoverssrc/, not tests or scripts. ESLint also ignores tests and generated dashboard files.
- Server plugin
src/index.ts— published as root (.) and./serveralias. TUI pluginsrc/tui.tsx. Installer CLIsrc/install/cli.ts(bin). Standalone dashboard viapnpm dashboard(scripts/dashboard.ts); not a package export. 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.ts,src/dashboard/marked-source.ts, andsrc/dashboard/app-bundle.ts. Editpackage.json,src/dashboard/marked.min.js, orsrc/dashboard/app/respectively, never the generated files.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/.- The section-summary block template is the single
SECTION_SUMMARY_TEMPLATEinsrc/loop/prompts.ts, built from the marker constants insrc/utils/section-summary.ts; do not hand-write the marker strings into prompt markdown files or other prompt builders.
MAX_TOTAL_SECTIONSinsrc/constants/loop.tsis the single section cap. Update botharchitect.mdandarchitect-auto.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 only part of it the runtime parses is the section-summary block.
formatFindingDetailsinsrc/utils/review-format.tsis the single finding renderer for bothreview-readand every coder prompt.lastAuditResultis persisted for the 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, V2 session status cache, pending shell-sandbox markers, TUI event emitters) goes through
processSharedinsrc/utils/process-shared.ts; a plain module-levelMap/Setsilently splits between the host and worktree instances. 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) 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.resolveLoopNamematches terminated loops too.
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.
- Dashboard uses
solid-js/html, not JSX. No<${Show}>or<${For}>; use reactive thunks/memos and.map(). Root component returns one wrapper element.test/dashboard/app-dom.test.tsenforces these constraints. src/dashboard/render.tsowns the stylesheet;test/dashboard/render.test.tsenforces CSS token usage, sticky stack--z-subnav<--z-app-bar<--z-popover, app-bar height variables at relevant breakpoints, and matching loop-tabledata-colattributes.- Storage migrations are registered explicitly, in execution order, in the lowercase
migrationsarray insrc/storage/migrations/index.ts; they are not discovered from filenames. resolveDashboardConfiginsrc/dashboard/config.tsis the only dashboard bind host/port resolver. Every launch surface must pass overrides intostartDashboardServerand render warnings. Dashboard has no auth;DASHBOARD_EXPOSED_WARNINGis the single warning.resolveForgeDbPathinsrc/utils/opencode-paths.tsis the only place<dataDir>/forge.dbis built.