@vibe-forge/adapter-opencode 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,13 +1,116 @@
1
- # OpenCode Adapter 目录说明
1
+ # OpenCode Adapter
2
2
 
3
- - `src/runtime/common/`:纯配置与参数映射层,只做 prompt、tools、permissions、model、mcp、session record 转换,不承载进程控制
4
- - `src/runtime/session/`:运行时层,负责 child env、skill bridge、direct/stream 会话执行与错误传播
5
- - `src/runtime/common.ts` / `src/runtime/session.ts`:稳定入口文件,只做 re-export 或轻量路由
6
- - `__tests__/runtime-*.spec.ts`:按能力拆分测试;公共 mock 和文件系统 helper 放在 `__tests__/runtime-test-helpers.ts`
3
+ ## 文档入口
7
4
 
8
- 约束:
5
+ - `docs/HOOKS.md`
6
+ - 通用 hooks 方案、事件矩阵、`.ai/.mock` 托管配置布局
7
+ - `docs/HOOKS-REFERENCE.md`
8
+ - 真实 CLI 验证命令、本次改造经验、共用实现入口
9
+ - `apps/cli/src/AGENTS.md`
10
+ - CLI hook bridge 与 `call-hook.js` 入口
11
+
12
+ ## 目录职责
13
+
14
+ - `src/runtime/common/`
15
+ - 纯配置与参数映射层,只做 prompt、tools、permissions、model、mcp、session record 转换
16
+ - 不承载进程控制
17
+ - `src/runtime/session/`
18
+ - 运行时层,负责 child env、skill bridge、direct/stream 会话执行与错误传播
19
+ - `src/runtime/native-hooks.ts`
20
+ - 把 `.ai/.mock/.config/opencode/` 写成托管 config/plugin
21
+ - 把真实用户配置镜像到 mock config dir
22
+ - `src/runtime/common.ts` / `src/runtime/session.ts`
23
+ - 稳定入口文件,只做 re-export 或轻量路由
24
+ - `__tests__/runtime-*.spec.ts`
25
+ - 按能力拆分测试;公共 mock 和文件系统 helper 放在 `__tests__/runtime-test-helpers.ts`
26
+
27
+ ## Hooks 维护入口
28
+
29
+ - `src/runtime/native-hooks.ts`
30
+ - 负责 `.ai/.mock/.config/opencode/plugins/vibe-forge-hooks.js`
31
+ - `src/runtime/session/skill-config.ts`
32
+ - base config、plugins、skills 镜像到 session config dir
33
+ - `src/runtime/session/child-env.ts`
34
+ - session 级 `opencode.json`、provider config、child env
35
+ - `src/runtime/session/stream.ts`
36
+ - `opencode run --format json` 事件流消费与最终文本提取
37
+ - `src/runtime/common/tools.ts`
38
+ - `opencode run` 参数构造
39
+ - `packages/core/src/hooks/native.ts`
40
+ - `packages/core/src/hooks/bridge.ts`
41
+ - `packages/core/src/controllers/task/run.ts`
42
+
43
+ 维护时保持这条边界:
44
+
45
+ - OpenCode adapter 负责 plugin/config dir/session env
46
+ - CLI 或 upstream 事件流负责把 tool 事件送出来
47
+ - core 负责统一 hook runtime 和去重
48
+
49
+ ## 真实 CLI 验证
50
+
51
+ 优先尝试包装层:
52
+
53
+ 仓库根快捷命令:
54
+
55
+ ```bash
56
+ pnpm test:e2e:adapters
57
+ pnpm tools adapter-e2e run opencode
58
+ pnpm tools adapter-e2e test opencode-read-once --update
59
+ ```
60
+
61
+ 这条命令会先试 `vf --adapter opencode`,超时后自动 fallback 到 upstream `opencode run --format json`。
62
+ 它默认也会启动本地 mock LLM server,并通过仓库根 `.ai.config.json` 里的 `hook-smoke-mock` model service 驱动 OpenCode。adapter E2E 的共享 harness 在 `scripts/adapter-e2e/`,scripts CLI 入口在 `scripts/cli.ts`。
63
+ case 定义、spec 和期望快照统一维护在 `scripts/__tests__/adapter-e2e/`。
64
+
65
+ ```bash
66
+ __VF_PROJECT_AI_CTX_ID__='hooks-smoke-opencode' \
67
+ node apps/cli/cli.js \
68
+ --adapter opencode \
69
+ --model hook-smoke-mock,opencode-hooks \
70
+ --print \
71
+ --no-inject-default-system-prompt \
72
+ --exclude-mcp-server ChromeDevtools \
73
+ --session-id '<uuid>' \
74
+ "Use the read tool exactly once on README.md, then reply with exactly E2E_OPENCODE and nothing else."
75
+ ```
76
+
77
+ 如果包装层没有自然结束,直接复用 session config dir 调 upstream:
78
+
79
+ ```bash
80
+ HOME="$PWD/.ai/.mock" \
81
+ OPENCODE_CONFIG_DIR="$PWD/.ai/.mock/.opencode-adapter/<sessionId>/config-dir" \
82
+ __VF_VIBE_FORGE_OPENCODE_HOOKS_ACTIVE__='1' \
83
+ __VF_PROJECT_WORKSPACE_FOLDER__="$PWD" \
84
+ __VF_PROJECT_NODE_PATH__="$(which node)" \
85
+ __VF_PROJECT_REAL_HOME__="$HOME" \
86
+ __VF_PROJECT_CLI_PACKAGE_DIR__="$PWD/apps/cli" \
87
+ __VF_PROJECT_PACKAGE_DIR__="$PWD/apps/cli" \
88
+ __VF_OPENCODE_TASK_SESSION_ID__='<sessionId>' \
89
+ __VF_OPENCODE_HOOK_RUNTIME__='cli' \
90
+ packages/adapters/opencode/node_modules/.bin/opencode run \
91
+ --print-logs \
92
+ --format json \
93
+ --model opencode/gpt-5-nano \
94
+ --dir "$PWD" \
95
+ "Use the read tool exactly once on README.md, then reply with exactly E2E_OPENCODE and nothing else."
96
+ ```
97
+
98
+ 通过标准:
99
+
100
+ - 终端输出 `E2E_OPENCODE`
101
+ - `.ai/logs/<ctxId>/<sessionId>.log.md` 出现 `SessionStart` / `PreToolUse` / `PostToolUse` / `Stop`
102
+ - `.ai/.mock/.config/opencode/opencode.json` 与 `plugins/vibe-forge-hooks.js` 仍落在 mock config dir
103
+
104
+ OpenCode 这次改造最关键的经验:
105
+
106
+ - 优先把 `opencode run --format json` 当成稳定基线,不要再依赖最终 stdout 文本猜事件
107
+ - session config 不要只靠 `OPENCODE_CONFIG_CONTENT`,优先写真实 `opencode.json`
108
+ - 包装层与 upstream 直跑当前仍可能有行为差异,文档里必须写清楚
109
+
110
+ ## 约束
9
111
 
