@tanstack/ai-opencode 0.1.0

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 (46) hide show
  1. package/README.md +18 -0
  2. package/dist/esm/adapters/projection.d.ts +14 -0
  3. package/dist/esm/adapters/projection.js +83 -0
  4. package/dist/esm/adapters/projection.js.map +1 -0
  5. package/dist/esm/adapters/text.d.ts +47 -0
  6. package/dist/esm/adapters/text.js +231 -0
  7. package/dist/esm/adapters/text.js.map +1 -0
  8. package/dist/esm/index.d.ts +16 -0
  9. package/dist/esm/index.js +23 -0
  10. package/dist/esm/index.js.map +1 -0
  11. package/dist/esm/messages/prompt.d.ts +16 -0
  12. package/dist/esm/messages/prompt.js +37 -0
  13. package/dist/esm/messages/prompt.js.map +1 -0
  14. package/dist/esm/model-meta.d.ts +14 -0
  15. package/dist/esm/model-meta.js +13 -0
  16. package/dist/esm/model-meta.js.map +1 -0
  17. package/dist/esm/process/permissions.d.ts +50 -0
  18. package/dist/esm/process/permissions.js +51 -0
  19. package/dist/esm/process/permissions.js.map +1 -0
  20. package/dist/esm/process/sandbox-server.d.ts +23 -0
  21. package/dist/esm/process/sandbox-server.js +72 -0
  22. package/dist/esm/process/sandbox-server.js.map +1 -0
  23. package/dist/esm/process/server.d.ts +77 -0
  24. package/dist/esm/process/server.js +160 -0
  25. package/dist/esm/process/server.js.map +1 -0
  26. package/dist/esm/provider-options.d.ts +18 -0
  27. package/dist/esm/stream/queue.d.ts +18 -0
  28. package/dist/esm/stream/queue.js +56 -0
  29. package/dist/esm/stream/queue.js.map +1 -0
  30. package/dist/esm/stream/sdk-types.d.ts +134 -0
  31. package/dist/esm/stream/translate.d.ts +51 -0
  32. package/dist/esm/stream/translate.js +305 -0
  33. package/dist/esm/stream/translate.js.map +1 -0
  34. package/package.json +62 -0
  35. package/src/adapters/projection.ts +220 -0
  36. package/src/adapters/text.ts +368 -0
  37. package/src/index.ts +40 -0
  38. package/src/messages/prompt.ts +67 -0
  39. package/src/model-meta.ts +24 -0
  40. package/src/process/permissions.ts +119 -0
  41. package/src/process/sandbox-server.ts +130 -0
  42. package/src/process/server.ts +270 -0
  43. package/src/provider-options.ts +19 -0
  44. package/src/stream/queue.ts +64 -0
  45. package/src/stream/sdk-types.ts +104 -0
  46. package/src/stream/translate.ts +419 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * OpenCode workspace projector — mirrors the Claude Code reference
