@vibe-forge/adapter-opencode 0.1.2

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.
Files changed (36) hide show
  1. package/AGENTS.md +13 -0
  2. package/LICENSE +21 -0
  3. package/__tests__/runtime-common.spec.ts +95 -0
  4. package/__tests__/runtime-config.spec.ts +124 -0
  5. package/__tests__/runtime-permissions.spec.ts +181 -0
  6. package/__tests__/runtime-test-helpers.ts +142 -0
  7. package/__tests__/session-runtime-config.spec.ts +156 -0
  8. package/__tests__/session-runtime-direct.spec.ts +141 -0
  9. package/__tests__/session-runtime-stream.spec.ts +181 -0
  10. package/package.json +59 -0
  11. package/src/AGENTS.md +38 -0
  12. package/src/adapter-config.ts +21 -0
  13. package/src/icon.ts +17 -0
  14. package/src/index.ts +11 -0
  15. package/src/models.ts +24 -0
  16. package/src/paths.ts +30 -0
  17. package/src/runtime/common/agent.ts +11 -0
  18. package/src/runtime/common/inline-config.ts +45 -0
  19. package/src/runtime/common/mcp.ts +47 -0
  20. package/src/runtime/common/model.ts +115 -0
  21. package/src/runtime/common/object-utils.ts +35 -0
  22. package/src/runtime/common/permission-node.ts +119 -0
  23. package/src/runtime/common/permissions.ts +73 -0
  24. package/src/runtime/common/prompt.ts +56 -0
  25. package/src/runtime/common/session-records.ts +61 -0
  26. package/src/runtime/common/tools.ts +129 -0
  27. package/src/runtime/common.ts +17 -0
  28. package/src/runtime/init.ts +55 -0
  29. package/src/runtime/session/child-env.ts +100 -0
  30. package/src/runtime/session/direct.ts +109 -0
  31. package/src/runtime/session/process.ts +55 -0
  32. package/src/runtime/session/shared.ts +72 -0
  33. package/src/runtime/session/skill-config.ts +84 -0
  34. package/src/runtime/session/stream.ts +198 -0
  35. package/src/runtime/session.ts +13 -0
  36. package/src/schema.ts +1 -0
