Skip to content
Open
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
29 changes: 29 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Default log path: `~/.local/share/opencode/forge/logs/forge.log` or `$XDG_DATA_H
| `loop.stallTimeoutMs` | `60000` | Stall watchdog timeout in milliseconds. |
| `loop.maxConsecutiveStalls` | `5` | Consecutive stalls before terminating with `stall_timeout`. `0` disables stall termination. |
| `loop.allowExternalDirectories` | unset | Absolute host directories that loop, audit, and post-action sessions may read despite worktree isolation. |
| `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. |

### Worktree Logging

Expand Down Expand Up @@ -86,6 +87,34 @@ Example:
}
```

### Worktree Opencode Config

`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.

The config is written only when:
- The worktree has no existing `opencode.json` or `opencode.jsonc` (committed configs are never overwritten)
- The value is a non-empty object

The written file is added to the worktree's git exclude so it never appears in `git status` or loop commits.

Example — expose Chrome DevTools MCP inside every loop:

```jsonc
{
"loop": {
"worktreeOpencodeConfig": {
"mcp": {
"chrome-devtools": {
"type": "local",
"command": ["npx", "chrome-devtools-mcp@latest", "--isolated"],
"enabled": true
}
}
}
}
}
```

## Group Launch

`groupLaunch` configures parallel feature orchestration (see the [`launch-group`](tools.md#group-tools) tool).
Expand Down
1 change: 1 addition & 0 deletions docs/loop-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Benefits of worktree isolation:
- Isolation from ongoing development
- Safe to experiment without affecting main branch
- Branch preserved for later review/merge
- 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))

## Sandbox Integration

Expand Down
9 changes: 9 additions & 0 deletions forge-config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@
// identical container path so absolute temp paths match host<->container. Created on startup
// if missing. Defaults to "/tmp/oc-forge".
// "tmpDir": "/tmp/oc-forge"
//
// Inline opencode config written as opencode.jsonc into each loop worktree (skip-if-exists;
// never overwrites a committed opencode.json/opencode.jsonc). Primarily for enabling per-loop
// MCP servers. The written file is git-excluded so it never enters loop commits.
// "worktreeOpencodeConfig": {
// "mcp": {
// "my-server": { "type": "local", "command": ["npx", "some-mcp-server"], "enabled": true }
// }
// }
},