10
112
  - 单文件保持在 200 行以内;接近 160 行时优先继续拆,而不是继续堆分支
11
113
  - 新增逻辑先判断属于“映射层”还是“运行时层”,不要再把两类职责混回一个文件
12
114
  - 需要 mock `child_process` 的测试,mock 定义放在各自 spec 顶部;helper 不直接持有全局 mock 状态
13
115
  - 修改 adapter 行为时,至少补一条对应的 runtime 或 common 回归测试
116
+ - OpenCode 的 native hooks 通过 `.ai/.mock/.config/opencode/plugins/vibe-forge-hooks.js` 接入,不再直接依赖 stdout 文本桥接
@@ -0,0 +1,73 @@
1
+ import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ import { ensureOpenCodeNativeHooksInstalled } from '../src/runtime/native-hooks'
8
+
9
+ const tempDirs: string[] = []
10
+
11
+ const createTempDir = async (prefix: string) => {
12
+ const dir = await mkdtemp(join(tmpdir(), prefix))
13
+ tempDirs.push(dir)
14
+ return dir
15
+ }
16
+
17
+ const writeDocument = async (filePath: string, content: string) => {
18
+ await mkdir(dirname(filePath), { recursive: true })
19
+ await writeFile(filePath, content, { encoding: 'utf8', flag: 'w' })
20
+ }
21
+
22
+ afterEach(async () => {
23
+ delete process.env.__VF_PROJECT_REAL_HOME__
24
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
25
+ })
26
+
27
+ describe('ensureOpenCodeNativeHooksInstalled', () => {
28
+ it('creates an isolated mock config dir and installs the managed plugin there', async () => {
29
+ const workspace = await createTempDir('opencode-hooks-workspace-')
30
+ const realHome = await createTempDir('opencode-hooks-home-')
31
+ const mockHome = join(workspace, '.ai', '.mock')
32
+
33
+ await writeDocument(join(realHome, '.config', 'opencode', 'opencode.json'), JSON.stringify({
34
+ $schema: 'https://opencode.ai/config.json'
35
+ }))
36
+ await writeDocument(join(realHome, '.config', 'opencode', 'commands', 'review.md'), '# review')
37
+ process.env.__VF_PROJECT_REAL_HOME__ = realHome
38
+
39
+ const ctx = {
40
+ cwd: workspace,
41
+ env: {
42
+ HOME: mockHome
43
+ },
44
+ logger: {
45
+ info: vi.fn(),
46
+ warn: vi.fn(),
47
+ error: vi.fn(),
48
+ debug: vi.fn()
49
+ },
50
+ assets: {
51
+ hookPlugins: [
52
+ {
53
+ id: 'hookPlugin:project:logger'
54
+ }
55
+ ]
56
+ }
57
+ } as any
58
+
59
+ const installed = await ensureOpenCodeNativeHooksInstalled(ctx)
60
+ const managedConfigDir = join(mockHome, '.config', 'opencode')
61
+ const pluginPath = join(managedConfigDir, 'plugins', 'vibe-forge-hooks.js')
62
+
63
+ expect(installed).toBe(true)
64
+ expect(ctx.env.__VF_PROJECT_AI_OPENCODE_NATIVE_HOOKS_AVAILABLE__).toBe('1')
65
+ expect(ctx.env.OPENCODE_CONFIG_DIR).toBe(managedConfigDir)
66
+ expect((await lstat(managedConfigDir)).isDirectory()).toBe(true)
67
+ expect((await lstat(join(managedConfigDir, 'commands'))).isSymbolicLink()).toBe(true)
68
+ expect(await readFile(pluginPath, 'utf8')).toContain('tool.execute.before')
69
+ expect(JSON.parse(await readFile(join(managedConfigDir, 'opencode.json'), 'utf8'))).toMatchObject({
70
+ $schema: 'https://opencode.ai/config.json'
71
+ })
72
+ })
73
+ })
@@ -1,4 +1,4 @@
1
- import { lstat } from 'node:fs/promises'
1
+ import { lstat, readFile } from 'node:fs/promises'
2
2
  import { execFile, spawn } from 'node:child_process'
3
3
  import { join } from 'node:path'
