Skip to content

Commit 704aa28

Browse files
feat: add worktreeOpencodeConfig for per-loop opencode config (#63)
* feat: add worktreeOpencodeConfig for per-loop opencode config * refactor: protect forge-written opencode.jsonc from loop commits
1 parent 5676e2f commit 704aa28

13 files changed

Lines changed: 452 additions & 20 deletions

‎docs/configuration.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Default log path: `~/.local/share/opencode/forge/logs/forge.log` or `$XDG_DATA_H
5050
| `loop.stallTimeoutMs` | `60000` | Stall watchdog timeout in milliseconds. |
5151
| `loop.maxConsecutiveStalls` | `5` | Consecutive stalls before terminating with `stall_timeout`. `0` disables stall termination. |
5252
| `loop.allowExternalDirectories` | unset | Absolute host directories that loop, audit, and post-action sessions may read despite worktree isolation. |
53+
| `loop.worktreeOpencodeConfig` | unset | Inline [opencode config](https://opencode.ai/config.json) written as `opencode.jsonc` into each freshly created loop worktree. Enables per-loop customization (MCP servers, model overrides, etc.). Skip-if-exists — never overwrites a committed `opencode.json`/`opencode.jsonc`. The written file is git-excluded to keep it out of loop commits. |
5354

5455
### Worktree Logging
5556

@@ -86,6 +87,38 @@ Example:
8687
}
8788
```
8889

90+
### Worktree Opencode Config
91+
92+
`loop.worktreeOpencodeConfig` writes an inline opencode config file (`opencode.jsonc`) at the root of each freshly created loop worktree. This enables per-loop customization — primarily MCP servers — without modifying the host config or polluting loop commits.
93+
94+
The config is written only when:
95+
- The worktree has no existing `opencode.json` or `opencode.jsonc` (committed configs are never overwritten)
96+
- The value is a non-empty object
97+
98+
The written file is added to the worktree's git exclude so it never appears in `git status` or loop commits.
99+
100+
Notes:
101+
- The written file is ephemeral. Forge deletes its own `opencode.jsonc` before any teardown commit (and the whole worktree is removed on completion), so it can never land in loop history — even if the git-exclude write failed. A repository-tracked `opencode.jsonc` is never deleted (forge did not write it). Because the file is removed at teardown, a restarted loop is rewritten from the current `loop.worktreeOpencodeConfig`, so edits take effect on the next run.
102+
- MCP servers declared here run as **host** processes from the worktree directory. When [Sandbox](sandbox.md) is enabled, only `bash`/`glob`/`grep` execute inside the container; the MCP commands themselves are not container-isolated.
103+
104+
Example — expose Chrome DevTools MCP inside every loop:
105+
106+
```jsonc
107+
{
108+
"loop": {
109+
"worktreeOpencodeConfig": {
110+
"mcp": {
111+
"chrome-devtools": {
112+
"type": "local",
113+
"command": ["npx", "chrome-devtools-mcp@latest", "--isolated"],
114+
"enabled": true
115+
}
116+
}
117+
}
118+
}
119+
}
120+
```
121+
89122
## Group Launch
90123

91124
`groupLaunch` configures parallel feature orchestration (see the [`launch-group`](tools.md#group-tools) tool).

‎docs/loop-system.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ Benefits of worktree isolation:
187187
- Isolation from ongoing development
188188
- Safe to experiment without affecting main branch
189189
- Branch preserved for later review/merge
190+
- Per-loop customization via `loop.worktreeOpencodeConfig` — inject MCP servers and other [opencode config](https://opencode.ai/config.json) into each worktree without host config changes or commit pollution (see [Configuration Reference](configuration.md#worktree-opencode-config))
190191

191192
## Sandbox Integration
192193

‎forge-config.jsonc‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@
6363
// identical container path so absolute temp paths match host<->container. Created on startup
6464
// if missing. Defaults to "/tmp/oc-forge".
6565
// "tmpDir": "/tmp/oc-forge"
66+
//
67+
// Inline opencode config written as opencode.jsonc into each loop worktree (skip-if-exists;
68+
// never overwrites a committed opencode.json/opencode.jsonc). Primarily for enabling per-loop
69+
// MCP servers. The written file is git-excluded so it never enters loop commits.
70+
// "worktreeOpencodeConfig": {
71+
// "mcp": {
72+
// "my-server": { "type": "local", "command": ["npx", "some-mcp-server"], "enabled": true }
73+
// }
74+
// }
6675
},
6776

6877
// Max loops from one group running concurrently. Also bounds concurrent planning passes. Default 3.

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
300300
sandboxManager,
301301
gitService: defaultGitService,
302302
getTeardownContext: (loopName) => pendingTeardowns.get(loopName),
303+
worktreeOpencodeConfig: config.loop?.worktreeOpencodeConfig,
303304
}))
304305
logger.log(`Registered forge workspace adapter (worktrees under ${join(dataDir, 'worktrees')})`)
305306
}