// Max loops from one group running concurrently. Also bounds concurrent planning passes. Default 3.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
sandboxManager,
gitService: defaultGitService,
getTeardownContext: (loopName) => pendingTeardowns.get(loopName),
worktreeOpencodeConfig: config.loop?.worktreeOpencodeConfig,
}))
logger.log(`Registered forge workspace adapter (worktrees under ${join(dataDir, 'worktrees')})`)
}
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export interface LoopConfig {
* Defaults to `/tmp/oc-forge`. The directory is created on startup if missing.
*/
tmpDir?: string
/**
* Inline opencode config object written verbatim as `opencode.jsonc` at the root of each
* freshly created loop worktree, enabling per-loop opencode customization (primarily MCP
* servers, e.g. `{ "mcp": { ... } }`). Written only when the worktree has no existing
* `opencode.json`/`opencode.jsonc` (committed configs are never overwritten). The written file
* is added to the worktree's git exclude so it never enters loop commits. An empty object or
* omission disables the behavior.
*/
worktreeOpencodeConfig?: Record<string, unknown>
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '0.5.0-beta.9'
export const VERSION = '0.5.0'
43 changes: 29 additions & 14 deletions src/workspace/forge-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { SandboxManager } from '../sandbox/manager'
import { forgeBranchName, forgeWorktreeDir, forgeWorktreeSlug } from './forge-naming'
import { cleanupLoopWorktree } from '../utils/worktree-cleanup'
import { defaultGitService, type GitService } from '../utils/git-service'
import { writeWorktreeOpencodeConfig, WORKTREE_OPENCODE_CONFIG_FILENAME } from './worktree-opencode-config'


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

const DEFAULT_TEARDOWN_CONTEXT: TeardownContext = {
Expand All @@ -48,7 +51,7 @@ const DEFAULT_TEARDOWN_CONTEXT: TeardownContext = {
}

export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAdapter {
const { dataDir, logger, sandboxManager, getTeardownContext, gitService: gitServiceOpt } = deps
const { dataDir, logger, sandboxManager, getTeardownContext, gitService: gitServiceOpt, worktreeOpencodeConfig } = deps
const git = gitServiceOpt ?? defaultGitService

function deriveLoopName(info: WorkspaceInfo): string {
Expand All @@ -65,6 +68,21 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
return dir
}

function addToGitExclude(directory: string, pattern: string): void {
const excludeRes = git.revParseGitPath(directory, 'info/exclude')
if (!excludeRes.ok || !excludeRes.stdout) return
const excludeFile = excludeRes.stdout.trim()
try {
const content = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : ''
if (!content.split('\n').some((l) => l.trim() === pattern)) {
appendFileSync(excludeFile, `\n${pattern}\n`)
logger.log(`forge-adapter: added ${pattern} to git exclude in ${directory}`)
}
} catch (err) {
logger.log(`forge-adapter: could not update git exclude: ${err instanceof Error ? err.message : String(err)}`)
}
}

function resolveLoopName(info: WorkspaceInfo): string {
try {
return deriveLoopName(info)
Expand Down Expand Up @@ -196,19 +214,16 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
// Idempotently add .forge/ to git exclude so overflow/scratch files
// never enter loop commits.
if (info.directory) {
const excludeRes = git.revParseGitPath(info.directory, 'info/exclude')
if (excludeRes.ok && excludeRes.stdout) {
const excludeFile = excludeRes.stdout.trim()
try {
const content = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : ''
if (!content.split('\n').some((l) => l.trim() === '.forge/')) {
appendFileSync(excludeFile, '\n.forge/\n')
logger.log(`forge-adapter: added .forge/ to git exclude in ${info.directory}`)
}
} catch (err) {
logger.log(`forge-adapter: could not update git exclude: ${err instanceof Error ? err.message : String(err)}`)
}
}
addToGitExclude(info.directory, '.forge/')
}

const cfgResult = writeWorktreeOpencodeConfig({
directory: info.directory,
config: worktreeOpencodeConfig,
logger,
})
if (cfgResult.written) {
addToGitExclude(info.directory, WORKTREE_OPENCODE_CONFIG_FILENAME)
}

if (sandboxManager) {
Expand Down
56 changes: 56 additions & 0 deletions src/workspace/worktree-opencode-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { join } from 'path'
import { existsSync, writeFileSync } from 'fs'
import type { Logger } from '../types'

/** Filename forge writes the inline opencode config to inside a worktree. */
export const WORKTREE_OPENCODE_CONFIG_FILENAME = 'opencode.jsonc'

/** Filenames opencode discovers as a project config; any present means "already configured". */
const OPENCODE_CONFIG_FILENAMES = ['opencode.jsonc', 'opencode.json'] as const

export type WriteWorktreeOpencodeConfigReason = 'no-config' | 'exists' | 'written' | 'error'

export interface WriteWorktreeOpencodeConfigInput {
/** Absolute worktree root directory. */
directory: string
/** Inline config object from `loop.worktreeOpencodeConfig` (may be undefined/empty). */
config: Record<string, unknown> | undefined
logger: Logger
}

export interface WriteWorktreeOpencodeConfigResult {
written: boolean
reason: WriteWorktreeOpencodeConfigReason
/** Path written when `written` is true; undefined otherwise. */
path?: string
}

/**
* Write the inline opencode config into a worktree as `opencode.jsonc`.
* Skip-if-exists (never overwrites a committed opencode config) and non-fatal:
* any failure is logged and reported via `reason: 'error'`, never thrown.
*/
export function writeWorktreeOpencodeConfig(
input: WriteWorktreeOpencodeConfigInput,
): WriteWorktreeOpencodeConfigResult {
const { directory, config, logger } = input
if (!config || typeof config !== 'object' || Object.keys(config).length === 0) {
return { written: false, reason: 'no-config' }
}
const existing = OPENCODE_CONFIG_FILENAMES.find((name) => existsSync(join(directory, name)))
if (existing) {
logger.log(`worktree-opencode-config: ${existing} already present in ${directory}; skipping`)
return { written: false, reason: 'exists' }
}
const target = join(directory, WORKTREE_OPENCODE_CONFIG_FILENAME)
try {
writeFileSync(target, JSON.stringify(config, null, 2) + '\n', 'utf-8')
logger.log(`worktree-opencode-config: wrote ${WORKTREE_OPENCODE_CONFIG_FILENAME} in ${directory}`)
return { written: true, reason: 'written', path: target }
} catch (err) {
logger.log(
`worktree-opencode-config: failed to write ${WORKTREE_OPENCODE_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`,
)
return { written: false, reason: 'error' }
}
}
85 changes: 84 additions & 1 deletion test/workspace/forge-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createForgeWorkspaceAdapter, type ForgeAdapterDeps } from '../../src/workspace/forge-adapter'
import { join, isAbsolute } from 'path'
import { mkdtempSync, existsSync, rmSync, readFileSync } from 'fs'
import { mkdtempSync, existsSync, rmSync, readFileSync, writeFileSync } from 'fs'
import { execSync } from 'child_process'
import { tmpdir } from 'os'

Expand Down Expand Up @@ -436,4 +436,87 @@ describe('createForgeWorkspaceAdapter', () => {
const target = adapter.target(configured)
expect(target).toEqual({ type: 'local', directory: configured.directory })
})

it('create writes opencode.jsonc and adds it to git exclude', async () => {
const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-opencode-'))
try {
execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' })
const config = { mcp: { demo: { type: 'local', command: ['x'], enabled: true } } }
const adapter = createForgeWorkspaceAdapter({
dataDir: tmpDataDir,
logger,
worktreeOpencodeConfig: config,
})
const configured = adapter.configure(makeInfo('opencode-loop', tmpRepo))

await adapter.create(configured, {})

const configPath = join(configured.directory, 'opencode.jsonc')
expect(existsSync(configPath)).toBe(true)
expect(JSON.parse(readFileSync(configPath, 'utf-8'))).toEqual(config)

let excludePath = execSync(`git -C "${configured.directory}" rev-parse --git-path info/exclude`, {
encoding: 'utf-8',
}).trim()
if (!isAbsolute(excludePath)) {
excludePath = join(configured.directory, excludePath)
}
const excludeContent = readFileSync(excludePath, 'utf-8')
expect(excludeContent).toContain('opencode.jsonc')
} finally {
if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true })
}
})

it('create does not overwrite a committed opencode.jsonc', async () => {
const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-committed-'))
try {
execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' })
const sentinel = { sentinel: true }
writeFileSync(join(tmpRepo, 'opencode.jsonc'), JSON.stringify(sentinel) + '\n')
execSync('git add opencode.jsonc && git commit -m "add opencode config"', { cwd: tmpRepo, encoding: 'utf-8' })

const adapter = createForgeWorkspaceAdapter({
dataDir: tmpDataDir,
logger,
worktreeOpencodeConfig: { mcp: { other: {} } },
})
const configured = adapter.configure(makeInfo('committed-opencode-loop', tmpRepo))

await adapter.create(configured, {})

const configPath = join(configured.directory, 'opencode.jsonc')
expect(JSON.parse(readFileSync(configPath, 'utf-8'))).toEqual(sentinel)

// Also verify the committed config was not added to exclude.
let excludePath = execSync(`git -C "${configured.directory}" rev-parse --git-path info/exclude`, {
encoding: 'utf-8',
}).trim()
if (!isAbsolute(excludePath)) {
excludePath = join(configured.directory, excludePath)
}
const excludeContent = readFileSync(excludePath, 'utf-8')
expect(excludeContent).not.toContain('opencode.jsonc')
} finally {
if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true })
}
})

it('create does not write opencode.jsonc when no config provided', async () => {
const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-no-opencode-'))
try {
execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' })
const adapter = createForgeWorkspaceAdapter({
dataDir: tmpDataDir,
logger,
})
const configured = adapter.configure(makeInfo('no-opencode-loop', tmpRepo))

await adapter.create(configured, {})

expect(existsSync(join(configured.directory, 'opencode.jsonc'))).toBe(false)
} finally {
if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true })
}
})
})
Loading