Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 26 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ Add to your `opencode.json` to enable Forge’s server-side hooks, tools, and ag
}
```

**Optional — workspace integration:** to let worktree loops appear as switchable OpenCode workspaces in the TUI, also export this in the environment that launches `opencode`:
As of OpenCode 1.17.8, `OPENCODE_EXPERIMENTAL_WORKSPACES=true` is required for the plugin's loop functionality to work. Set it in the environment that launches `opencode`:

```bash
export OPENCODE_EXPERIMENTAL_WORKSPACES=true
```

Requires OpenCode ≥ 1.15.0. Without it, loops still run normally — you just don't get workspace switching. See [Workspace Integration](#workspace-integration) for details.
Without this, Forge cannot create loop worktrees and `loop` / `/loop` will fail. See [Common Issues](#common-issues) and [Workspace Integration](#workspace-integration) for details.

## What Forge Adds

Expand Down Expand Up @@ -499,30 +499,18 @@ Loops always run in an isolated git worktree. Sandbox is optional: when Docker i

## Workspace Integration

Worktree loops can optionally register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything.
Forge worktree loops register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything.

### Requirements

Workspace integration requires the **experimental workspace runtime** to be enabled in OpenCode itself. The plugin API surface (`experimental_workspace.register`) is always present, but the underlying sync, session-scoping, and TUI dialogs are gated behind an environment variable. Without it, Forge's adapter registers fine but `workspace.create` silently no-ops and the TUI never shows worktree workspaces.

Set one of these in the environment that launches `opencode`:

```bash
export OPENCODE_EXPERIMENTAL_WORKSPACES=true
# or, to enable every experimental opencode feature at once:
export OPENCODE_EXPERIMENTAL=true
```

Accepted values are `true` or `1` (case-insensitive). Requires **OpenCode ≥ 1.15.0**.
Workspace integration requires the **experimental workspace runtime** enabled in OpenCode. See [Quick Start](#quick-start) for the environment variable setup. No forge config option enables or disables this — the toggle is purely on the OpenCode side and must be present before OpenCode starts.

> The `OPENCODE_EXPERIMENTAL_WORKSPACES` flag is not currently documented on opencode.ai. The authoritative source is `packages/core/src/flag/flag.ts` and `packages/opencode/src/effect/runtime-flags.ts` in the OpenCode repo.

No forge config option enables or disables this — the toggle is purely on the OpenCode side.

### When workspace integration is active

- **Env var set, OpenCode ≥ 1.15.0** → worktree loops become workspace-backed. The worktree directory appears as a switchable workspace in the TUI, and its sessions are bound to that workspace.
- **Env var unset or older OpenCode** → Forge's adapter still registers (the API surface is always present), but `workspace.create` no-ops and the loop runs as a plain worktree loop with no workspace switching. Everything else (iteration, auditing, sandbox, status, cancel, restart) is unaffected.
- **Env var set, OpenCode ≥ 1.17.8** → Forge can create the worktree workspace, bind loop sessions to it, and show the loop as a switchable workspace in the TUI.
- **Env var unset or older OpenCode** → `experimental.workspace.create` is unavailable or no-ops, Forge cannot create the loop worktree, and `loop` / `/loop` fails before iteration starts.

### What it does

Expand All @@ -536,15 +524,29 @@ When a worktree loop starts with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`, forge:

The adapter's `remove` hook commits in-flight changes (when teardown context allows), stops the sandbox container if any, and removes the worktree directory unless the loop is restartable. Branches are preserved for later restart or merge.

### Graceful degradation
### Failure behavior

If workspace creation or session binding fails at runtime — env var unset, OpenCode version too old, network error, API mismatch — the loop **does not abort**. Forge logs the failure, clears the workspace ID, and the loop continues as a regular (non-workspace) worktree loop. You lose workspace-based switching for that loop, but iteration, auditing, sandbox, and restart all run to completion.
If initial workspace creation fails at startup — env var unset, OpenCode version too old, network error, API mismatch — the loop aborts before creating the first loop session. If a workspace disappears after a loop is already running, Forge attempts to re-provision or detach it and continue where possible.

### From the TUI

- Loops are launched via the execution dialog (select Loop mode)
- On hosts with workspace support, active loops appear as switchable workspaces alongside your main project

## Common Issues

### `loop` / `/loop` fails to start