‎src/types.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ export interface LoopConfig {
7474
* Defaults to `/tmp/oc-forge`. The directory is created on startup if missing.
7575
*/
7676
tmpDir?: string
77+
/**
78+
* Inline opencode config object written verbatim as `opencode.jsonc` at the root of each
79+
* freshly created loop worktree, enabling per-loop opencode customization (primarily MCP
80+
* servers, e.g. `{ "mcp": { ... } }`). Written only when the worktree has no existing
81+
* `opencode.json`/`opencode.jsonc` (committed configs are never overwritten). The written file
82+
* is added to the worktree's git exclude so it never enters loop commits. An empty object or
83+
* omission disables the behavior.
84+
*/
85+
worktreeOpencodeConfig?: Record<string, unknown>
7786
}
7887

7988
/**

‎src/utils/git-service.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface GitResult {
99

1010
export interface GitService {
1111
addAll(cwd: string): GitResult
12+
/** True when `path` (file or directory) is tracked by git in `cwd`. */
13+
isPathTracked(cwd: string, path: string): boolean
1214
statusPorcelain(cwd: string): GitResult
1315
commit(cwd: string, message: string): GitResult
1416
isInsideWorkTree(cwd: string): boolean
@@ -34,6 +36,11 @@ export function createGitService(): GitService {
3436
return runGit(['add', '-A'], cwd)
3537
},
3638

39+
isPathTracked(cwd: string, path: string): boolean {
40+
const r = runGit(['ls-files', '--', path], cwd)
41+
return r.ok && r.stdout.trim().length > 0
42+
},
43+
3744
statusPorcelain(cwd: string): GitResult {
3845
return runGit(['status', '--porcelain'], cwd)
3946
},

‎src/version.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const VERSION = '0.5.0-beta.9'
1+
export const VERSION = '0.5.0'

‎src/workspace/forge-adapter.ts‎

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { join } from 'path'
22
import { mkdir } from 'fs/promises'
3-
import { existsSync, readFileSync, appendFileSync } from 'fs'
3+
import { existsSync, readFileSync, appendFileSync, rmSync } from 'fs'
44
import type { WorkspaceAdapter, WorkspaceInfo } from '@opencode-ai/plugin'
55
import type { Logger } from '../types'
66
import type { SandboxManager } from '../sandbox/manager'
77
import { forgeBranchName, forgeWorktreeDir, forgeWorktreeSlug } from './forge-naming'
88
import { cleanupLoopWorktree } from '../utils/worktree-cleanup'
99
import { defaultGitService, type GitService } from '../utils/git-service'
10+
import { writeWorktreeOpencodeConfig, WORKTREE_OPENCODE_CONFIG_FILENAME } from './worktree-opencode-config'
1011

1112

1213
/**
@@ -38,6 +39,8 @@ export interface ForgeAdapterDeps {
3839
getTeardownContext?: TeardownContextProvider
3940
/** Inject a custom GitService (defaults to real git if omitted). */
4041
gitService?: GitService
42+
/** Inline opencode config written as opencode.jsonc into each worktree (skip-if-exists). */
43+
worktreeOpencodeConfig?: Record<string, unknown>
4144
}
4245

