@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 +109 -6
- package/__tests__/native-hooks.spec.ts +73 -0
- package/__tests__/session-runtime-config.spec.ts +69 -2
- package/package.json +2 -2
- package/src/AGENTS.md +13 -0
- package/src/runtime/common/inline-config.ts +5 -1
- package/src/runtime/common/tools.ts +4 -1
- package/src/runtime/init.ts +12 -14
- package/src/runtime/native-hooks.ts +242 -0
- package/src/runtime/session/child-env.ts +68 -11
- package/src/runtime/session/direct.ts +1 -0
- package/src/runtime/session/shared.ts +26 -0
- package/src/runtime/session/skill-config.ts +15 -2
- package/src/runtime/session/stream.ts +14 -5
package/AGENTS.md
CHANGED
|
@@ -1,13 +1,116 @@
|
|
|
1
|
-
# OpenCode Adapter
|
|
1
|
+
# OpenCode Adapter
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
+
"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.
|
|
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(
|
|
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))
|
package/src/runtime/init.ts
CHANGED
|
@@ -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 (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
await
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
@@ -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
|
|
59
|
-
|
|
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 {
|
|
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 =
|
|
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(`${
|
|
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 })
|