@@ -0,0 +1,156 @@
1
+ import { lstat } from 'node:fs/promises'
2
+ import { execFile, spawn } from 'node:child_process'
3
+ import { join } from 'node:path'
4
+
5
+ import { describe, expect, it, vi } from 'vitest'
6
+
7
+ import type { AdapterOutputEvent } from '@vibe-forge/core/adapter'
8
+
9
+ import { createOpenCodeSession } from '#~/runtime/session.js'
10
+
11
+ import {
12
+ createWorkspace,
13
+ flushAsyncWork,
14
+ makeCtx,
15
+ makeProc,
16
+ mockExecFileJsonResponses,
17
+ registerRuntimeTestHooks,
18
+ writeDocument
19
+ } from './runtime-test-helpers'
20
+
21
+ vi.mock('node:child_process', () => ({
22
+ spawn: vi.fn(),
23
+ execFile: vi.fn()
24
+ }))
25
+
26
+ const spawnMock = vi.mocked(spawn)
27
+ const execFileMock = vi.mocked(execFile)
28
+
29
+ describe('createOpenCodeSession runtime config', () => {
30
+ registerRuntimeTestHooks()
31
+
32
+ it('uses the built-in plan agent when permission mode is plan', async () => {
33
+ mockExecFileJsonResponses(execFileMock, [
34
+ { id: 'sess_plan', title: 'Vibe Forge:session-plan', updatedAt: '2026-03-26T00:00:00.000Z' }
35
+ ])
36
+ spawnMock.mockImplementation(() => makeProc({ stdout: 'planned\n' }))
37
+
38
+ const events: AdapterOutputEvent[] = []
39
+ const { ctx } = makeCtx()
40
+
41
+ await createOpenCodeSession(ctx, {
42
+ type: 'create',
43
+ runtime: 'server',
44
+ sessionId: 'session-plan',
45
+ permissionMode: 'plan',
46
+ description: 'Inspect only.',
47
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
48
+ } as any)
49
+
50
+ await flushAsyncWork()
51
+
52
+ expect(spawnMock.mock.calls[0]?.[1]).toContain('plan')
53
+ expect(events[0]).toMatchObject({
54
+ type: 'init',
55
+ data: {
56
+ agents: ['plan']
57
+ }
58
+ })
59
+ })
60
+
61
+ it('applies default MCP include and exclude filters from config', async () => {
62
+ mockExecFileJsonResponses(execFileMock, [
63
+ { id: 'sess_mcp', title: 'Vibe Forge:session-mcp-defaults', updatedAt: '2026-03-26T00:00:00.000Z' }
64
+ ])
65
+ spawnMock.mockImplementation(() => makeProc({ stdout: 'mcp\n' }))
66
+
67
+ const { ctx } = makeCtx({
68
+ configs: [{
69
+ mcpServers: {
70
+ docs: { command: 'npx', args: ['docs-server'] },
71
+ jira: { type: 'http', url: 'https://example.test/mcp' },
72
+ browser: { command: 'npx', args: ['browser-server'] }
73
+ },
74
+ defaultIncludeMcpServers: ['docs', 'jira'],
75
+ defaultExcludeMcpServers: ['jira']
76
+ }, undefined]
77
+ })
78
+
79
+ await createOpenCodeSession(ctx, {
80
+ type: 'create',
81
+ runtime: 'server',
82
+ sessionId: 'session-mcp-defaults',
83
+ description: 'list docs',
84
+ onEvent: () => {}
85
+ } as any)
86
+
87
+ await flushAsyncWork()
88
+
89
+ const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: Record<string, string> }
90
+ const inlineConfig = JSON.parse(spawnOptions.env?.OPENCODE_CONFIG_CONTENT ?? '{}') as {
91
+ mcp?: Record<string, unknown>
92
+ }
93
+
94
+ expect(Object.keys(inlineConfig.mcp ?? {})).toEqual(['docs'])
95
+ })
96
+
97
+ it('filters nullish adapter env values before spawning opencode', async () => {
98
+ mockExecFileJsonResponses(execFileMock, [
99
+ { id: 'sess_env', title: 'Vibe Forge:session-env', updatedAt: '2026-03-26T00:00:00.000Z' }
100
+ ])
101
+ spawnMock.mockImplementation(() => makeProc({ stdout: 'env\n' }))
102
+
103
+ const { ctx } = makeCtx({
104
+ env: {
105
+ KEEP_ME: 'yes',
106
+ DROP_ME: null,
107
+ DROP_ME_TOO: undefined
108
+ }
109
+ })
110
+
111
+ await createOpenCodeSession(ctx, {
112
+ type: 'create',
113
+ runtime: 'server',
114
+ sessionId: 'session-env',
115
+ description: 'env',
116
+ onEvent: () => {}
117
+ } as any)
118
+
119
+ await flushAsyncWork()
120
+
121
+ const childEnv = (spawnMock.mock.calls[0]?.[2] as { env?: Record<string, string> }).env ?? {}
122
+ expect(childEnv.KEEP_ME).toBe('yes')
123
+ expect('DROP_ME' in childEnv).toBe(false)
124
+ expect('DROP_ME_TOO' in childEnv).toBe(false)
125
+ })
126
+
127
+ it('bridges Vibe Forge skills into OPENCODE_CONFIG_DIR for the skill tool', async () => {
128
+ const workspace = await createWorkspace()
129
+ await writeDocument(join(workspace, '.ai/skills/research/SKILL.md'), '---\nname: research\ndescription: 检索资料\n---\n阅读 README.md')
130
+ await writeDocument(join(workspace, '.ai/skills/review/SKILL.md'), '---\nname: review\ndescription: 代码评审\n---\n检查风险')
131
+ mockExecFileJsonResponses(execFileMock, [
132
+ { id: 'sess_skill', title: 'Vibe Forge:session-skill-bridge', updatedAt: '2026-03-26T00:00:00.000Z' }
133
+ ])
134
+ spawnMock.mockImplementation(() => makeProc({ stdout: 'skills\n' }))
135
+
136
+ const { ctx } = makeCtx({ cwd: workspace })
137
+ await createOpenCodeSession(ctx, {
138
+ type: 'create',
139
+ runtime: 'server',
140
+ sessionId: 'session-skill-bridge',
141
+ description: 'list skills',
142
+ skills: {
143
+ include: ['research'],
144
+ exclude: ['review']
145
+ },
146
+ onEvent: () => {}
147
+ } as any)
148
+
149
+ await flushAsyncWork()
150
+
151
+ const configDir = (spawnMock.mock.calls[0]?.[2] as { env?: Record<string, string> }).env?.OPENCODE_CONFIG_DIR
152
+ expect(typeof configDir).toBe('string')
153
+ expect((await lstat(join(configDir!, 'skills', 'research'))).isSymbolicLink()).toBe(true)
154
+ await expect(lstat(join(configDir!, 'skills', 'review'))).rejects.toThrow()
155
+ })
156
+ })
@@ -0,0 +1,141 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import type { AdapterOutputEvent } from '@vibe-forge/core/adapter'
6
+
7
+ import { createOpenCodeSession } from '#~/runtime/session.js'
8
+
9
+ import {
10
+ flushAsyncWork,
11
+ makeCtx,
12
+ makeErrorProc,
13
+ makeProc,
14
+ mockExecFileJsonResponses,
15
+ registerRuntimeTestHooks
16
+ } from './runtime-test-helpers'
17
+
18
+ vi.mock('node:child_process', () => ({
19
+ spawn: vi.fn(),
20
+ execFile: vi.fn()
21
+ }))
22
+
23
+ const spawnMock = vi.mocked(spawn)
24
+ const execFileMock = vi.mocked(execFile)
25
+
26
+ describe('createOpenCodeSession direct runtime', () => {
27
+ registerRuntimeTestHooks()
28
+
29
+ it('emits exit in direct mode after the child process finishes', async () => {
30
+ mockExecFileJsonResponses(execFileMock, [
31
+ { id: 'sess_direct', title: 'Vibe Forge:session-direct', updatedAt: '2026-03-26T00:00:00.000Z' }
32
+ ])
33
+ spawnMock.mockImplementation(() => makeProc({ exitCode: 0 }))
34
+
35
+ const events: AdapterOutputEvent[] = []
36
+ const { ctx } = makeCtx()
37
+
38
+ await createOpenCodeSession(ctx, {
39
+ type: 'create',
40
+ runtime: 'cli',
41
+ mode: 'direct',
42
+ sessionId: 'session-direct',
43
+ description: 'say hi',
44
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
45
+ } as any)
46
+
47
+ await flushAsyncWork()
48
+
49
+ expect((spawnMock.mock.calls[0]?.[2] as { stdio?: string }).stdio).toBe('inherit')
50
+ expect(events).toEqual([{ type: 'exit', data: { exitCode: 0 } }])
51
+ })
52
+
53
+ it('does not inject a placeholder prompt in direct mode when description is missing', async () => {
54
+ spawnMock.mockImplementation(() => makeProc({ exitCode: 0 }))
55
+
56
+ const { ctx } = makeCtx()
57
+ await createOpenCodeSession(ctx, {
58
+ type: 'create',
59
+ runtime: 'cli',
60
+ mode: 'direct',
61
+ sessionId: 'session-direct-empty',
62
+ onEvent: () => {}
63
+ } as any)
64
+
65
+ await flushAsyncWork()
66
+
67
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual([
68
+ 'run',
69
+ '--format',
70
+ 'default',
71
+ '--title',
72
+ 'Vibe Forge:session-direct-empty'
73
+ ])
74
+ })
75
+
76
+ it('emits exit in direct mode when the child process errors before launch', async () => {
77
+ spawnMock.mockImplementation(() => makeErrorProc(new Error('opencode missing')))
78
+
79
+ const events: AdapterOutputEvent[] = []
80
+ const { ctx } = makeCtx()
81
+
82
+ await createOpenCodeSession(ctx, {
83
+ type: 'create',
84
+ runtime: 'cli',
85
+ mode: 'direct',
86
+ sessionId: 'session-direct-error',
87
+ description: 'say hi',
88
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
89
+ } as any)
90
+
91
+ await flushAsyncWork()
92
+
93
+ expect(events).toEqual([
94
+ expect.objectContaining({
95
+ type: 'error',
96
+ data: expect.objectContaining({
97
+ message: 'opencode missing',
98
+ fatal: true
99
+ })
100
+ }),
101
+ { type: 'exit', data: { exitCode: 1, stderr: 'opencode missing' } }
102
+ ])
103
+ })
104
+
105
+ it('resolves the latest session id before starting direct resume mode', async () => {
106
+ mockExecFileJsonResponses(execFileMock,
107
+ [{ id: 'sess_latest', title: 'Vibe Forge:session-direct-resume', updatedAt: '2026-03-26T00:00:00.000Z' }],
108
+ [{ id: 'sess_latest', title: 'Vibe Forge:session-direct-resume', updatedAt: '2026-03-26T00:00:01.000Z' }]
109
+ )
110
+ spawnMock.mockImplementation(() => makeProc({ exitCode: 0 }))
111
+
112
+ const events: AdapterOutputEvent[] = []
113
+ const { ctx, cacheStore } = makeCtx({
114
+ cacheSeed: {
115
+ 'adapter.opencode.session': {
116
+ opencodeSessionId: 'sess_stale',
117
+ title: 'Vibe Forge:session-direct-resume'
118
+ }
119
+ }
120
+ })
121
+
122
+ await createOpenCodeSession(ctx, {
123
+ type: 'resume',
124
+ runtime: 'cli',
125
+ mode: 'direct',
126
+ sessionId: 'session-direct-resume',
127
+ description: 'continue',
128
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
129
+ } as any)
130
+
131
+ await flushAsyncWork()
132
+
133
+ expect(spawnMock.mock.calls[0]?.[1]).toContain('sess_latest')
134
+ expect(spawnMock.mock.calls[0]?.[1]).not.toContain('sess_stale')
135
+ expect(cacheStore.get('adapter.opencode.session')).toMatchObject({
136
+ opencodeSessionId: 'sess_latest',
137
+ title: 'Vibe Forge:session-direct-resume'
138
+ })
139
+ expect(events).toEqual([{ type: 'exit', data: { exitCode: 0 } }])
140
+ })
141
+ })
@@ -0,0 +1,181 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import type { AdapterOutputEvent } from '@vibe-forge/core/adapter'
6
+
7
+ import { createOpenCodeSession } from '#~/runtime/session.js'
8
+
9
+ import {
10
+ flushAsyncWork,
11
+ makeCtx,
12
+ makeErrorProc,
13
+ makeProc,
14
+ mockExecFileJsonResponses,
15
+ registerRuntimeTestHooks
16
+ } from './runtime-test-helpers'
17
+
18
+ vi.mock('node:child_process', () => ({
19
+ spawn: vi.fn(),
20
+ execFile: vi.fn()
21
+ }))
22
+
23
+ const spawnMock = vi.mocked(spawn)
24
+ const execFileMock = vi.mocked(execFile)
25
+
26
+ describe('createOpenCodeSession stream runtime', () => {
27
+ registerRuntimeTestHooks()
28
+
29
+ it('emits init, message, and stop for a successful stream turn', async () => {
30
+ mockExecFileJsonResponses(execFileMock, [
31
+ { id: 'sess_1', title: 'Vibe Forge:session-1', updatedAt: '2026-03-26T00:00:00.000Z' }
32
+ ])
33
+ spawnMock.mockImplementation(() => makeProc({ stdout: 'pong\n' }))
34
+
35
+ const events: AdapterOutputEvent[] = []
36
+ const { ctx, cacheStore } = makeCtx()
37
+
38
+ await createOpenCodeSession(ctx, {
39
+ type: 'create',
40
+ runtime: 'server',
41
+ sessionId: 'session-1',
42
+ description: 'Reply with exactly pong.',
43
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
44
+ } as any)
45
+
46
+ await flushAsyncWork()
47
+
48
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual([
49
+ 'run', '--format', 'default', '--title', 'Vibe Forge:session-1', 'Reply with exactly pong.'
50
+ ])
51
+ expect(events.map(event => event.type)).toEqual(['init', 'message', 'stop'])
52
+ expect(events[1]).toMatchObject({ type: 'message', data: { role: 'assistant', content: 'pong' } })
53
+ expect(cacheStore.get('adapter.opencode.session')).toMatchObject({
54
+ opencodeSessionId: 'sess_1',
55
+ title: 'Vibe Forge:session-1'
56
+ })
57
+ })
58
+
59
+ it('reuses the cached OpenCode session id for later turns', async () => {
60
+ mockExecFileJsonResponses(execFileMock,
61
+ [{ id: 'sess_1', title: 'Vibe Forge:session-stream', updatedAt: '2026-03-26T00:00:00.000Z' }],
62
+ [{ id: 'sess_1', title: 'Vibe Forge:session-stream', updatedAt: '2026-03-26T00:01:00.000Z' }]
63
+ )
64
+ spawnMock
65
+ .mockImplementationOnce(() => makeProc({ stdout: 'first\n' }))
66
+ .mockImplementationOnce(() => makeProc({ stdout: 'second\n' }))
67
+
68
+ const events: AdapterOutputEvent[] = []
69
+ const { ctx } = makeCtx()
70
+ const session = await createOpenCodeSession(ctx, {
71
+ type: 'create',
72
+ runtime: 'server',
73
+ sessionId: 'session-stream',
74
+ description: 'first',
75
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
76
+ } as any)
77
+
78
+ await flushAsyncWork()
79
+ session.emit({ type: 'message', content: [{ type: 'text', text: 'second' }] })
80
+ await flushAsyncWork()
81
+
82
+ expect(spawnMock.mock.calls[0]?.[1]).toContain('--title')
83
+ expect(spawnMock.mock.calls[1]?.[1]).toContain('--session')
84
+ expect(spawnMock.mock.calls[1]?.[1]).toContain('sess_1')
85
+ expect(events.filter(event => event.type === 'message')).toHaveLength(2)
86
+ })
87
+
88
+ it('does not start a turn until a message arrives when create has no description', async () => {
89
+ spawnMock.mockImplementation(() => makeProc({ stdout: 'later\n' }))
90
+
91
+ const events: AdapterOutputEvent[] = []
92
+ const { ctx } = makeCtx()
93
+ const session = await createOpenCodeSession(ctx, {
94
+ type: 'create',
95
+ runtime: 'server',
96
+ sessionId: 'session-empty-create',
97
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
98
+ } as any)
99
+
100
+ await flushAsyncWork()
101
+ expect(spawnMock).not.toHaveBeenCalled()
102
+ expect(events).toEqual([expect.objectContaining({ type: 'init' })])
103
+
104
+ mockExecFileJsonResponses(execFileMock, [
105
+ { id: 'sess_later', title: 'Vibe Forge:session-empty-create', updatedAt: '2026-03-26T00:00:00.000Z' }
106
+ ])
107
+ session.emit({ type: 'message', content: [{ type: 'text', text: 'later' }] })
108
+
109
+ await flushAsyncWork()
110
+ expect(spawnMock).toHaveBeenCalledTimes(1)
111
+ expect(spawnMock.mock.calls[0]?.[1]).toContain('later')
112
+ })
113
+
114
+ it('retries without the cached session id when resume hits a missing session', async () => {
115
+ mockExecFileJsonResponses(execFileMock,
116
+ [{ id: 'sess_new', title: 'Vibe Forge:session-resume', updatedAt: '2026-03-26T00:00:00.000Z' }],
117
+ [{ id: 'sess_new', title: 'Vibe Forge:session-resume', updatedAt: '2026-03-26T00:00:01.000Z' }]
118
+ )
119
+ spawnMock
120
+ .mockImplementationOnce(() => makeProc({ stderr: 'Error: session not found\n', exitCode: 1 }))
121
+ .mockImplementationOnce(() => makeProc({ stdout: 'recovered\n' }))
122
+
123
+ const events: AdapterOutputEvent[] = []
124
+ const { ctx, cacheStore } = makeCtx({
125
+ cacheSeed: {
126
+ 'adapter.opencode.session': {
127
+ opencodeSessionId: 'sess_missing',
128
+ title: 'Vibe Forge:session-resume'
129
+ }
130
+ }
131
+ })
132
+
133
+ await createOpenCodeSession(ctx, {
134
+ type: 'resume',
135
+ runtime: 'server',
136
+ sessionId: 'session-resume',
137
+ description: 'continue',
138
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
139
+ } as any)
140
+
141
+ await flushAsyncWork()
142
+
143
+ expect(spawnMock.mock.calls[0]?.[1]).toContain('sess_missing')
144
+ expect(spawnMock.mock.calls[1]?.[1]).toContain('sess_new')
145
+ expect(events.some(event => event.type === 'exit')).toBe(false)
146
+ expect(events.some(event => event.type === 'stop')).toBe(true)
147
+ expect(cacheStore.get('adapter.opencode.session')).toMatchObject({
148
+ opencodeSessionId: 'sess_new',
149
+ title: 'Vibe Forge:session-resume'
150
+ })
151
+ })
152
+
153
+ it('emits exit when a stream turn fails before the child process starts cleanly', async () => {
154
+ spawnMock.mockImplementation(() => makeErrorProc(new Error('spawn failed')))
155
+
156
+ const events: AdapterOutputEvent[] = []
157
+ const { ctx } = makeCtx()
158
+
159
+ await createOpenCodeSession(ctx, {
160
+ type: 'create',
161
+ runtime: 'server',
162
+ sessionId: 'session-stream-error',
163
+ description: 'hello',
164
+ onEvent: (event: AdapterOutputEvent) => events.push(event)
165
+ } as any)
166
+
167
+ await flushAsyncWork()
168
+
169
+ expect(events).toEqual([
170
+ expect.objectContaining({ type: 'init' }),
171
+ expect.objectContaining({
172
+ type: 'error',
173
+ data: expect.objectContaining({
174
+ message: 'spawn failed',
175
+ fatal: true
176
+ })
177
+ }),
178
+ { type: 'exit', data: { exitCode: 1, stderr: 'spawn failed' } }
179
+ ])
180
+ })
181
+ })
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@vibe-forge/adapter-opencode",
3
+ "version": "0.1.2",
4
+ "description": "OpenCode Adapter for Vibe Forge",
5
+ "imports": {
6
+ "#~/*.js": {
7
+ "__vibe-forge__": {
8
+ "default": "./src/*.ts"
9
+ },
10
+ "default": {
11
+ "import": "./dist/*.mjs",
12
+ "require": "./dist/*.js"
13
+ }
14
+ }
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "__vibe-forge__": {
19
+ "default": "./src/index.ts"
20
+ },
21
+ "default": {
22
+ "import": "./dist/index.mjs",
23
+ "require": "./dist/index.js"
24
+ }
25
+ },
26
+ "./schema": {
27
+ "__vibe-forge__": {
28
+ "default": "./src/schema.ts"
29
+ },
30
+ "default": {
31
+ "import": "./dist/schema.mjs",
32
+ "require": "./dist/schema.js"
33
+ }
34
+ },
35
+ "./models": {
36
+ "__vibe-forge__": {
37
+ "default": "./src/models.ts"
38
+ },
39
+ "default": {
40
+ "import": "./dist/models.mjs",
41
+ "require": "./dist/models.js"
42
+ }
43
+ },
44
+ "./icon": {
45
+ "__vibe-forge__": {
46
+ "default": "./src/icon.ts"
47
+ },
48
+ "default": {
49
+ "import": "./dist/icon.mjs",
50
+ "require": "./dist/icon.js"
51
+ }
52
+ },
53
+ "./package.json": "./package.json"
54
+ },
55
+ "dependencies": {
56
+ "opencode-ai": "1.3.2",
57
+ "@vibe-forge/core": "^0.7.3"
58
+ }
59
+ }
package/src/AGENTS.md ADDED
@@ -0,0 +1,38 @@
1
+ # `src` 目录说明
2
+
3
+ ## 入口层
4
+
5
+ - `index.ts`:adapter 包对外入口,只负责导出 `init` / `query` 能力
6
+ - `init.ts`:初始化阶段逻辑;只放启动前检查或一次性准备,不混入会话执行分支
7
+ - `paths.ts`:CLI binary 路径解析
8
+ - `models.ts` / `icon.ts` / `schema.ts`:展示或元数据层,不承载运行时逻辑
9
+ - `adapter-config.ts`:adapter 自身配置 schema 和类型
10
+
11
+ ## Runtime 分层
12
+
13
+ - `runtime/common/`:纯映射层
14
+ - 负责 prompt、tools、permissions、model、mcp、inline config、session list parsing
15
+ - 不直接读写文件系统,不启动子进程,不维护会话状态
16
+
17
+ - `runtime/session/`:执行层
18
+ - 负责 child env、skill bridge、OpenCode 进程启动、direct/stream 会话控制、错误传播
19
+ - 可以依赖 `runtime/common/`,反向依赖不允许出现
20
+
21
+ - `runtime/common.ts` / `runtime/session.ts`
22
+ - 仅作为稳定入口和 re-export 路由
23
+ - 新逻辑不要继续堆回这两个文件
24
+
25
+ ## 依赖方向
26
+
27
+ - `src/*` 可以依赖 `runtime/*` 吗:不建议。公共入口只做装配。
28
+ - `runtime/session/*` 可以依赖 `runtime/common/*`
29
+ - `runtime/common/*` 不应依赖 `runtime/session/*`
30
+ - 测试 helper 只能放在 `__tests__/runtime-test-helpers.ts`,不要从 `src` 反向引用测试代码
31
+
32
+ ## 修改约束
33
+
34
+ - 单文件保持在 200 行以内;接近 160 行就优先继续拆模块
35
+ - 新增参数适配时,先判断它属于“映射层”还是“执行层”,不要跨层散落
36
+ - 涉及 OpenCode CLI 参数、env、session resume 语义的改动,至少补一条 runtime 回归测试
37
+ - 涉及 permissions / tools / model / mcp 映射的改动,至少补一条 common 回归测试
38
+ - 若新增目录,先在本文件补一句职责说明,再落代码
@@ -0,0 +1,21 @@
1
+ export {}
2
+
3
+ declare module '@vibe-forge/core' {
4
+ interface Cache {
5
+ 'adapter.opencode.session': {
6
+ opencodeSessionId?: string
7
+ title?: string
8
+ }
9
+ }
10
+
11
+ interface AdapterMap {
12
+ opencode: {
13
+ agent?: string
14
+ planAgent?: string | false
15
+ titlePrefix?: string
16
+ share?: boolean
17
+ sessionListMaxCount?: number
18
+ configContent?: Record<string, unknown>
19
+ }
20
+ }
21
+ }
package/src/icon.ts ADDED
@@ -0,0 +1,17 @@
1
+ const OPENCODE_ICON_SVG = `
2
+ <svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg">
3
+ <rect width="24" height="24" rx="6" fill="#0F172A"></rect>
4
+ <path d="M12 3.25 4.75 7.5v9L12 20.75l7.25-4.25v-9L12 3.25Zm0 2.06 5.5 3.22-5.5 3.22-5.5-3.22L12 5.31Zm-6 4.97 5 2.93v5.46l-5-2.93v-5.4Zm7 8.39v-5.46l5-2.93v5.4l-5 2.99Z" fill="url(#opencode-fill)"></path>
5
+ <defs>
6
+ <linearGradient id="opencode-fill" x1="4.75" x2="19.25" y1="3.25" y2="20.75" gradientUnits="userSpaceOnUse">
7
+ <stop stop-color="#34D399"></stop>
8
+ <stop offset=".55" stop-color="#22C55E"></stop>
9
+ <stop offset="1" stop-color="#14B8A6"></stop>
10
+ </linearGradient>
11
+ </defs>
12
+ </svg>
13
+ `
14
+ .trim()
15
+
16
+ export const adapterIcon = `data:image/svg+xml;utf8,${encodeURIComponent(OPENCODE_ICON_SVG)}`
17
+ export const adapterDisplayName = 'OpenCode'
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import './adapter-config'
2
+
3
+ import { defineAdapter } from '@vibe-forge/core/adapter'
4
+
5
+ import { initOpenCodeAdapter } from './runtime/init'
6
+ import { createOpenCodeSession } from './runtime/session'
7
+
8
+ export default defineAdapter({
9
+ init: initOpenCodeAdapter,
10
+ query: createOpenCodeSession
11
+ })
package/src/models.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { AdapterBuiltinModel } from '@vibe-forge/core'
2
+
3
+ export const builtinModels: AdapterBuiltinModel[] = [
4
+ {
5
+ value: 'default',
6
+ title: 'default',
7
+ description: 'Use OpenCode\'s configured default model'
8
+ },
9
+ {
10
+ value: 'openai/gpt-5',
11
+ title: 'openai/gpt-5',
12
+ description: 'OpenAI GPT-5 via the OpenAI provider'
13
+ },
14
+ {
15
+ value: 'anthropic/claude-sonnet-4-5',
16
+ title: 'anthropic/claude-sonnet-4-5',
17
+ description: 'Anthropic Claude Sonnet 4.5 via the Anthropic provider'
18
+ },
19
+ {
20
+ value: 'anthropic/claude-haiku-4-5',
21
+ title: 'anthropic/claude-haiku-4-5',
22
+ description: 'Anthropic Claude Haiku 4.5 via the Anthropic provider'
23
+ }
24
+ ]
package/src/paths.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, resolve } from 'node:path'
4
+
5
+ import type { AdapterCtx } from '@vibe-forge/core/adapter'
6
+
7
+ const require = createRequire(import.meta.url ?? __filename)
8
+ const adapterPackageDir = dirname(require.resolve('@vibe-forge/adapter-opencode/package.json'))
9
+
10
+ export const toRealPath = (targetPath: string) => {
11
+ try {
12
+ return realpathSync(targetPath)
13
+ } catch {
14
+ return targetPath
15
+ }
16
+ }
17
+
18
+ export const resolveOpenCodeBinaryPath = (env: AdapterCtx['env']): string => {
19
+ const envPath = env.__VF_PROJECT_AI_ADAPTER_OPENCODE_CLI_PATH__
20
+ if (typeof envPath === 'string' && envPath.trim() !== '') {
21
+ return envPath
22
+ }
23
+
24
+ const bundledPath = resolve(adapterPackageDir, 'node_modules/.bin/opencode')
25
+ if (existsSync(bundledPath)) {
26
+ return toRealPath(bundledPath)
27
+ }
28
+
29
+ return 'opencode'
30
+ }