4346
const DEFAULT_TEARDOWN_CONTEXT: TeardownContext = {
@@ -48,7 +51,7 @@ const DEFAULT_TEARDOWN_CONTEXT: TeardownContext = {
4851
}
4952

5053
export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAdapter {
51-
const { dataDir, logger, sandboxManager, getTeardownContext, gitService: gitServiceOpt } = deps
54+
const { dataDir, logger, sandboxManager, getTeardownContext, gitService: gitServiceOpt, worktreeOpencodeConfig } = deps
5255
const git = gitServiceOpt ?? defaultGitService
5356

5457
function deriveLoopName(info: WorkspaceInfo): string {
@@ -65,6 +68,24 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
6568
return dir
6669
}
6770

71+
function addToGitExclude(directory: string, patterns: string[]): void {
72+
if (patterns.length === 0) return
73+
const excludeRes = git.revParseGitPath(directory, 'info/exclude')
74+
if (!excludeRes.ok || !excludeRes.stdout) return
75+
const excludeFile = excludeRes.stdout.trim()
76+
try {
77+
const content = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : ''
78+
const present = new Set(content.split('\n').map((l) => l.trim()))
79+
const missing = patterns.filter((p) => !present.has(p))
80+
if (missing.length > 0) {
81+
appendFileSync(excludeFile, `\n${missing.join('\n')}\n`)
82+
logger.log(`forge-adapter: added ${missing.join(', ')} to git exclude in ${directory}`)
83+
}
84+
} catch (err) {
85+
logger.log(`forge-adapter: could not update git exclude: ${err instanceof Error ? err.message : String(err)}`)
86+
}
87+
}
88+
6889
function resolveLoopName(info: WorkspaceInfo): string {
6990
try {
7091
return deriveLoopName(info)
@@ -73,9 +94,30 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
7394
}
7495
}
7596

97+
/**
98+
* Remove the forge-written `opencode.jsonc` before a teardown commit so the
99+
* inline per-loop config never enters loop history. Only an untracked file is
100+
* removed: when the repository already tracks an `opencode.jsonc`, forge never
101+
* wrote it (skip-if-exists), so it is left untouched and its edits still commit.
102+
* The worktree itself is torn down at teardown, so the removed file is not lost.
103+
*/
104+
function removeForgeWrittenOpencodeConfig(directory: string): void {
105+
const configPath = join(directory, WORKTREE_OPENCODE_CONFIG_FILENAME)
106+
if (!existsSync(configPath)) return
107+
if (git.isPathTracked(directory, WORKTREE_OPENCODE_CONFIG_FILENAME)) return
108+
try {
109+
rmSync(configPath, { force: true })
110+
logger.log(`forge-adapter: removed forge-written ${WORKTREE_OPENCODE_CONFIG_FILENAME} before commit in ${directory}`)
111+
} catch (err) {
112+
logger.log(`forge-adapter: could not remove ${WORKTREE_OPENCODE_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`)
113+
}
114+
}
115+
76116
async function stepCommitChanges(loopName: string, directory: string, branchLabel: string, ctx: TeardownContext): Promise<void> {
77117
if (!ctx.doCommit || !existsSync(directory)) return
78118

119+
removeForgeWrittenOpencodeConfig(directory)
120+
79121
try {
80122
const addResult = git.addAll(directory)
81123
if (!addResult.ok) {
@@ -193,23 +235,18 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
193235
}
194236
logger.log(`forge-adapter: created worktree ${info.directory} on branch ${info.branch}${branchExists ? ' (reused existing branch)' : ''}${reusedOrphan ? ' (after orphan cleanup)' : ''}`)
195237

196-
// Idempotently add .forge/ to git exclude so overflow/scratch files
197-
// never enter loop commits.
198-
if (info.directory) {
199-
const excludeRes = git.revParseGitPath(info.directory, 'info/exclude')
200-
if (excludeRes.ok && excludeRes.stdout) {
201-
const excludeFile = excludeRes.stdout.trim()
202-
try {
203-
const content = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : ''
204-
if (!content.split('\n').some((l) => l.trim() === '.forge/')) {
205-
appendFileSync(excludeFile, '\n.forge/\n')
206-
logger.log(`forge-adapter: added .forge/ to git exclude in ${info.directory}`)
207-
}
208-
} catch (err) {
209-
logger.log(`forge-adapter: could not update git exclude: ${err instanceof Error ? err.message : String(err)}`)
210-
}
211-
}
238+
// Idempotently add .forge/ (overflow/scratch) and any forge-written
239+
// opencode config to git exclude so they never enter loop commits.
240+
const excludePatterns = ['.forge/']
241+
const cfgResult = writeWorktreeOpencodeConfig({
242+
directory: info.directory,
243+
config: worktreeOpencodeConfig,
244+
logger,
245+
})
246+
if (cfgResult.written) {
247+
excludePatterns.push(WORKTREE_OPENCODE_CONFIG_FILENAME)
212248
}
249+
addToGitExclude(info.directory, excludePatterns)
213250

214251
if (sandboxManager) {
215252
try {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { join } from 'path'
2+
import { existsSync, writeFileSync } from 'fs'
3+
import type { Logger } from '../types'
4+
5+
/** Filename forge writes the inline opencode config to inside a worktree. */
6+
export const WORKTREE_OPENCODE_CONFIG_FILENAME = 'opencode.jsonc'
7+
8+
/** Filenames opencode discovers as a project config; any present means "already configured". */
9+
const OPENCODE_CONFIG_FILENAMES = ['opencode.jsonc', 'opencode.json'] as const
10+
11+
type WriteWorktreeOpencodeConfigReason = 'no-config' | 'exists' | 'written' | 'error'
12+
13+
export interface WriteWorktreeOpencodeConfigInput {
14+
/** Absolute worktree root directory. */
15+
directory: string
16+
/** Inline config object from `loop.worktreeOpencodeConfig` (may be undefined/empty). */
17+
config: Record<string, unknown> | undefined
18+
logger: Logger
19+
}
20+
21+
export interface WriteWorktreeOpencodeConfigResult {
22+
written: boolean
23+
reason: WriteWorktreeOpencodeConfigReason
24+
/** Path written when `written` is true; undefined otherwise. */
25+
path?: string
26+
}
27+
28+
/**
29+
* Write the inline opencode config into a worktree as `opencode.jsonc`.
30+
* Skip-if-exists (never overwrites a committed opencode config) and non-fatal:
31+
* any failure is logged and reported via `reason: 'error'`, never thrown.
32+
*/
33+
export function writeWorktreeOpencodeConfig(
34+
input: WriteWorktreeOpencodeConfigInput,
35+
): WriteWorktreeOpencodeConfigResult {
36+
const { directory, config, logger } = input
37+
if (!config || typeof config !== 'object' || Object.keys(config).length === 0) {
38+
return { written: false, reason: 'no-config' }
39+
}
40+
const existing = OPENCODE_CONFIG_FILENAMES.find((name) => existsSync(join(directory, name)))
41+
if (existing) {
42+
logger.log(`worktree-opencode-config: ${existing} already present in ${directory}; skipping`)
43+
return { written: false, reason: 'exists' }
44+
}
45+
const target = join(directory, WORKTREE_OPENCODE_CONFIG_FILENAME)
46+
try {
47+
writeFileSync(target, JSON.stringify(config, null, 2) + '\n', 'utf-8')
48+
logger.log(`worktree-opencode-config: wrote ${WORKTREE_OPENCODE_CONFIG_FILENAME} in ${directory}`)
49+
return { written: true, reason: 'written', path: target }
50+
} catch (err) {
51+
logger.log(
52+
`worktree-opencode-config: failed to write ${WORKTREE_OPENCODE_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`,
53+
)
54+
return { written: false, reason: 'error' }
55+
}
56+
}

‎test/helpers/fake-git.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const defaultOk: GitResult = { ok: true, status: 0, stdout: '', stderr: '' }
66
export function createFakeGitService(overrides?: Partial<GitService>): GitService {
77
return {
88
addAll: vi.fn<[string], GitResult>(() => ({ ...defaultOk })),
9+
isPathTracked: vi.fn<[string, string], boolean>(() => false),
910
statusPorcelain: vi.fn<[string], GitResult>(() => ({ ...defaultOk })),
1011
commit: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })),
1112
isInsideWorkTree: vi.fn<[string], boolean>(() => true),

0 commit comments

Comments
 (0)