Skip to content

Commit 4266931

Browse files
refactor: consolidate permission boundaries into permission.ask hook (#29)
* refactor: consolidate permission boundaries into permission.ask hook Loops are autonomous and cannot answer permission prompts. The previous approach patched subagent session permissions after session.created fired, which had race conditions and mutated session state for observability only. Replace it with a synchronous decision at opencode's permission.ask plugin hook so the loop's session ruleset and the hook agree by construction. Permission boundaries - Add evaluatePermissionRuleset(rules, request) returning allow/deny/ask by finding the last matching rule (mirrors opencode session-level semantics) - Add createLoopPermissionAskHook factory that resolves the active loop via sessionLoopResolver and sets output.status based on buildLoopPermissionRuleset() - Wire 'permission.ask' into the plugin hooks in src/index.ts - Remove createLoopPermissionRejectHook patching path and PATCHED_SESSIONS cache; drop redundant patcher tests, add unit and integration tests for the new hook Plan metadata + loop name editing - Unify plan title and loop name extraction into a single metadata helper - Allow editing the loop name from the execute plan dialog, persisting through tui-execution-preferences and loops-repo - Add migration 131_add_loop_model_variants.sql for model variant storage Housekeeping - Bump version to 0.4.6 - Refresh README, docs/api, docs/architecture, docs/modules to match - Add tui-models helpers and tui-client variants tests * docs: update architecture and loop system documentation for permission boundaries refactor * docs: update API documentation and media assets * refactor: remove unused showLoops config option from TuiConfig * refactor: remove dead code and shrink barrel exports - Delete unused src/utils/plan-patch.ts and src/utils/session-stats.ts - Remove unused zod and @opentui/keymap dependencies - Drop unused exports: truncateMiddle, formatDuration (format.ts), resolveCurrentGitBranch, fetchLoopByName - Shrink barrel re-exports in src/loop/index.ts, src/storage/index.ts, src/hooks/index.ts; consumers import from source modules directly - Drop LOOP_BLOCKED_TOOLS and extractPlanTitle re-exports in plan-approval.ts - Drop unused Phase type alias in src/loop/state.ts - Drop ModelUsage and LoopSessionOutput re-exports in loop-format.ts - Add test/utils/tui-client-variants.test.ts to vitest include so buildPromptModelSelection coverage actually runs - Update docs/modules.md to drop references to deleted files
1 parent 2173c27 commit 4266931

104 files changed

Lines changed: 2362 additions & 6665 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,4 @@
44

55
### Changed
66

7-
- Replaced custom `forge-worktree` workspace adapter with opencode's builtin `worktree` workspace type to fix red-dot/disconnected status in the TUI. Old `forge-worktree` workspace rows in the local DB must be deleted manually.
87
- Renamed auto-generated git branches to `opencode/<loopName>` (with `-2`, `-3`, ... suffixes on conflict) at loop completion for better discoverability.

‎README.md‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ Add to your `opencode.json` to enable Forge’s server-side hooks, tools, and ag
3838
}
3939
```
4040

41+
**Optional — workspace integration:** to let worktree loops appear as switchable OpenCode workspaces in the TUI, also export this in the environment that launches `opencode`:
42+
43+
```bash
44+
export OPENCODE_EXPERIMENTAL_WORKSPACES=true
45+
```
46+
47+
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.
48+
4149
## What Forge Adds
4250

4351
Forge ships two user-facing surfaces:
@@ -213,6 +221,7 @@ Enable `logging.enabled` to write logs to disk. To use the default log path, omi
213221
"planArchiveTtlMs": 604800000, // TTL in ms for archived plans before pruning. 0 disables pruning.
214222
"keybinds": { // Keyboard shortcut overrides
215223
"viewPlan": "<leader>v", // View plan dialog
224+
"showLoops": "<leader>w", // Show loops dialog
216225
"loadPlan": "<leader>i" // Load archived plans dialog
217226
}
218227
},
@@ -278,6 +287,7 @@ When enabled, logs are written to the specified file with timestamps. The log fi
278287
- `tui.autoSavePlans` - Auto-save captured plans to disk under `<dataDir>/plans/<projectId>/`. Default: `false`.
279288
- `tui.planArchiveTtlMs` - TTL in ms for archived plans before pruning. 0 disables pruning. Default: `604800000` (7 days).
280289
- `tui.keybinds.viewPlan` - View plan dialog keybind. Default: `<leader>v`.
290+
- `tui.keybinds.showLoops` - Show loops dialog keybind. Default: `<leader>w`.
281291
- `tui.keybinds.loadPlan` - Load archived plans dialog keybind. Default: `<leader>i`.
282292

283293
## TUI Plugin
@@ -432,7 +442,7 @@ After the architect presents a summary, the user chooses an execution mode from
432442
| `Execute here` | When preserving current context matters |
433443
| `Loop` | Safer autonomous iteration |
434444

435-
The dialog also lets you pick the execution model and auditor model at launch time. Those selections are remembered per project and pre-filled on later launches.
445+
The dialog also lets you pick the execution model and auditor model at launch time. Those selections are remembered per project and pre-filled on later launches. Optional **variant selectors** accompany each model selector, letting you choose provider-specific reasoning or thinking-effort levels (e.g., `low`, `high`, `max`) when the model exposes them. Variant selections are also persisted per project.
436446

437447
Execution is immediate — there are no additional LLM calls between approval and execution. The system intercepts the user's approval answer, reads the cached plan, and dispatches it programmatically to the code agent. The architect never processes the approval response.
438448

@@ -533,30 +543,44 @@ Loops always run in an isolated git worktree. Sandbox is optional: when Docker i
533543

534544
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.
535545

536-
### When it runs
546+
### Requirements
547+
548+
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.
549+
550+
Set one of these in the environment that launches `opencode`:
551+
552+
```bash
553+
export OPENCODE_EXPERIMENTAL_WORKSPACES=true
554+
# or, to enable every experimental opencode feature at once:
555+
export OPENCODE_EXPERIMENTAL=true
556+
```
557+
558+
Accepted values are `true` or `1` (case-insensitive). Requires **OpenCode ≥ 1.15.0**.
559+
560+
> 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.
537561
538-
Workspace integration is **host-gated, not config-gated**. Forge uses opencode's builtin `worktree` workspace type, which is always available on hosts that expose the experimental workspace API (`experimental_workspace` on the plugin input, `experimental.workspace` on the SDK client).
562+
No forge config option enables or disables this — the toggle is purely on the OpenCode side.
539563

540-
- **Host exposes the API** → worktree loops become workspace-backed. The worktree directory appears as a switchable workspace in the TUI, and its sessions are bound to that workspace.
541-
- **Host does not expose the API** → forge skips registration, logs a note, and worktree loops run exactly as before. Everything else (iteration, auditing, sandbox, status, cancel, restart) is unaffected.
564+
### When workspace integration is active
542565

543-
No forge config option enables or disables this — the feature lights up automatically on supported hosts.
566+
- **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.
567+
- **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.
544568

545569
### What it does
546570

547-
When a worktree loop starts on a supported host, forge:
571+
When a worktree loop starts with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`, forge:
548572