**Most common cause:** `OPENCODE_EXPERIMENTAL_WORKSPACES=true` was not set in the environment that launched OpenCode. See [Quick Start](#quick-start) for setup.

Symptoms include:

- `loop` or `/loop` returns an internal error before the first coding session starts
- Forge logs contain `createBuiltinWorktreeWorkspace: workspace.create threw`, `workspace.create returned no workspace id`, or `handleStartLoop: failed to create builtin worktree workspace`
- No loop worktree appears in the TUI workspace switcher

The flag must be set before OpenCode starts — setting it inside an already-running session is too late. If OpenCode is launched by a desktop app, service manager, shell alias, terminal profile, or wrapper script, set the variable there and fully restart OpenCode.

## Docker Sandbox

Run loop iterations inside an isolated Docker container. Three tools (`bash`, `glob`, `grep`) execute inside the container via `docker exec`, while `read`/`write`/`edit` operate on the host filesystem. The worktree directory is bind-mounted at `/workspace` for instant file sharing, and the source project directory is mounted read-only at `/project` for convenient host-side access.
Expand All @@ -563,7 +565,7 @@ docker build -t oc-forge-sandbox:latest container/

The image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, and jq.

The `container/Dockerfile` ships with the package. If the image is missing at loop start, the sandbox fails fast with a message showing the build command and the `"enabled": false` opt-out. There is no auto-build the image must be built manually.
The `container/Dockerfile` ships with the plugin package. If the image is missing when OpenCode starts, Forge shows a warning toast with a "Forge: Build sandbox image" command in the palette. You can also trigger the build from the command palette at any time by searching for `Forge: Build sandbox image`, which opens a confirmation dialog and runs `docker build` automatically.

**2. Configure the sandbox** (`~/.config/opencode/forge-config.jsonc`):

Expand Down Expand Up @@ -665,12 +667,14 @@ When a `sh` command produces output exceeding the tool's limit, the overflow is

### Customizing the Image

The `container/Dockerfile` is included in the project package. To add project-specific tools (e.g., Go, Rust, additional language servers), edit the Dockerfile and rebuild:
The `container/Dockerfile` is included in the plugin package. To add project-specific tools (e.g., Go, Rust, additional language servers), edit the Dockerfile and rebuild:

```bash
docker build -t oc-forge-sandbox:latest container/
```

You can also rebuild from the command palette using `Forge: Build sandbox image`. This picks up any local changes to the bundled Dockerfile automatically.


## Development

Expand Down
43 changes: 34 additions & 9 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ Add to your `opencode.json` to enable Forge’s server-side hooks, tools, and ag
}
```

**Optional — workspace integration:** to let worktree loops appear as switchable OpenCode workspaces in the TUI, also export this in the environment that launches `opencode`:
**Required for loops:** Forge creates loop worktrees through OpenCode's experimental workspace runtime. Export this in the environment that launches `opencode`:

```bash
export OPENCODE_EXPERIMENTAL_WORKSPACES=true
```

Requires OpenCode ≥ 1.15.0. Without it, loops still run normally — you just don't get workspace switching. See [Workspace Integration](#workspace-integration) for details.
Requires OpenCode ≥ 1.15.0. If this is missing, Forge cannot create the loop worktree and `loop` / `/loop` will fail. See [Common Issues](#common-issues) and [Workspace Integration](#workspace-integration) for details.

## What Forge Adds

Expand Down Expand Up @@ -499,11 +499,11 @@ Loops always run in an isolated git worktree. Sandbox is optional: when Docker i

## Workspace Integration

Worktree loops can optionally register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything.
Forge worktree loops register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything.

### Requirements

Workspace integration requires the **experimental workspace runtime** to be enabled in OpenCode itself. The plugin API surface (`experimental_workspace.register`) is always present, but the underlying sync, session-scoping, and TUI dialogs are gated behind an environment variable. Without it, Forge's adapter registers fine but `workspace.create` silently no-ops and the TUI never shows worktree workspaces.
Workspace integration requires the **experimental workspace runtime** to be enabled in OpenCode itself. Forge's current loop startup path creates the worktree through `experimental.workspace.create`, so this flag is required for `loop` / `/loop`, not just for TUI switching.

Set one of these in the environment that launches `opencode`:

Expand All @@ -517,12 +517,12 @@ Accepted values are `true` or `1` (case-insensitive). Requires **OpenCode ≥ 1.

> The `OPENCODE_EXPERIMENTAL_WORKSPACES` flag is not currently documented on opencode.ai. The authoritative source is `packages/core/src/flag/flag.ts` and `packages/opencode/src/effect/runtime-flags.ts` in the OpenCode repo.

No forge config option enables or disables this — the toggle is purely on the OpenCode side.
No forge config option enables or disables this — the toggle is purely on the OpenCode side and must be present before OpenCode starts. Forge cannot reliably set it from the plugin because OpenCode reads runtime flags before plugins are loaded, and the TUI/server may be separate processes.

### When workspace integration is active

- **Env var set, OpenCode ≥ 1.15.0** → worktree loops become workspace-backed. The worktree directory appears as a switchable workspace in the TUI, and its sessions are bound to that workspace.
- **Env var unset or older OpenCode** → Forge's adapter still registers (the API surface is always present), but `workspace.create` no-ops and the loop runs as a plain worktree loop with no workspace switching. Everything else (iteration, auditing, sandbox, status, cancel, restart) is unaffected.
- **Env var set, OpenCode ≥ 1.15.0** → Forge can create the worktree workspace, bind loop sessions to it, and show the loop as a switchable workspace in the TUI.
- **Env var unset or older OpenCode** → `experimental.workspace.create` is unavailable or no-ops, Forge cannot create the loop worktree, and `loop` / `/loop` fails before iteration starts.

### What it does

Expand All @@ -536,15 +536,40 @@ When a worktree loop starts with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`, forge:

The adapter's `remove` hook commits in-flight changes (when teardown context allows), stops the sandbox container if any, and removes the worktree directory unless the loop is restartable. Branches are preserved for later restart or merge.

### Graceful degradation
### Failure behavior

If workspace creation or session binding fails at runtime — env var unset, OpenCode version too old, network error, API mismatch — the loop **does not abort**. Forge logs the failure, clears the workspace ID, and the loop continues as a regular (non-workspace) worktree loop. You lose workspace-based switching for that loop, but iteration, auditing, sandbox, and restart all run to completion.
If initial workspace creation fails at startup — env var unset, OpenCode version too old, network error, API mismatch — the loop aborts before creating the first loop session. If a workspace disappears after a loop is already running, Forge attempts to re-provision or detach it and continue where possible.

### From the TUI

- Loops are launched via the Execute tab in the Plan Viewer dialog (select Loop mode)
- On hosts with workspace support, active loops appear as switchable workspaces alongside your main project

## Common Issues

### `loop` / `/loop` fails to start

**Most common cause:** `OPENCODE_EXPERIMENTAL_WORKSPACES=true` was not set in the environment that launched OpenCode.

Symptoms include:

- `loop` or `/loop` returns an internal error before the first coding session starts
- Forge logs contain `createBuiltinWorktreeWorkspace: workspace.create threw`, `workspace.create returned no workspace id`, or `handleStartLoop: failed to create builtin worktree workspace`
- No loop worktree appears in the TUI workspace switcher

Fix:

```bash
export OPENCODE_EXPERIMENTAL_WORKSPACES=true
opencode
```

If OpenCode is launched by a desktop app, service manager, shell alias, terminal profile, or wrapper script, set the variable there and fully restart OpenCode. Setting it inside an already-running OpenCode session is too late.

### Can Forge enable workspaces automatically?

Not reliably. OpenCode reads its experimental runtime flags before plugins are loaded, so setting `process.env.OPENCODE_EXPERIMENTAL_WORKSPACES = "true"` inside Forge would usually happen too late and only affect the current process. Configure the environment before starting OpenCode instead.

## Docker Sandbox

Run loop iterations inside an isolated Docker container. Three tools (`bash`, `glob`, `grep`) execute inside the container via `docker exec`, while `read`/`write`/`edit` operate on the host filesystem. Your project directory is bind-mounted at `/workspace` for instant file sharing.
Expand Down
5 changes: 3 additions & 2 deletions docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ createLoop(deps: LoopRuntimeDeps): Loop
isWorkspaceNotFoundError(error): boolean

// State
rowToLoopState(row, largeFields?): LoopState
loopRowToState(row, largeFields?): LoopState
loopStateToRow(state, projectId): LoopRow
MAX_RETRIES: number