3
+ * implementation at `packages/ai-claude-code/src/adapters/projection.ts`.
4
+ *
5
+ * `withSandbox` surfaces a portable `WorkspaceProjection` (skills, plugins, a
6
+ * secret resolver, and a one-time marker path) via a capability. This adapter
7
+ * reads it in `chatStream` setup and projects workspace inputs into OpenCode's
8
+ * native format:
9
+ *
10
+ * - MCP servers → `opencode.json` at the workspace root (mcp section).
11
+ * Written on EVERY call (never marker-gated) so rotated
12
+ * secrets always re-apply.
13
+ * - gitSkill repos → OpenCode has no recognised skills directory; we
14
+ * warn-and-skip rather than invent a path.
15
+ * - agentSkill → no bare-name primitive in opencode; warn-and-skip.
16
+ * - plugins → opencode has no `opencode plugin install` command;
17
+ * warn-and-skip.
18
+ *
19
+ * The `opencode.json` mcp-section shape mirrors the `OPENCODE_CONFIG_CONTENT`
20
+ * shape already used by the adapter's host-tool-bridge, ensuring both
21
+ * mechanisms use the same server-entry schema:
22
+ *
23
+ * ```json
24
+ * { "mcp": { "<name>": { "type": "remote", "url": "...", "enabled": true,
25
+ * "headers": { ... } } } }
26
+ * ```
27
+ *
28
+ * OpenCode merges project-scoped `opencode.json` with its environment config,
29
+ * so the workspace file coexists safely with the runtime `OPENCODE_CONFIG_CONTENT`
30
+ * env written for the host-tool-bridge.
31
+ *
32
+ * External-convention caveat: the `opencode.json` location and the `remote`
33
+ * MCP-entry shape are derived from the adapter's existing `OPENCODE_CONFIG_CONTENT`
34
+ * usage. Where OpenCode has no clean primitive (gitSkill dir, agentSkill,
35
+ * plugins) we no-op with a warning instead of fabricating a command.
36
+ */
37
+ import { isSecretRef, resolveGitSkillDir } from '@tanstack/ai-sandbox'
38
+ import type {
39
+ BearerRef,
40
+ SandboxHandle,
41
+ SecretRef,
42
+ WorkspaceProjection,
43
+ WorkspaceSkill,
44
+ } from '@tanstack/ai-sandbox'
45
+
46
+ /** True when `value` is a `bearer(ref)` marker created by `@tanstack/ai-sandbox`. */
47
+ function isBearerMarker(value: unknown): value is BearerRef {
48
+ return (
49
+ typeof value === 'object' &&
50
+ value !== null &&
51
+ isSecretRef((value as { __bearerRef?: unknown }).__bearerRef)
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Resolve a single MCP header value: a `SecretRef` resolves to its plaintext,
57
+ * a `bearer(ref)` marker resolves to `Bearer <plaintext>`, and a plain string
58
+ * is passed through unchanged.
59
+ */
60
+ function resolveHeaderValue(
61
+ value: string | SecretRef | BearerRef,
62
+ resolveSecret: (ref: SecretRef) => string,
63
+ ): string {
64
+ if (isSecretRef(value)) return resolveSecret(value)
65
+ if (isBearerMarker(value)) return `Bearer ${resolveSecret(value.__bearerRef)}`
66
+ return value
67
+ }
68
+
69
+ /** An OpenCode `remote`-type MCP server entry (mirrors OPENCODE_CONFIG_CONTENT shape). */
70
+ interface OpencodeMcpServer {
71
+ type: 'remote'
72
+ url: string
73
+ enabled: boolean
74
+ headers: Record<string, string>
75
+ }
76
+
77
+ /**
78
+ * Build the `mcp` section of OpenCode's `opencode.json` from the
79
+ * `{ kind: 'mcp' }` skills, resolving every header value (SecretRef / bearer
80
+ * / string). Returns `undefined` when there are no MCP skills so the caller
81
+ * can skip the write.
82
+ */
83
+ function buildMcpSection(
84
+ skills: Array<WorkspaceSkill>,
85
+ resolveSecret: (ref: SecretRef) => string,
86
+ ): Record<string, OpencodeMcpServer> | undefined {
87
+ const mcp: Record<string, OpencodeMcpServer> = {}
88
+ let count = 0
89
+ for (const skill of skills) {
90
+ if (skill.kind !== 'mcp') continue
91
+ count += 1
92
+ const headers: Record<string, string> = {}
93
+ const rawHeaders = skill.config.headers ?? {}
94
+ for (const [name, value] of Object.entries(rawHeaders)) {
95
+ headers[name] = resolveHeaderValue(value, resolveSecret)
96
+ }
97
+ const rawUrl = skill.config['url']
98
+ const url = typeof rawUrl === 'string' ? rawUrl : ''
99
+ mcp[skill.name] = { type: 'remote', url, enabled: true, headers }
100
+ }
101
+ return count > 0 ? mcp : undefined
102
+ }
103
+
104
+ /**
105
+ * Write (or merge-update) `opencode.json` at the workspace root with the
106
+ * project-scoped MCP servers, re-resolving every secret. Runs on EVERY
107
+ * projection call (never marker-gated) so rotated secrets always re-apply.
108
+ *
109
+ * If `opencode.json` already exists (e.g. committed to the repo) we read it,
110
+ * merge only the `mcp` key, and rewrite, preserving other settings. When there
111
+ * are no MCP skills the write is skipped entirely.
112
+ */
113
+ async function projectMcpServers(
114
+ handle: SandboxHandle,
115
+ projection: WorkspaceProjection,
116
+ ): Promise<void> {
117
+ const mcpSection = buildMcpSection(
118
+ projection.skills,
119
+ projection.resolveSecret,
120
+ )
121
+ if (mcpSection === undefined) return
122
+
123
+ const target = `${projection.root}/opencode.json`
124
+
125
+ // Read the existing file if present so we can preserve non-mcp settings.
126
+ let existing: Record<string, unknown> = {}
127
+ if (await handle.fs.exists(target)) {
128
+ try {
129
+ const raw = await handle.fs.read(target)
130
+ const parsed: unknown = JSON.parse(raw)
131
+ if (
132
+ typeof parsed === 'object' &&
133
+ parsed !== null &&
134
+ !Array.isArray(parsed)
135
+ ) {
136
+ existing = parsed as Record<string, unknown>
137
+ }
138
+ } catch {
139
+ // Unreadable or invalid JSON — start fresh so the MCP config lands cleanly.
140
+ }
141
+ }
142
+
143
+ const merged = { ...existing, mcp: mcpSection }
144
+ await handle.fs.write(target, JSON.stringify(merged, null, 2))
145
+ }
146
+
147
+ /**
148
+ * OpenCode has no recognised skills directory where we can drop gitSkill repos.
149
+ * AGENTS.md is already handled by the bootstrap layer (OpenCode reads it
150
+ * natively). We warn-and-skip instead of inventing a path.
151
+ */
152
+ function projectGitSkills(projection: WorkspaceProjection): void {
153
+ for (const skill of projection.skills) {
154
+ if (skill.kind !== 'git') continue
155
+ const dir = skill.into ?? resolveGitSkillDir(projection.root, skill)
156
+ console.warn(
157
+ `[opencode] gitSkill "${skill.repo}" cloned to ${dir} but OpenCode has no ` +
158
+ 'recognised skills directory to link it into. The skill is available at that ' +
159
+ 'path — add an AGENTS.md reference to it manually if needed. Skipping.',
160
+ )
161
+ }
162
+ }
163
+
164
+ /**
165
+ * `agentSkill` references a public skill by bare name. OpenCode has no command
166
+ * to fetch a skill from a bare name, so we warn and skip.
167
+ */
168
+ function projectAgentSkills(projection: WorkspaceProjection): void {
169
+ for (const skill of projection.skills) {
170
+ if (skill.kind !== 'agent-skill') continue
171
+ console.warn(
172
+ `[opencode] agentSkill "${skill.name}" cannot be projected: OpenCode has no ` +
173
+ 'command to install a public skill by bare name. Provide it as a gitSkill ' +
174
+ 'instead. Skipping.',
175
+ )
176
+ }
177
+ }
178
+
179
+ /**
180
+ * OpenCode has no plugin install command. We warn-and-skip each declared plugin
181
+ * rather than fabricating a command.
182
+ */
183
+ function projectPlugins(projection: WorkspaceProjection): void {
184
+ for (const name of projection.plugins) {
185
+ console.warn(
186
+ `[opencode] plugin "${name}" cannot be installed: OpenCode has no plugin ` +
187
+ 'install command. Skipping.',
188
+ )
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Project a `WorkspaceProjection` into the OpenCode sandbox. Safe to call on
194
+ * every `chatStream`. The secret-bearing `opencode.json` mcp section is
195
+ * (re)written on every call, re-resolving secrets, so OpenCode always reads
196
+ * current values and a snapshot can never serve a stale or rotated secret.
197
+ * The safe, idempotent, non-secret operations (gitSkill, agentSkill, plugins
198
+ * — all warn-and-skip for OpenCode) are guarded by a one-time marker so they
199
+ * produce at most one warning per sandbox lifetime.
200
+ *
201
+ * @param handle - The sandbox handle (`fs` + `process`).
202
+ * @param projection - The portable workspace inputs from `withSandbox`.
203
+ */
204
+ export async function projectOpencodeWorkspace(
205
+ handle: SandboxHandle,
206
+ projection: WorkspaceProjection,
207
+ ): Promise<void> {
208
+ // Always re-resolve and rewrite the secret-bearing MCP config so rotated
209
+ // secrets re-apply and snapshots can't serve stale values.
210
+ await projectMcpServers(handle, projection)
211
+
212
+ // Gate only the safe, idempotent, non-secret operations on the marker.
213
+ if (await handle.fs.exists(projection.markerPath)) return
214
+
215
+ projectGitSkills(projection)
216
+ projectAgentSkills(projection)
217
+ projectPlugins(projection)
218
+
219
+ await handle.fs.write(projection.markerPath, '')
220
+ }
@@ -0,0 +1,368 @@
1
+ import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
2
+ import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
3
+ import { BaseTextAdapter } from '@tanstack/ai/adapters'
4
+ import {
5
+ SandboxCapability,
6
+ buildApprovalRequestedEvent,
7
+ createBridgeEventChannel,
8
+ getSandbox,
9
+ getToolBridgeProvisioner,
10
+ getWorkspaceProjection,
11
+ mergeChunkStreams,
12
+ nodeHttpBridgeProvisioner,
13
+ } from '@tanstack/ai-sandbox'
14
+ import { buildPrompt } from '../messages/prompt'
15
+ import { startOpencodeSession } from '../process/server'
16
+ import { startOpencodeServerInSandbox } from '../process/sandbox-server'
17
+ import { resolveInteractivePermission } from '../process/permissions'
18
+ import { AsyncQueue } from '../stream/queue'
19
+ import { translateOpencodeStream } from '../stream/translate'
20
+ import { projectOpencodeWorkspace } from './projection'
21
+ import type { HostToolBridge, SandboxHandle } from '@tanstack/ai-sandbox'
22
+ import type {
23
+ StructuredOutputOptions,
24
+ StructuredOutputResult,
25
+ } from '@tanstack/ai/adapters'
26
+ import type {
27
+ DefaultMessageMetadataByModality,
28
+ Modality,
29
+ StreamChunk,
30
+ TextOptions,
31
+ } from '@tanstack/ai'
32
+ import type { OpencodeSessionHandle } from '../process/server'
33
+ import type {
34
+ OpencodePermissionMode,
35
+ PermissionHandler,
36
+ } from '../process/permissions'
37
+ import type { OpencodeStreamEvent } from '../stream/sdk-types'
38
+ import type { OpencodeModel } from '../model-meta'
39
+ import type { OpencodeTextProviderOptions } from '../provider-options'
40
+
41
+ const DEFAULT_WORKDIR = '/workspace'
42
+ const DEFAULT_PORT = 4096
43
+
44
+ export interface OpencodeTextConfig {
45
+ /** Working directory inside the sandbox. Defaults to `/workspace`. */
46
+ directory?: string
47
+ /**
48
+ * Port the in-sandbox `opencode serve` listens on. Defaults to 4096. For the
49
+ * Docker provider this port must also be published (`publishPorts: [4096]`)
50
+ * so the host can reach it.
51
+ */
52
+ port?: number
53
+ /** Hostname the in-sandbox server binds. Defaults to `0.0.0.0`. */
54
+ hostname?: string
55
+ /**
56
+ * OpenCode permission mode driving the dynamic permission handler. Defaults
57
+ * to `'default'`; set `'acceptEdits'` / `'bypassPermissions'` to let the
58
+ * harness edit files and run commands autonomously inside the sandbox.
59
+ */
60
+ permissionMode?: OpencodePermissionMode
61
+ /** Custom permission handler; replaces the adapter's default policy. */
62
+ onPermissionRequest?: PermissionHandler
63
+ }
64
+
65
+ /** Split a `provider/model` id into its provider and model halves. */
66
+ function splitModel(model: string): { providerID: string; modelID: string } {
67
+ const slash = model.indexOf('/')
68
+ if (slash <= 0 || slash === model.length - 1) {
69
+ throw new Error(
70
+ `OpenCode models must be addressed as "provider/model" (e.g. "anthropic/claude-sonnet-4-5"); received "${model}".`,
71
+ )
72
+ }
73
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
74
+ }
75
+
76
+ export class OpencodeTextAdapter<
77
+ TModel extends OpencodeModel,
78
+ > extends BaseTextAdapter<
79
+ TModel,
80
+ OpencodeTextProviderOptions,
81
+ ReadonlyArray<Modality> & readonly ['text'],
82
+ DefaultMessageMetadataByModality,
83
+ ReadonlyArray<string>,
84
+ unknown,
85
+ never
86
+ > {
87
+ readonly name = 'opencode' as const
88
+
89
+ override readonly requires = [SandboxCapability] as const
90
+
91
+ private readonly adapterConfig: OpencodeTextConfig
92
+
93
+ constructor(config: OpencodeTextConfig, model: TModel) {
94
+ super({}, model)
95
+ this.adapterConfig = config
96
+ }
97
+
98
+ private sandboxFrom(
99
+ options: TextOptions<OpencodeTextProviderOptions>,
100
+ ): SandboxHandle {
101
+ const ctx = options.capabilities
102
+ if (!ctx) {
103
+ throw new Error(
104
+ 'Adapter "opencode" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.',
105
+ )
106
+ }
107
+ return getSandbox(ctx)
108
+ }
109
+
110
+ private applySystemPrompts(
111
+ options: TextOptions<OpencodeTextProviderOptions>,
112
+ prompt: string,
113
+ ): string {
114
+ const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
115
+ .map((systemPrompt) => systemPrompt.content)
116
+ .filter((content) => content.trim() !== '')
117
+ if (systemPrompts.length === 0) return prompt
118
+ return `${systemPrompts.join('\n\n')}\n\n${prompt}`
119
+ }
120
+
121
+ async *chatStream(
122
+ options: TextOptions<OpencodeTextProviderOptions>,
123
+ ): AsyncIterable<StreamChunk> {
124
+ const { logger } = options
125
+ let server:
126
+ | Awaited<ReturnType<typeof startOpencodeServerInSandbox>>
127
+ | undefined
128
+ let handle: OpencodeSessionHandle | undefined
129
+ let bridge: HostToolBridge | undefined
130
+ const externalSignal =
131
+ options.abortController?.signal ?? options.request?.signal ?? undefined
132
+ let onAbort: (() => void) | undefined
133
+ const runId = options.runId ?? this.generateId()
134
+ const threadId = options.threadId ?? this.generateId()
135
+ // Surfaces custom events from bridged tools (e.g. code mode console logs)
136
+ // on this run's live output stream.
137
+ const channel = createBridgeEventChannel({
138
+ model: this.model,
139
+ threadId,
140
+ runId,
141
+ })
142
+
143
+ try {
144
+ const sandbox = this.sandboxFrom(options)
145
+ const directory =
146
+ options.modelOptions?.directory ??
147
+ this.adapterConfig.directory ??
148
+ DEFAULT_WORKDIR
149
+
150
+ // Project workspace skills / MCP servers into the sandbox before starting
151
+ // the opencode server so the workspace config is in place for the session.
152
+ if (options.capabilities !== undefined) {
153
+ const projection = getWorkspaceProjection(options.capabilities, {
154
+ optional: true,
155
+ })
156
+ if (projection !== undefined) {
157
+ await projectOpencodeWorkspace(sandbox, projection)
158
+ }
159
+ }
160
+
161
+ const modelOptions = options.modelOptions
162
+ const sessionId = modelOptions?.sessionId
163
+ const { prompt: resumePrompt } = buildPrompt(options.messages, sessionId)
164
+ const { providerID, modelID } = splitModel(this.model)
165
+
166
+ // Bridge chat()-provided tools into the in-sandbox server over MCP
167
+ // (configured via OPENCODE_CONFIG_CONTENT at server spawn).
168
+ const bridgedToolNames = new Set(
169
+ (options.tools ?? []).map((tool) => tool.name),
170
+ )
171
+ if (options.tools && options.tools.length > 0) {
172
+ const provisioner =
173
+ (options.capabilities
174
+ ? getToolBridgeProvisioner(options.capabilities, { optional: true })
175
+ : undefined) ?? nodeHttpBridgeProvisioner
176
+ bridge = await provisioner.provision(options.tools, {
177
+ provider: sandbox.provider,
178
+ context: options.context,
179
+ emitCustomEvent: channel.emitCustomEvent,
180
+ ...(externalSignal ? { signal: externalSignal } : {}),
181
+ })
182
+ }
183
+
184
+ // Approval-requested events for `ask`-policy actions with no client
185
+ // decision yet, emitted after the stream so the client can approve + re-run.
186
+ const approvalRequests: Array<StreamChunk> = []
187
+
188
+ const queue = new AsyncQueue<OpencodeStreamEvent>()
189
+ const mode =
190
+ modelOptions?.permissionMode ??
191
+ this.adapterConfig.permissionMode ??
192
+ 'default'
193
+ const permissionHandler: PermissionHandler =
194
+ this.adapterConfig.onPermissionRequest ??
195
+ ((request) => {
196
+ const result = resolveInteractivePermission(
197
+ request,
198
+ mode,
199
+ bridgedToolNames,
200
+ options.approvals,
201
+ )
202
+ if (result.approvalId !== undefined) {
203
+ approvalRequests.push(
204
+ buildApprovalRequestedEvent({
205
+ approvalId: result.approvalId,
206
+ title: result.title ?? request.title,
207
+ threadId,
208
+ runId,
209
+ detail: { provider: 'opencode' },
210
+ }),
211
+ )
212
+ }
213
+ return result.response
214
+ })
215
+
216
+ logger.request(
217
+ `activity=chat provider=opencode model=${this.model} sandbox=${sandbox.provider} messages=${options.messages.length} resume=${sessionId ?? 'none'}`,
218
+ { provider: 'opencode', model: this.model },
219
+ )
220
+
221
+ const serverEnv = bridge
222
+ ? {
223
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
224
+ mcp: {
225
+ [bridge.name]: {
226
+ type: 'remote',
227
+ url: bridge.url,
228
+ enabled: true,
229
+ headers: { Authorization: `Bearer ${bridge.token}` },
230
+ },
231
+ },
232
+ }),
233
+ }
234
+ : undefined
235
+
236
+ server = await startOpencodeServerInSandbox(sandbox, {
237
+ port: this.adapterConfig.port ?? DEFAULT_PORT,
238
+ ...(this.adapterConfig.hostname !== undefined && {
239
+ hostname: this.adapterConfig.hostname,
240
+ }),
241
+ cwd: directory,
242
+ ...(serverEnv ? { env: serverEnv } : {}),
243
+ ...(externalSignal ? { signal: externalSignal } : {}),
244
+ })
245
+
246
+ handle = await startOpencodeSession({
247
+ baseUrl: server.baseUrl,
248
+ // Forward the channel's auth headers (e.g. Daytona's preview token) so
249
+ // the host client can reach a token-gated preview proxy.
250
+ ...(server.headers !== undefined && { headers: server.headers }),
251
+ // NOTE: do NOT pass `directory` here. `directory` is the VIRTUAL sandbox
252
+ // path (e.g. `/workspace`); the server is already spawned with that as its
253
+ // cwd (the provider handle maps it to the real workdir — `/workspace`
254
+ // inside Docker, a host temp dir for local-process). Forwarding the
255
+ // virtual path to the host-side opencode HTTP API breaks local-process,
256
+ // where `/workspace` doesn't exist → the API stalls until the request
257
+ // times out. Omitting it makes opencode use the server's (correct) cwd.
258
+ providerID,
259
+ modelID,
260
+ ...(sessionId !== undefined && { resumeSessionId: sessionId }),
261
+ onEvent: (event) => queue.push({ kind: 'event', event }),
262
+ onPermissionRequest: permissionHandler,
263
+ onError: (error) => queue.fail(error),
264
+ })
265
+ const session = handle
266
+
267
+ if (externalSignal !== undefined) {
268
+ onAbort = () => void session.abort().catch(() => undefined)
269
+ if (externalSignal.aborted) onAbort()
270
+ else externalSignal.addEventListener('abort', onAbort, { once: true })
271
+ }
272
+
273
+ queue.push({ kind: 'session', sessionId: session.sessionId })
274
+
275
+ const promptText = this.applySystemPrompts(
276
+ options,
277
+ session.resumed || sessionId === undefined
278
+ ? resumePrompt
279
+ : buildPrompt(options.messages, undefined).prompt,
280
+ )
281
+
282
+ session
283
+ .prompt(promptText)
284
+ .then(({ message }) => {
285
+ queue.push({ kind: 'done', message })
286
+ queue.end()
287
+ })
288
+ .catch((error: unknown) => queue.fail(error))
289
+
290
+ yield* mergeChunkStreams(
291
+ translateOpencodeStream(queue, {
292
+ model: this.model,
293
+ runId,
294
+ threadId,
295
+ ...(options.parentRunId !== undefined && {
296
+ parentRunId: options.parentRunId,
297
+ }),
298
+ genId: () => this.generateId(),
299
+ bridgedToolNames,
300
+ onStreamEvent: (event) =>
301
+ logger.provider(`provider=opencode kind=${event.kind}`, {
302
+ chunk: event,
303
+ }),
304
+ }),
305
+ channel.stream,
306
+ )
307
+
308
+ // Surface pending approval requests (ask-policy actions awaiting a client
309
+ // decision); the client approves and re-runs to continue.
310
+ for (const event of approvalRequests) yield event
311
+ } catch (error: unknown) {
312
+ const err = error as Error & { code?: string }
313
+ const rawEvent = toRunErrorRawEvent(error)
314
+ logger.errors('opencode.chatStream fatal', {
315
+ error,
316
+ source: 'opencode.chatStream',
317
+ })
318
+ yield {
319
+ type: EventType.RUN_ERROR,
320
+ model: options.model,
321
+ timestamp: Date.now(),
322
+ message: err.message || 'Unknown error occurred',
323
+ ...(err.code !== undefined && { code: err.code }),
324
+ ...(rawEvent !== undefined && { rawEvent }),
325
+ error: {
326
+ message: err.message || 'Unknown error occurred',
327
+ ...(err.code !== undefined && { code: err.code }),
328
+ },
329
+ }
330
+ } finally {
331
+ if (externalSignal !== undefined && onAbort !== undefined) {
332
+ externalSignal.removeEventListener('abort', onAbort)
333
+ }
334
+ channel.close()
335
+ await handle?.dispose()
336
+ await server?.dispose()
337
+ await bridge?.close()
338
+ }
339
+ }
340
+
341
+ structuredOutput(
342
+ _options: StructuredOutputOptions<OpencodeTextProviderOptions>,
343
+ ): Promise<StructuredOutputResult<unknown>> {
344
+ return Promise.reject(
345
+ new Error(
346
+ 'Structured output is not yet supported by the in-sandbox OpenCode adapter. ' +
347
+ 'Use a model adapter for structured output, or omit outputSchema.',
348
+ ),
349
+ )
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Creates an OpenCode harness adapter that runs **inside a sandbox**.
355
+ *
356
+ * It declares `requires: [SandboxCapability]`, spawns `opencode serve` inside
357
+ * the sandbox provided by `withSandbox(...)`, exposes its port, and connects
358
+ * the `@opencode-ai/sdk` HTTP client to it. OpenCode owns the agent loop and
359
+ * executes its native tools against the sandbox workspace. The sandbox image
360
+ * must provide the `opencode` executable (Docker: also publish the server port
361
+ * via `publishPorts`). chat()-provided tools aren't bridged yet.
362
+ */
363
+ export function opencodeText<TModel extends OpencodeModel>(
364
+ model: TModel,
365
+ config: OpencodeTextConfig = {},
366
+ ): OpencodeTextAdapter<TModel> {
367
+ return new OpencodeTextAdapter(config, model)
368
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ export { OpencodeTextAdapter, opencodeText } from './adapters/text'
2
+ export type { OpencodeTextConfig } from './adapters/text'
3
+ export type { OpencodeTextProviderOptions } from './provider-options'
4
+ export { OPENCODE_MODELS } from './model-meta'
5
+ export type { OpencodeModel, KnownOpencodeModel } from './model-meta'
6
+ export {
7
+ SESSION_ID_EVENT,
8
+ TODO_EVENT,
9
+ BRIDGED_MCP_SERVER_NAME,
10
+ translateOpencodeStream,
11
+ resolveToolName,
12
+ } from './stream/translate'
13
+ export type { TranslateContext } from './stream/translate'
14
+ export type {
15
+ OpencodeAssistantMessage,
16
+ OpencodeEvent,
17
+ OpencodePart,
18
+ OpencodeStreamEvent,
19
+ OpencodeTokens,
20
+ OpencodeToolState,
21
+ } from './stream/sdk-types'
22
+ export { resolvePermission, matchBridgedToolName } from './process/permissions'
23
+ export type {
24
+ OpencodePermissionMode,
25
+ OpencodePermissionRequest,
26
+ OpencodePermissionResponse,
27
+ PermissionHandler,
28
+ } from './process/permissions'
29
+ export { startOpencodeSession } from './process/server'
30
+ export type {
31
+ OpencodeSessionHandle,
32
+ StartOpencodeSessionOptions,
33
+ } from './process/server'
34
+ export { buildPrompt } from './messages/prompt'
35
+ export type { BuiltPrompt } from './messages/prompt'
36
+ export { startOpencodeServerInSandbox } from './process/sandbox-server'
37
+ export type {
38
+ SandboxOpencodeServer,
39
+ StartServerOptions,
40
+ } from './process/sandbox-server'
@@ -0,0 +1,67 @@
1
+ import type { ModelMessage } from '@tanstack/ai'
2
+
3
+ export interface BuiltPrompt {
4
+ prompt: string
5
+ /** OpenCode session id to resume, when the caller threaded one through. */
6
+ resume?: string
7
+ }
8
+
9
+ function extractText(content: ModelMessage['content']): string {
10
+ if (content === null) return ''
11
+ if (typeof content === 'string') return content
12
+ return content
13
+ .map((part) =>
14
+ part.type === 'text' && typeof part.content === 'string'
15
+ ? part.content
16
+ : '',
17
+ )
18
+ .join('')
19
+ }
20
+
21
+ /**
22
+ * Convert TanStack chat history into the OpenCode prompt + resume inputs.
23
+ *
24
+ * With a `sessionId`, the harness already holds the conversation context, so
25
+ * only the trailing user message is sent and the session is resumed. Without
26
+ * one, prior turns are flattened into a plain-text transcript preamble (tool
27
+ * messages and tool-call-only assistant turns are harness-internal noise and
28
+ * are skipped; prompts are text-only in v1).
29
+ */
30
+ export function buildPrompt(
31
+ messages: Array<ModelMessage>,
32
+ sessionId: string | undefined,
33
+ ): BuiltPrompt {
34
+ const lastMessage = messages.at(-1)
35
+ const lastUserText =
36
+ lastMessage?.role === 'user' ? extractText(lastMessage.content).trim() : ''
37
+
38
+ if (!lastUserText) {
39
+ throw new Error(
40
+ 'OpenCode adapter requires a trailing user message with text content.',
41
+ )
42
+ }
43
+
44
+ if (sessionId !== undefined) {
45
+ return { prompt: lastUserText, resume: sessionId }
46
+ }
47
+
48
+ const priorTurns = messages
49
+ .slice(0, -1)
50
+ .filter(
51
+ (message) =>
52
+ (message.role === 'user' || message.role === 'assistant') &&
53
+ extractText(message.content).trim() !== '',
54
+ )
55
+ .map(
56
+ (message) =>
57
+ `${message.role === 'user' ? 'User' : 'Assistant'}: ${extractText(message.content).trim()}`,
58
+ )
59
+
60
+ if (priorTurns.length === 0) {
61
+ return { prompt: lastUserText }
62
+ }
63
+
64
+ return {
65
+ prompt: `Previous conversation:\n${priorTurns.join('\n')}\n\n${lastUserText}`,
66
+ }
67
+ }