549-
1. Creates the git worktree (as usual)
550-
2. Creates a new Code session pointed at the worktree directory
551-
3. Calls `experimental.workspace.create` with `type: "worktree"` and `branch: null` to create a builtin worktree workspace
573+
1. Calls `experimental.workspace.create` with `type: "forge"`, `branch: null`, and `extra: { loopName, projectDirectory, workspaceCreatedAt }` to register the workspace through the `forge` adapter
574+
2. The adapter's `create` hook creates the git worktree (reusing an orphaned branch when possible) and, when configured, provisions the Docker sandbox container
575+
3. Creates a new Code session pointed at the worktree directory
552576
4. Calls `experimental.workspace.warp` to bind the session to that workspace
553577
5. Persists the workspace ID on the loop record (`loops.workspace_id`) so the TUI can route clicks on a loop into the correct workspace
554578

555-
The adaptor's `create` and `remove` hooks are intentional no-ops — forge's loop system owns worktree lifecycle, not the workspace system. The adaptor only surfaces existing worktrees to the workspace UI.
579+
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.
556580

557581
### Graceful degradation
558582

559-
If workspace creation or session binding fails at runtime (network error, API mismatch, unsupported host), 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 the loop itself runs to completion.
583+
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.
560584

561585
### From the TUI
562586