4
4
 
@@ -87,7 +87,10 @@ describe('createOpenCodeSession runtime config', () => {
87
87
  await flushAsyncWork()
88
88
 
89
89
  const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: Record<string, string> }
90
- const inlineConfig = JSON.parse(spawnOptions.env?.OPENCODE_CONFIG_CONTENT ?? '{}') as {
90
+ const configDir = spawnOptions.env?.OPENCODE_CONFIG_DIR
91
+ const inlineConfig = JSON.parse(
92
+ configDir ? await readFile(join(configDir, 'opencode.json'), 'utf8') : '{}'
93
+ ) as {
91
94
  mcp?: Record<string, unknown>
92
95
  }
93
96
 
@@ -153,4 +156,68 @@ describe('createOpenCodeSession runtime config', () => {
153
156
  expect((await lstat(join(configDir!, 'skills', 'research'))).isSymbolicLink()).toBe(true)
154
157
  await expect(lstat(join(configDir!, 'skills', 'review'))).rejects.toThrow()
155
158
  })
159
+
160
+ it('merges base opencode.json into the session config without mutating the source file', async () => {
161
+ const workspace = await createWorkspace()
162
+ const baseConfigDir = join(workspace, 'user-opencode-config')
163
+ await writeDocument(
164
+ join(baseConfigDir, 'opencode.json'),
165
+ JSON.stringify({
166
+ instructions: ['base-instructions.md'],
167
+ theme: 'ocean',
168
+ nested: {
169
+ keep: true
170
+ }
171
+ }, null, 2)
172
+ )
173
+ mockExecFileJsonResponses(execFileMock, [
174
+ { id: 'sess_merge', title: 'Vibe Forge:session-merge', updatedAt: '2026-03-26T00:00:00.000Z' }
175
+ ])
176
+ spawnMock.mockImplementation(() => makeProc({ stdout: 'merged\n' }))
177
+
178
+ const { ctx } = makeCtx({
179
+ cwd: workspace,
180
+ env: {
181
+ OPENCODE_CONFIG_DIR: baseConfigDir
182
+ }
183
+ })
184
+
185
+ await createOpenCodeSession(ctx, {
186
+ type: 'create',
187
+ runtime: 'server',
188
+ sessionId: 'session-merge',
189
+ description: 'merge config',
190
+ onEvent: () => {}
191
+ } as any)
192
+
193
+ await flushAsyncWork()
194
+
195
+ const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: Record<string, string> }
196
+ const configDir = spawnOptions.env?.OPENCODE_CONFIG_DIR
197
+ const sessionConfig = JSON.parse(
198
+ configDir ? await readFile(join(configDir, 'opencode.json'), 'utf8') : '{}'
199
+ ) as {
200
+ instructions?: string[]
201
+ theme?: string
202
+ nested?: Record<string, unknown>
203
+ }
204
+ const sourceConfig = JSON.parse(
205
+ await readFile(join(baseConfigDir, 'opencode.json'), 'utf8')
206
+ ) as {
207
+ instructions?: string[]
208
+ theme?: string
209
+ nested?: Record<string, unknown>
210
+ }
211
+
212
+ expect(sessionConfig.instructions).toContain('base-instructions.md')
213
+ expect(sessionConfig.theme).toBe('ocean')
214
+ expect(sessionConfig.nested).toMatchObject({ keep: true })
215
+ expect(sourceConfig).toEqual({
216
+ instructions: ['base-instructions.md'],
217
+ theme: 'ocean',
218
+ nested: {
219
+ keep: true
220
+ }
221
+ })
222
+ })
156
223
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-forge/adapter-opencode",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "OpenCode Adapter for Vibe Forge",
5
5
  "imports": {
6
6
  "#~/*.js": {
@@ -54,6 +54,6 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "opencode-ai": "1.3.2",
57
- "@vibe-forge/core": "^0.7.4"
57
+ "@vibe-forge/core": "^0.7.5"
58
58
  }
59
59
  }
package/src/AGENTS.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # `src` 目录说明
2
2
 
3
+ hooks 相关改动先交叉看:
4
+
5
+ - `../AGENTS.md`
6
+ - `../../../docs/HOOKS.md`
7
+ - `../../../docs/HOOKS-REFERENCE.md`
8
+
3
9
  ## 入口层
4
10
 
5
11
  - `index.ts`:adapter 包对外入口,只负责导出 `init` / `query` 能力
@@ -21,6 +27,12 @@
21
27
  - `runtime/common.ts` / `runtime/session.ts`
22
28
  - 仅作为稳定入口和 re-export 路由
23
29
  - 新逻辑不要继续堆回这两个文件
30
+ - `runtime/native-hooks.ts`
31
+ - `runtime/session/child-env.ts`
32
+ - `runtime/session/skill-config.ts`
33
+ - `runtime/session/stream.ts`
34
+ - `runtime/common/tools.ts`
35
+ - 这几处共同决定 OpenCode native hooks、session config dir 和 JSON 事件流
24
36
 
25
37
  ## 依赖方向
26
38
 
@@ -36,3 +48,4 @@
36
48
  - 涉及 OpenCode CLI 参数、env、session resume 语义的改动,至少补一条 runtime 回归测试
37
49
  - 涉及 permissions / tools / model / mcp 映射的改动,至少补一条 common 回归测试
38
50
  - 若新增目录,先在本文件补一句职责说明,再落代码
51
+ - hooks 改动不要只跑单测,至少再按 `../AGENTS.md` 里的真实 CLI 路径补一轮 smoke
@@ -8,6 +8,7 @@ import { buildToolPermissionConfig, mapPermissionModeToOpenCode } from './permis
8
8
  import { buildToolConfig } from './tools'
9
9
 
10
10
  export const buildInlineConfigContent = (params: {
11
+ baseConfigContent?: Record<string, unknown>
11
12
  adapterConfigContent?: Record<string, unknown>
12
13
  envConfigContent?: Record<string, unknown>
13
14
  permissionMode?: AdapterQueryOptions['permissionMode']
@@ -17,7 +18,10 @@ export const buildInlineConfigContent = (params: {
17
18
  systemPromptFile?: string
18
19
  providerConfig?: Record<string, unknown>
19
20
  }) => {
20
- const mergedBaseConfig = deepMerge(params.envConfigContent ?? {}, params.adapterConfigContent ?? {})
21
+ const mergedBaseConfig = deepMerge(
22
+ deepMerge(params.baseConfigContent ?? {}, params.envConfigContent ?? {}),
23
+ params.adapterConfigContent ?? {}
24
+ )
21
25
  const inheritedPermission = normalizePermissionNode(mergedBaseConfig.permission)
22
26
  const hasToolFilter = (params.tools?.include?.length ?? 0) > 0 || (params.tools?.exclude?.length ?? 0) > 0
23
27
  const basePermission = params.permissionMode === 'dontAsk' || params.permissionMode === 'bypassPermissions'
@@ -106,10 +106,12 @@ export const buildOpenCodeRunArgs = (params: {
106
106
  agent?: string
107
107
  share?: boolean
108
108
  title?: string
109
+ dir?: string
109
110
  opencodeSessionId?: string
110
111
  extraOptions?: string[]
112
+ format?: 'default' | 'json'
111
113
  }) => {
112
- const args = ['run', '--format', 'default']
114
+ const args = ['run', '--format', params.format ?? 'default']
113
115
 
114
116
  if (params.opencodeSessionId) {
115
117
  args.push('--session', params.opencodeSessionId)
@@ -120,6 +122,7 @@ export const buildOpenCodeRunArgs = (params: {
120
122
  if (params.model) args.push('--model', params.model)
121
123
  if (params.agent) args.push('--agent', params.agent)
122
124
  if (params.share) args.push('--share')
125
+ if (params.dir) args.push('--dir', params.dir)
123
126
 
124
127
  for (const file of params.files) args.push('--file', file)
125
128
  args.push(...sanitizeOpenCodeExtraOptions(params.extraOptions))
@@ -7,6 +7,7 @@ import { promisify } from 'node:util'
7
7
  import type { AdapterCtx } from '@vibe-forge/core/adapter'
8
8
 
9
9
  import { resolveOpenCodeBinaryPath } from '#~/paths.js'
10
+ import { ensureOpenCodeNativeHooksInstalled } from './native-hooks'
10
11
 
11
12
  const execFileAsync = promisify(execFile)
12
13
 
@@ -38,18 +39,15 @@ export const initOpenCodeAdapter = async (ctx: AdapterCtx) => {
38
39
  const realHome = process.env.__VF_PROJECT_REAL_HOME__
39
40
  const aiHome = process.env.HOME
40
41
 
41
- if (!realHome || !aiHome) return
42
-
43
- await ensureSymlink(
44
- join(realHome, '.config', 'opencode'),
45
- join(aiHome, '.config', 'opencode')
46
- )
47
- await ensureSymlink(
48
- join(realHome, '.local', 'share', 'opencode', 'auth.json'),
49
- join(aiHome, '.local', 'share', 'opencode', 'auth.json')
50
- )
51
- await ensureSymlink(
52
- join(realHome, '.local', 'share', 'opencode', 'mcp-auth.json'),
53
- join(aiHome, '.local', 'share', 'opencode', 'mcp-auth.json')
54
- )
42
+ if (realHome && aiHome) {
43
+ await ensureSymlink(
44
+ join(realHome, '.local', 'share', 'opencode', 'auth.json'),
45
+ join(aiHome, '.local', 'share', 'opencode', 'auth.json')
46
+ )
47
+ await ensureSymlink(
48
+ join(realHome, '.local', 'share', 'opencode', 'mcp-auth.json'),
49
+ join(aiHome, '.local', 'share', 'opencode', 'mcp-auth.json')
50
+ )
51
+ }
52
+ await ensureOpenCodeNativeHooksInstalled(ctx)
55
53
  }
@@ -0,0 +1,242 @@
1
+ import { access, mkdir, readdir, rm, symlink, writeFile } from 'node:fs/promises'
2
+ import { dirname, resolve } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import type { AdapterCtx } from '@vibe-forge/core/adapter'
6
+ import {
7
+ hasManagedHookPlugins,
8
+ prepareManagedHookRuntime,
9
+ resolveHookCliScriptPath,
10
+ writeJsonFile
11
+ } from '@vibe-forge/core/hooks'
12
+
13
+ const MANAGED_PLUGIN_FILE_NAME = 'vibe-forge-hooks.js'
14
+ const DEFAULT_OPENCODE_CONFIG = {
15
+ $schema: 'https://opencode.ai/config.json'
16
+ }
17
+
18
+ const pathExists = async (targetPath: string) => {
19
+ try {
20
+ await access(targetPath)
21
+ return true
22
+ } catch {
23
+ return false
24
+ }
25
+ }
26
+
27
+ const ensureSymlinkTarget = async (sourcePath: string, targetPath: string) => {
28
+ await rm(targetPath, { recursive: true, force: true })
29
+ await mkdir(dirname(targetPath), { recursive: true })
30
+ await symlink(sourcePath, targetPath)
31
+ }
32
+
33
+ const mirrorDirectory = async (sourceDir: string, targetDir: string) => {
34
+ if (!await pathExists(sourceDir)) return
35
+ await rm(targetDir, { recursive: true, force: true })
36
+ await mkdir(dirname(targetDir), { recursive: true })
37
+ await symlink(sourceDir, targetDir)
38
+ }
39
+
40
+ const mirrorFile = async (sourcePath: string, targetPath: string) => {
41
+ if (!await pathExists(sourcePath)) return false
42
+ await ensureSymlinkTarget(sourcePath, targetPath)
43
+ return true
44
+ }
45
+
46
+ const syncPluginDirectory = async (sourceDir: string | undefined, targetDir: string) => {
47
+ await mkdir(targetDir, { recursive: true })
48
+
49
+ const existingEntries = await readdir(targetDir).catch(() => [])
50
+ await Promise.all(existingEntries
51
+ .filter(entry => entry !== MANAGED_PLUGIN_FILE_NAME)
52
+ .map(entry => rm(resolve(targetDir, entry), { recursive: true, force: true })))
53
+
54
+ if (sourceDir == null || !await pathExists(sourceDir)) return
55
+
56
+ const sourceEntries = await readdir(sourceDir)
57
+ for (const entry of sourceEntries) {
58
+ if (entry === MANAGED_PLUGIN_FILE_NAME) continue
59
+ await ensureSymlinkTarget(resolve(sourceDir, entry), resolve(targetDir, entry))
60
+ }
61
+ }
62
+
63
+ const buildManagedPluginSource = () => {
64
+ const callHookPath = resolveHookCliScriptPath('call-hook.js')
65
+
66
+ return `import { spawnSync } from "node:child_process"
67
+ import process from "node:process"
68
+
69
+ const ACTIVE_MARKER = "__VF_VIBE_FORGE_OPENCODE_HOOKS_ACTIVE__"
70
+ const NODE_PATH = process.env.__VF_PROJECT_NODE_PATH__ ?? ${JSON.stringify(process.execPath)}
71
+ const CALL_HOOK_PATH = ${JSON.stringify(callHookPath)}
72
+ const SESSION_ID = process.env.__VF_OPENCODE_TASK_SESSION_ID__ ?? "opencode-session"
73
+ const RUNTIME = process.env.__VF_OPENCODE_HOOK_RUNTIME__
74
+
75
+ const parseJson = (value, fallback) => {
76
+ try {
77
+ return JSON.parse(value)
78
+ } catch {
79
+ return fallback
80
+ }
81
+ }
82
+
83
+ const createBaseInput = (hookEventName, cwd, canBlock) => ({
84
+ hookEventName,
85
+ cwd,
86
+ sessionId: SESSION_ID,
87
+ adapter: "opencode",
88
+ runtime: RUNTIME,
89
+ hookSource: "native",
90
+ canBlock,
91
+ })
92
+
93
+ const callVibeForgeHook = (payload) => {
94
+ if (process.env[ACTIVE_MARKER] !== "1") return { continue: true }
95
+
96
+ try {
97
+ const result = spawnSync(NODE_PATH, [CALL_HOOK_PATH], {
98
+ input: JSON.stringify(payload),
99
+ encoding: "utf8",
100
+ env: {
101
+ ...process.env,
102
+ __VF_PROJECT_WORKSPACE_FOLDER__: payload.cwd ?? process.env.__VF_PROJECT_WORKSPACE_FOLDER__ ?? process.cwd(),
103
+ },
104
+ })
105
+
106
+ if ((result.status ?? 0) !== 0) {
107
+ console.error("[vibe-forge opencode hook] process exited with", result.status, result.stderr)
108
+ return { continue: true }
109
+ }
110
+
111
+ const stdout = result.stdout?.trim()
112
+ return stdout ? parseJson(stdout, { continue: true }) : { continue: true }
113
+ } catch (error) {
114
+ console.error("[vibe-forge opencode hook] failed", error)
115
+ return { continue: true }
116
+ }
117
+ }
118
+
119
+ const stopReason = (result, fallback) => (
120
+ typeof result?.stopReason === "string" && result.stopReason.trim() !== ""
121
+ ? result.stopReason.trim()
122
+ : fallback
123
+ )
124
+
125
+ const normalizeToolName = (input) => {
126
+ if (typeof input?.tool === "string" && input.tool.trim() !== "") return input.tool
127
+ if (typeof input?.name === "string" && input.name.trim() !== "") return input.name
128
+ return "unknown"
129
+ }
130
+
131
+ const normalizeToolInput = (input, output) => (
132
+ output?.args ?? input?.args ?? input?.input ?? input
133
+ )
134
+
135
+ const normalizeToolResponse = (input, output) => (
136
+ output?.result ?? output?.response ?? output?.data ?? input?.result ?? output
137
+ )
138
+
139
+ export const VibeForgeHooks = async ({ directory }) => ({
140
+ event: async ({ event }) => {
141
+ if (event?.type === "session.created") {
142
+ callVibeForgeHook({
143
+ ...createBaseInput("SessionStart", directory, true),
144
+ source: "startup",
145
+ })
146
+ return
147
+ }
148
+
149
+ if (event?.type === "session.idle") {
150
+ callVibeForgeHook(createBaseInput("Stop", directory, false))
151
+ }
152
+ },
153
+ "tool.execute.before": async (input, output) => {
154
+ const result = callVibeForgeHook({
155
+ ...createBaseInput("PreToolUse", directory, true),
156
+ toolName: normalizeToolName(input),
157
+ toolInput: normalizeToolInput(input, output),
158
+ })
159
+
160
+ if (result?.continue === false) {
161
+ throw new Error(stopReason(result, "blocked by Vibe Forge PreToolUse hook"))
162
+ }
163
+ },
164
+ "tool.execute.after": async (input, output) => {
165
+ const result = callVibeForgeHook({
166
+ ...createBaseInput("PostToolUse", directory, true),
167
+ toolName: normalizeToolName(input),
168
+ toolInput: normalizeToolInput(input, output),
169
+ toolResponse: normalizeToolResponse(input, output),
170
+ isError: Boolean(output?.error ?? output?.isError ?? input?.error),
171
+ })
172
+
173
+ if (result?.continue === false) {
174
+ throw new Error(stopReason(result, "blocked by Vibe Forge PostToolUse hook"))
175
+ }
176
+ },
177
+ })
178
+ `
179
+ }
180
+
181
+ const resolveSourceConfigDir = (ctx: Pick<AdapterCtx, 'env'>) => {
182
+ const explicit = ctx.env.OPENCODE_CONFIG_DIR?.trim() || process.env.OPENCODE_CONFIG_DIR?.trim()
183
+ if (explicit) return resolve(explicit)
184
+
185
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__?.trim()
186
+ return realHome ? resolve(realHome, '.config', 'opencode') : undefined
187
+ }
188
+
189
+ export const ensureOpenCodeNativeHooksInstalled = async (
190
+ ctx: Pick<AdapterCtx, 'cwd' | 'env' | 'logger' | 'assets'>
191
+ ) => {
192
+ const { env, logger, assets } = ctx
193
+
194
+ env.__VF_PROJECT_AI_OPENCODE_NATIVE_HOOKS_AVAILABLE__ = '0'
195
+ const enabled = hasManagedHookPlugins({ assets })
196
+
197
+ try {
198
+ const { mockHome } = prepareManagedHookRuntime(ctx)
199
+ const sourceConfigDir = resolveSourceConfigDir(ctx)
200
+ const managedConfigDir = resolve(mockHome, '.config', 'opencode')
201
+ const pluginDir = resolve(managedConfigDir, 'plugins')
202
+ const managedPluginPath = resolve(pluginDir, MANAGED_PLUGIN_FILE_NAME)
203
+
204
+ await mkdir(managedConfigDir, { recursive: true })
205
+
206
+ if (sourceConfigDir != null && resolve(sourceConfigDir) !== managedConfigDir) {
207
+ await Promise.all([
208
+ mirrorDirectory(resolve(sourceConfigDir, 'agents'), resolve(managedConfigDir, 'agents')),
209
+ mirrorDirectory(resolve(sourceConfigDir, 'commands'), resolve(managedConfigDir, 'commands')),
210
+ mirrorDirectory(resolve(sourceConfigDir, 'modes'), resolve(managedConfigDir, 'modes')),
211
+ mirrorDirectory(resolve(sourceConfigDir, 'skills'), resolve(managedConfigDir, 'skills')),
212
+ mirrorFile(resolve(sourceConfigDir, 'package.json'), resolve(managedConfigDir, 'package.json')),
213
+ mirrorFile(resolve(sourceConfigDir, 'bun.lock'), resolve(managedConfigDir, 'bun.lock')),
214
+ mirrorFile(resolve(sourceConfigDir, 'bun.lockb'), resolve(managedConfigDir, 'bun.lockb'))
215
+ ])
216
+ await syncPluginDirectory(resolve(sourceConfigDir, 'plugins'), pluginDir)
217
+ if (!await mirrorFile(resolve(sourceConfigDir, 'opencode.json'), resolve(managedConfigDir, 'opencode.json'))) {
218
+ await writeJsonFile(resolve(managedConfigDir, 'opencode.json'), DEFAULT_OPENCODE_CONFIG)
219
+ }
220
+ } else {
221
+ await syncPluginDirectory(undefined, pluginDir)
222
+ if (!await pathExists(resolve(managedConfigDir, 'opencode.json'))) {
223
+ await writeJsonFile(resolve(managedConfigDir, 'opencode.json'), DEFAULT_OPENCODE_CONFIG)
224
+ }
225
+ }
226
+
227
+ if (enabled) {
228
+ await mkdir(pluginDir, { recursive: true })
229
+ await writeFile(managedPluginPath, buildManagedPluginSource(), 'utf8')
230
+ } else {
231
+ await rm(managedPluginPath, { force: true })
232
+ }
233
+
234
+ env.OPENCODE_CONFIG_DIR = managedConfigDir
235
+ env.__VF_PROJECT_AI_OPENCODE_NATIVE_HOOKS_AVAILABLE__ = enabled ? '1' : '0'
236
+ return enabled
237
+ } catch (error) {
238
+ logger.warn('[opencode hooks] failed to install native hook bridge', error)
239
+ env.__VF_PROJECT_AI_OPENCODE_NATIVE_HOOKS_AVAILABLE__ = '0'
240
+ return false
241
+ }
242
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
2
  import { resolve } from 'node:path'
3
3
  import process from 'node:process'
4
4
 
@@ -53,6 +53,17 @@ const parseEnvConfigContent = (env: AdapterCtx['env']) => {
53
53
  }
54
54
  }
55
55
 
56
+ const readBaseConfigContent = async (configDir: string | undefined) => {
57
+ if (configDir == null) return undefined
58
+
59
+ try {
60
+ const parsed = JSON.parse(await readFile(resolve(configDir, 'opencode.json'), 'utf8'))
61
+ return asPlainRecord(parsed)
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
56
67
  export const buildChildEnv = async (params: {
57
68
  ctx: AdapterCtx
58
69
  options: AdapterQueryOptions
@@ -60,10 +71,29 @@ export const buildChildEnv = async (params: {
60
71
  systemPromptFile?: string
61
72
  }) => {
62
73
  const configDir = await ensureOpenCodeConfigDir({ ctx: params.ctx, options: params.options })
74
+ const nativeHooksAvailable = params.ctx.env.__VF_PROJECT_AI_OPENCODE_NATIVE_HOOKS_AVAILABLE__ === '1'
75
+ const baseConfigContent = await readBaseConfigContent(configDir)
63
76
  const { cliModel, providerConfig } = resolveOpenCodeModel(
64
77
  params.options.model,
65
78
  resolveMergedModelServices(params.ctx)
66
79
  )
80
+ const inlineConfigContent = buildInlineConfigContent({
81
+ baseConfigContent,
82
+ adapterConfigContent: params.adapterConfig.configContent,
83
+ envConfigContent: parseEnvConfigContent(params.ctx.env),
84
+ permissionMode: params.options.permissionMode,
85
+ tools: params.options.tools,
86
+ mcpServers: undefined,
87
+ availableMcpServers: params.options.assetPlan?.mcpServers ?? mapResolvedMcpServerSelection(params.ctx, params.options),
88
+ systemPromptFile: params.systemPromptFile,
89
+ providerConfig
90
+ })
91
+
92
+ if (configDir != null) {
93
+ const configPath = resolve(configDir, 'opencode.json')
94
+ await rm(configPath, { force: true })
95
+ await writeFile(configPath, `${JSON.stringify(inlineConfigContent, null, 2)}\n`, 'utf8')
96
+ }
67
97
 
68
98
  return {
69
99
  cliModel,
@@ -71,21 +101,48 @@ export const buildChildEnv = async (params: {
71
101
  ...process.env,
72
102
  ...params.ctx.env,
73
103
  OPENCODE_DISABLE_AUTOUPDATE: params.ctx.env.OPENCODE_DISABLE_AUTOUPDATE ?? 'true',
74
- OPENCODE_CONFIG_CONTENT: JSON.stringify(buildInlineConfigContent({
75
- adapterConfigContent: params.adapterConfig.configContent,
76
- envConfigContent: parseEnvConfigContent(params.ctx.env),
77
- permissionMode: params.options.permissionMode,
78
- tools: params.options.tools,
79
- mcpServers: resolveMcpServerSelection(params.ctx, params.options.mcpServers),
80
- availableMcpServers: resolveMergedMcpServers(params.ctx),
81
- systemPromptFile: params.systemPromptFile,
82
- providerConfig
83
- })),
104
+ ...(nativeHooksAvailable
105
+ ? {
106
+ __VF_VIBE_FORGE_OPENCODE_HOOKS_ACTIVE__: '1',
107
+ __VF_OPENCODE_HOOK_RUNTIME__: params.options.runtime,
108
+ __VF_OPENCODE_TASK_SESSION_ID__: params.options.sessionId
109
+ }
110
+ : {}),
111
+ ...(configDir == null
112
+ ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(inlineConfigContent) }
113
+ : {}),
84
114
  ...(configDir != null ? { OPENCODE_CONFIG_DIR: configDir } : {})
85
115
  })
86
116
  }
87
117
  }
88
118
 
119
+ const mapResolvedMcpServerSelection = (
120
+ ctx: AdapterCtx,
121
+ options: AdapterQueryOptions
122
+ ) => (
123
+ options.assetPlan?.mcpServers ?? mapSelectedMcpServersToConfig(
124
+ resolveMergedMcpServers(ctx),
125
+ resolveMcpServerSelection(ctx, options.mcpServers)
126
+ )
127
+ )
128
+
129
+ const mapSelectedMcpServersToConfig = (
130
+ availableMcpServers: Config['mcpServers'],
131
+ selection: AdapterQueryOptions['mcpServers']
132
+ ) => {
133
+ if (availableMcpServers == null) return undefined
134
+ const include = selection?.include != null ? new Set(selection.include) : undefined
135
+ const exclude = new Set(selection?.exclude ?? [])
136
+
137
+ return Object.fromEntries(
138
+ Object.entries(availableMcpServers).filter(([name]) => {
139
+ if (include != null && !include.has(name)) return false
140
+ if (exclude.has(name)) return false
141
+ return true
142
+ })
143
+ )
144
+ }
145
+
89
146
  export const ensureSystemPromptFile = async (
90
147
  ctx: AdapterCtx,
91
148
  options: AdapterQueryOptions
@@ -47,6 +47,7 @@ export const createDirectOpenCodeSession = async (
47
47
  agent,
48
48
  share: adapterConfig.share,
49
49
  title,
50
+ dir: ctx.cwd,
50
51
  opencodeSessionId,
51
52
  extraOptions: options.extraOptions
52
53
  }), {
@@ -19,6 +19,13 @@ export interface OpenCodeRunResult {
19
19
  stderr: string
20
20
  }
21
21
 
22
+ interface OpenCodeJsonLineEvent {
23
+ type?: string
24
+ part?: {
25
+ text?: string
26
+ }
27
+ }
28
+
22
29
  export const execFileAsync = (
23
30
  file: string,
24
31
  args: string[],
@@ -43,6 +50,25 @@ export const createAssistantMessage = (content: string, model?: string): ChatMes
43
50
  ...(model != null ? { model } : {})
44
51
  })
45
52
 
53
+ export const extractTextFromOpenCodeJsonEvents = (stdout: string) => {
54
+ const textParts: string[] = []
55
+
56
+ for (const rawLine of stdout.split('\n')) {
57
+ const line = rawLine.trim()
58
+ if (!line.startsWith('{') || !line.endsWith('}')) continue
59
+
60
+ try {
61
+ const event = JSON.parse(line) as OpenCodeJsonLineEvent
62
+ if (event.type === 'text' && typeof event.part?.text === 'string') {
63
+ textParts.push(event.part.text)
64
+ }
65
+ } catch {
66
+ }
67
+ }
68
+
69
+ return textParts.join('')
70
+ }
71
+
46
72
  export const getErrorMessage = (error: unknown) => (
47
73
  error instanceof Error ? error.message : String(error ?? 'OpenCode session failed unexpectedly')
48
74
  )
@@ -55,8 +55,14 @@ export const ensureOpenCodeConfigDir = async (params: {
55
55
  options: AdapterQueryOptions
56
56
  }) => {
57
57
  const baseConfigDir = params.ctx.env.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR ?? undefined
58
- const resolvedSkills = await filterResolvedSkills(params.ctx.cwd, params.options.skills)
59
- if (baseConfigDir == null && resolvedSkills.size === 0) return undefined
58
+ const planOverlays = params.options.assetPlan?.overlays ?? []
59
+ const resolvedSkills = planOverlays.length > 0
60
+ ? new Map(
61
+ planOverlays
62
+ .filter((entry) => entry.kind === 'skill')
63
+ .map((entry) => [basename(entry.targetPath), entry.sourcePath] as const)
64
+ )
65
+ : await filterResolvedSkills(params.ctx.cwd, params.options.skills)
60
66
 
61
67
  const configDir = resolve(
62
68
  params.ctx.cwd,
@@ -73,6 +79,9 @@ export const ensureOpenCodeConfigDir = async (params: {
73
79
  for (const folderName of ['agents', 'commands', 'modes', 'plugins']) {
74
80
  await ensureSymlinkTarget(resolve(baseConfigDir, folderName), resolve(configDir, folderName)).catch(() => undefined)
75
81
  }
82
+ for (const fileName of ['opencode.json', 'package.json', 'bun.lock', 'bun.lockb']) {
83
+ await ensureSymlinkTarget(resolve(baseConfigDir, fileName), resolve(configDir, fileName)).catch(() => undefined)
84
+ }
76
85
  await mirrorDirectoryEntries(resolve(baseConfigDir, 'skills'), resolve(configDir, 'skills'))
77
86
  }
78
87
 
@@ -80,5 +89,9 @@ export const ensureOpenCodeConfigDir = async (params: {
80
89
  await ensureSymlinkTarget(sourceDir, resolve(configDir, 'skills', name))
81
90
  }
82
91
 
92
+ for (const overlay of planOverlays.filter((entry) => entry.kind !== 'skill')) {
93
+ await ensureSymlinkTarget(overlay.sourcePath, resolve(configDir, overlay.targetPath))
94
+ }
95
+
83
96
  return configDir
84
97
  }
@@ -12,7 +12,14 @@ import {
12
12
  import { resolveOpenCodeBinaryPath } from '../../paths'
13
13
  import { buildChildEnv, ensureSystemPromptFile } from './child-env'
14
14
  import { findOpenCodeSessionId, runOpenCodeCommand } from './process'
15
- import { createAssistantMessage, getErrorMessage, resolveAdapterConfig, stripAnsi, toAdapterErrorData } from './shared'
15
+ import {
16
+ createAssistantMessage,
17
+ extractTextFromOpenCodeJsonEvents,
18
+ getErrorMessage,
19
+ resolveAdapterConfig,
20
+ stripAnsi,
21
+ toAdapterErrorData
22
+ } from './shared'
16
23
 
17
24
  export const createStreamOpenCodeSession = async (
18
25
  ctx: AdapterCtx,
@@ -93,8 +100,10 @@ export const createStreamOpenCodeSession = async (
93
100
  agent,
94
101
  share: adapterConfig.share,
95
102
  title,
103
+ dir: ctx.cwd,
96
104
  opencodeSessionId,
97
- extraOptions: options.extraOptions
105
+ extraOptions: options.extraOptions,
106
+ format: 'json'
98
107
  }),
99
108
  cwd: ctx.cwd,
100
109
  env,
@@ -115,10 +124,10 @@ export const createStreamOpenCodeSession = async (
115
124
  currentKill = undefined
116
125
  if (destroyed) return
117
126
 
118
- const output = stripAnsi(result.stdout).trim()
127
+ const output = extractTextFromOpenCodeJsonEvents(result.stdout).trim()
119
128
  const error = stripAnsi(result.stderr).trim()
120
129
  if (result.exitCode !== 0) {
121
- const missingSession = /session.+not found|no session found/i.test(`${output}\n${error}`)
130
+ const missingSession = /session.+not found|no session found/i.test(`${result.stdout}\n${error}`)
122
131
  if (missingSession && opencodeSessionId != null && allowRetry) {
123
132
  opencodeSessionId = undefined
124
133
  await ctx.cache.set('adapter.opencode.session', { title })
@@ -155,7 +164,7 @@ export const createStreamOpenCodeSession = async (
155
164
  }
156
165
 
157
166
  const assistantMessage = createAssistantMessage(
158
- output === '' ? '[OpenCode completed without text output]' : output,
167
+ output === '' ? (stripAnsi(result.stdout).trim() || '[OpenCode completed without text output]') : output,
159
168
  cliModel
160
169
  )
161
170
  emitEvent({ type: 'message', data: assistantMessage })