// Transitions
Expand Down Expand Up @@ -485,7 +486,7 @@ Other modules do NOT have barrel files (utils, sandbox, services, workspace).
All data access goes through typed repo interfaces:
- `LoopsRepo`, `PlansRepo`, `ReviewFindingsRepo`, `SectionPlansRepo`, `TuiPrefsRepo`
- Each created via `createXxxRepo(db)` with project-scoped queries.
- Rows are mapped to domain objects via `rowToLoopState()` etc.
- Rows are mapped to domain objects via `loopRowToState()` etc.

### State Machine Pattern

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-forge",
"version": "0.5.0",
"version": "0.5.0-beta.2",
"type": "module",
"oc-plugin": [
"server",
Expand Down
42 changes: 38 additions & 4 deletions src/hooks/host-side-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-r
import { aggregateToUsageSummary } from '../utils/loop-format'
import { sweepStaleForgeWorkspaces } from '../workspace/sweep-stale'
import { selectSessionBestEffort } from '../utils/tui-navigation'
import { cleanupLoopWorktree } from '../utils/worktree-cleanup'

export interface TerminationSideEffectsContext {
client: ForgeClient
Expand Down Expand Up @@ -171,22 +172,40 @@ function getToastMessage(state: LoopState, reason: TerminationReason): string {
* session. Once `workspace.remove` fires, that view becomes orphaned. We reuse
* the same navigation path as warp-in (`selectSessionBestEffort`): the
* `tui.selectSession` command first, falling back to a `tui.session.select`
* publish. Omitting the `workspace` property returns the user to the host
* session on the local system. Best-effort: failures are logged but never
* block teardown.
* publish. Selecting through the current workspace context reaches a TUI that
* is still scoped to the soon-to-be-removed workspace; selecting without a
* workspace reaches local project views. Best-effort: failures are logged but
* never block teardown.
*/
async function unwarpToHostSession(
state: LoopState,
ctx: TerminationSideEffectsContext,
): Promise<void> {
if (!state.hostSessionId || !state.projectDir) return

if (state.workspaceId) {
await selectSessionBestEffort(ctx.client, state.projectDir, ctx.logger, {
sessionID: state.hostSessionId,
workspace: state.workspaceId,
})
}

await selectSessionBestEffort(ctx.client, state.projectDir, ctx.logger, {
sessionID: state.hostSessionId,
})

const settleMs = resolveUnwarpSettleMs()
if (settleMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, settleMs))
}
ctx.logger.log(`Loop: unwarped TUI to host session ${state.hostSessionId} for ${state.loopName}`)
}

function resolveUnwarpSettleMs(): number {
const raw = Number(process.env.FORGE_UNWARP_SETTLE_MS)
return Number.isFinite(raw) && raw >= 0 ? raw : 750
}

/** Tear down the worktree workspace — always commits changes back. */
async function teardownWorktree(
state: LoopState,
Expand All @@ -198,25 +217,40 @@ async function teardownWorktree(
const reasonLabel = resolveReasonLabel(reason)
const doCommit = true
const doRemoveWorktree = reason.kind === 'completed'
const removeWorktreeAfterWorkspaceRemoval = doRemoveWorktree

ctx.pendingTeardowns?.set(state.loopName, {
iteration: state.iteration,
reasonLabel,
doCommit,
doRemoveWorktree,
doRemoveWorktree: false,
})

await unwarpToHostSession(state, ctx)

let removedWorkspace = false
try {
await ctx.client.workspace.remove({ id: state.workspaceId })
removedWorkspace = true
ctx.logger.log(`Loop: workspace ${state.workspaceId} removed for ${state.loopName}`)
} catch (err) {
ctx.logger.error(`Loop: workspace.remove threw for ${state.workspaceId}`, err)
} finally {
ctx.pendingTeardowns?.clear(state.loopName)
}

if (removedWorkspace && removeWorktreeAfterWorkspaceRemoval && state.worktreeDir) {
const settleMs = resolveUnwarpSettleMs()
if (settleMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, settleMs))
}
await cleanupLoopWorktree({
worktreeDir: state.worktreeDir,
logPrefix: 'Loop: post-workspace-remove',
logger: ctx.logger,
})
}

// Opportunistic sweep of stale sibling workspaces (port required)
if (ctx.client && ctx.loopsRepo && ctx.projectId && ctx.pendingTeardowns && state.projectDir) {
try {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function createLoopEventHandler(
getConfig,
sandboxManager,
dataDir,
getPlanText: loop.getPlanText,
getPlanText: loop.service.getPlanText,
pendingTeardowns,
loopsRepo,
projectId,
Expand Down
Loading