‎bunfig.toml‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,50 @@
1-
# Keep in sync with vitest.config.ts:5-24 (vitest-only test includes)
1+
# Files that import from 'vitest' must be ignored by `bun test`.
2+
# Bun executes their top-level `vi.mock(...)` calls, which can replace
3+
# real modules (e.g. `bun:sqlite`) and corrupt unrelated `bun:test` files
4+
# that run in the same process.
5+
# Keep in sync with the test files that use `import ... from 'vitest'`.
26
[test]
37
pathIgnorePatterns = [
48
"test/constants/loop.test.ts",
59
"test/deterministic-decomposer.test.ts",
610
"test/hooks/audit-rotate-ordering.test.ts",
7-
"test/hooks/loop-decomposing-salvage.test.ts",
8-
"test/hooks/loop-decomposing.test.ts",
9-
"test/hooks/loop-section-audit-retry.test.ts",
10-
"test/hooks/loop-idle-gate.test.ts",
11+
"test/hooks/forge-session-attach.test.ts",
12+
"test/hooks/host-side-effects-unwarp.test.ts",
1113
"test/hooks/loop-event-gate.test.ts",
12-
"test/section-capture-streaming-completion.test.ts",
13-
"test/services/execution-decomposer.test.ts",
14-
"test/services/orphan-sweep.test.ts",
14+
"test/hooks/loop-idle-gate.test.ts",
15+
"test/hooks/loop-section-audit-retry.test.ts",
16+
"test/hooks/plan-approval-dedupe.test.ts",
17+
"test/hooks/plan-approval-worktree-timing.test.ts",
18+
"test/index/session-lookup.test.ts",
19+
"test/loop-runtime-audit-permissions.test.ts",
20+
"test/loop-status-tool.test.ts",
21+
"test/loop/cancel.test.ts",
22+
"test/loop/in-flight-guard.test.ts",
23+
"test/loop/prompts.test.ts",
24+
"test/loop/state-mapper.test.ts",
25+
"test/loop/termination.test.ts",
26+
"test/loop/transitions.test.ts",
27+
"test/plan-approval.test.ts",
28+
"test/plan-execution.test.ts",
29+
"test/sandbox/context.test.ts",
30+
"test/services/execution-attach-cleanup.test.ts",
31+
"test/services/execution-in-flight-guard.test.ts",
1532
"test/services/execution-restart.test.ts",
33+
"test/services/execution.start-loop.test.ts",
1634
"test/services/parse-section-summary.test.ts",
17-
"test/utils/worktree-cleanup.test.ts",
18-
"test/workspace/forge-worktree-list.test.ts",
35+
"test/services/select-initial-worktree-session.test.ts",
36+
"test/tui/execute-plan-panel-busy.test.ts",
37+
"test/utils/tui-client-await-workspace-connected.test.ts",
38+
"test/utils/tui-client-loop-inline-plan.test.ts",
39+
"test/utils/tui-client-select-session.test.ts",
40+
"test/utils/tui-client-variants.test.ts",
41+
"test/utils/tui-client-warp-flow.test.ts",
1942
"test/utils/tui-client-workspaces.test.ts",
20-
"test/index/session-lookup.test.ts",
43+
"test/utils/workspace-status-registry.test.ts",
44+
"test/utils/worktree-cleanup.test.ts",
45+
"test/workspace/classify-stale.test.ts",
46+
"test/workspace/forge-adapter-e2e.test.ts",
47+
"test/workspace/forge-adapter.test.ts",
48+
"test/workspace/forge-worktree.test.ts",
49+
"test/workspace/sweep-stale.test.ts",
2150
]

0 commit comments

Comments
 (0)