Skip to content

Latest commit

 

History

History
54 lines (40 loc) · 11.1 KB

AGENTS.md

File metadata and controls

54 lines (40 loc) · 11.1 KB

Repository Guide

Toolchain and checks

  • Install with pnpm install --frozen-lockfile; pnpm-lock.yaml is canonical. Bun is still required because build and setup scripts run through it—do not substitute bun 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 the node project with a bun:sqlite shim.
  • tsc is TypeScript 7 (native, devDependency alias typescript-7). The typescript package is an alias for @typescript/typescript6 because typedoc needs the JavaScript compiler API, which TypeScript 7.0 does not ship; keep both aliases until typedoc supports 7.
  • pnpm typecheck covers src/, not tests or scripts. Oxlint also ignores tests.
  • Test fixtures come from test/helpers/: test databases from createTestDb in test/helpers/test-db.ts (real migrations; never hand-write CREATE TABLE outside test/storage-migrations.test.ts and test/scripts/cleanup-loop.test.ts), loggers from test/helpers/logger.ts, loop state/runtime/execution fixtures from test/helpers/loop-fixtures.ts, loop handoff payloads and recording from test/helpers/loop-handoff.ts, ForgeClients from test/helpers/fake-client.ts.

Package surfaces

  • Server plugin src/index.ts — published as root (.) and ./server alias. TUI plugin src/tui.tsx. Installer CLI src/install/cli.ts (bin).
  • src/index.ts is the server entry; the host-neutral composition root is createForgeCore in src/host/forge-core.ts, wrapped by the OpenCode 2.x adapter in src/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).

Generated and bundled files

  • pnpm build rewrites src/version.ts from package.json; never edit it directly.
  • pnpm build removes the output directory before compiling (scripts/build.ts), so deleting or renaming source modules cannot leave stale output in dist/.
  • scripts/build.ts runs tsc first, then Bun.build overwrites dist/index.js (server) and dist/tui.js (TUI) with self-contained bundles. Both must stay bundled so the plugin loads without resolving node_modules; the vendored installer mode (bunx opencode-forge --vendor) depends on it. @opentui/*, @opencode/plugin/tui, solid-js, and bun:sqlite stay external because opencode's runtime provides them. Import other @opencode/* packages type-only (the FORGE_RPC contract is a plain object checked with satisfies) so their runtime stays out of the bundles.
  • resolveShippedRoot in src/utils/shipped-paths.ts is the only way to locate shipped files on disk. Never derive paths from import.meta.url directly: 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 by pnpm docs:api (typedoc, cleanOutputDir: true), including the docs/api/_media/ copies of docs/*.md. Edit docs/*.md and regenerate; never hand-edit or hand-copy anything under docs/api/.
  • Loop phases advance only through persisted handoffs: Loop.recordHandoff in src/loop/runtime.ts is the single validator for loop-handoff/loop-complete; tool names come only from LOOP_PHASE_TOOL_NAMES in src/constants/loop.ts; the runtime never parses assistant text.

Loop runtime

  • MAX_TOTAL_SECTIONS in src/constants/loop.ts is the single section cap. Update the architect.md prose when it changes.
  • PLAN_AUTHORING_TOOL_NAMES and FORGE_MANAGED_PERMISSIONS/FORGE_REQUIRED_PERMISSIONS in src/constants/loop.ts are 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-handoff arguments. formatFindingDetails in src/utils/review-format.ts is the single finding renderer for both review-read and every coder prompt. lastAuditResult is persisted for the TUI dashboard and loop-status display only.
  • Watchdog has exactly two progress signals (recordActivity, recordSessionContent); busy is not one of them. Content signal is fed from loop.tick first 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 processShared in src/utils/process-shared.ts; a plain module-level Map/Set silently splits between the host and worktree instances. OpenCode 2.x registers plugin tools process-wide, so registerForgeToolsV2 routes 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 by projectLoopKey(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: ownsLoopWorktree in src/loop/runtime.ts compares directory to worktreeDir via canonicalizePath. startWatchdog and session.error handler 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 a cancelled inbox event that empties the queue replays the suppressed idle in the owning instance, session.deleted clears the entries, and entries older than QUEUED_PROMPT_MAX_AGE_MS expire. 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, so createV2SessionOwnership in src/host/v2-events.ts drops session events whose session lives in another location before core.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.* and session.retry.scheduled, mapped in normalizeV2Event (src/host/v2-events.ts). OpenCode 2.x never publishes session.status/session.idle; never map them as well, or a release that emits both would double-rotate phases.
  • LoopService.resolveActiveLoopForSession is the only correct "is this session inside a running loop" check. resolveLoopName only reads the persisted running row for the session and does not confirm the in-memory active state.

Sandbox

  • src/sandbox/msb.ts is the sole TypeScript runtime/lifecycle facade and msb CLI argument owner; route runtime operations through its SandboxRuntime facade. The one required exception is the generated shell shim (src/sandbox/shell-shim.ts), which invokes msb exec directly when an agent shell command must run inside a sandbox. src/sandbox/process.ts is the only child-process spawner; all TypeScript shell execution goes through runCommand.
  • OpenCode's shell.hook('create.before') carries no session identity. Loop sandboxes route there by working directory; host session sandboxes route through the shell tool wrapper in src/host/v2-hooks.ts, which prefixes the command with a one-off forge-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.
  • buildSandboxWorkspaces canonicalizes the host side of every mount and leaves containerDir as the original path. msb refuses a host path that traverses a symlink and fails the whole sandbox with ENOTDIR, which on macOS breaks every os.tmpdir() mount because /var is a symlink to private/var. Never canonicalize the container side: absolute paths handed to the agent must resolve identically inside the sandbox.
  • Every named disk buildMsbCreateArgs mounts must also be removed by removeSandbox (named volumes survive msb rm); a test pins the two sets equal, so a new disk needs both sides. /opt/forge/cache is the cache disk: the create-time prepareCacheDisk chown+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 with managed additional disk has an invalid or reserved device id.
  • getSandboxState is the only liveness primitive; five states: running, stopped (reusable, never create/evict), transient (real but not directly executable: Created/Starting/Draining/Paused map here), unknown (query failed), missing (may create or evict). Stopped/Crashed map to the reusable stopped state because msb exec starts them in place. registerActiveSandbox is the only place a usable sandbox is recorded.
  • container/Dockerfile must derive from a plain OCI base and keep the final USER agent; ENTRYPOINT/CMD are ignored because msb runs agentd as PID 1.

TUI

  • The TUI's context.location is a live getter, not a startup constant: OpenCode's session and project picker moves one TUI between locations and projects. Resolve the directory with resolveV2TuiDirectory per 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. createFakeV2TuiContext in test/tui/v2-tui.test.ts models this with setLocation, so a location-dependent feature needs a test that switches location.
  • The TUI dashboard (src/tui/dashboard/) keeps all state in store.ts because pages remount on every navigation; pure computations live in view-model.ts and machine-diagram.ts, and every value comes from FORGE_RPC (src/host/forge-rpc-dashboard.ts).

Storage and paths

  • Storage migrations are registered explicitly, in execution order, in the lowercase migrations array in src/storage/migrations/index.ts; they are not discovered from filenames.
  • resolveForgeDbPath in src/utils/opencode-paths.ts is the only place <dataDir>/forge.db is built.