@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,24 @@
1
+ /**
2
+ * Models known to work with OpenCode. OpenCode is provider-agnostic — it
3
+ * resolves any `provider/model` id its configured providers support (via the
4
+ * Vercel AI SDK + Models.dev), so this list exists for autocomplete. Any
5
+ * string is accepted via the `(string & {})` escape hatch in
6
+ * {@link OpencodeModel}.
7
+ *
8
+ * Models are addressed as `provider_id/model_id` (e.g.
9
+ * `anthropic/claude-sonnet-4-5`); the adapter splits on the first `/`.
10
+ */
11
+ export const OPENCODE_MODELS = [
12
+ 'anthropic/claude-opus-4-5',
13
+ 'anthropic/claude-sonnet-4-5',
14
+ 'openai/gpt-5.2',
15
+ 'openai/gpt-5.1-codex',
16
+ 'google/gemini-3-pro-preview',
17
+ 'opencode/claude-sonnet-4-5',
18
+ 'opencode/gpt-5.1-codex',
19
+ ] as const
20
+
21
+ export type KnownOpencodeModel = (typeof OPENCODE_MODELS)[number]
22
+
23
+ /** Any `provider/model` id accepted by OpenCode; known ids get autocomplete. */
24
+ export type OpencodeModel = KnownOpencodeModel | (string & {})
@@ -0,0 +1,119 @@
1
+ import { approvalId } from '@tanstack/ai-sandbox'
2
+
3
+ /**
4
+ * Permission modes for the OpenCode adapter, mirroring the Claude Code and
5
+ * Gemini CLI adapters' semantics:
6
+ *
7
+ * - `'default'`: bridged TanStack tools run; anything else that asks for
8
+ * permission is rejected with no prompt (a headless server must never hang
9
+ * on an interactive question).
10
+ * - `'acceptEdits'`: additionally auto-approves file-mutation requests
11
+ * (edit / write / patch).
12
+ * - `'bypassPermissions'`: approves everything.
13
+ */
14
+ export type OpencodePermissionMode =
15
+ | 'default'
16
+ | 'acceptEdits'
17
+ | 'bypassPermissions'
18
+
19
+ /** Structural subset of an OpenCode `permission.updated` payload. */
20
+ export interface OpencodePermissionRequest {
21
+ id: string
22
+ sessionID: string
23
+ /** Permission category, e.g. `'edit'`, `'bash'`, `'webfetch'`, a tool id. */
24
+ type: string
25
+ title: string
26
+ /** Tool call id this permission gates, when it gates a tool. */
27
+ callID?: string
28
+ }
29
+
30
+ /** OpenCode permission reply: allow once, allow always, or reject. */
31
+ export type OpencodePermissionResponse = 'once' | 'always' | 'reject'
32
+
33
+ /** Custom permission handler; replaces the adapter's default policy. */
34
+ export type PermissionHandler = (
35
+ request: OpencodePermissionRequest,
36
+ ) => Promise<OpencodePermissionResponse> | OpencodePermissionResponse
37
+
38
+ /** Permission categories treated as file mutations for `'acceptEdits'`. */
39
+ const EDIT_TYPES = new Set(['edit', 'write', 'patch'])
40
+
41
+ /**
42
+ * Decide whether an OpenCode permission request targets one of the bridged
43
+ * TanStack tools. OpenCode names MCP tools `<server>_<tool>` (e.g.
44
+ * `tanstack_lookup_user`), so a request is bridged when its type or title is
45
+ * a registered tool name, or carries the `tanstack` server prefix.
46
+ */
47
+ export function matchBridgedToolName(
48
+ request: OpencodePermissionRequest,
49
+ bridgedToolNames: ReadonlySet<string> | undefined,
50
+ ): boolean {
51
+ if (!bridgedToolNames || bridgedToolNames.size === 0) return false
52
+ for (const field of [request.type, request.title]) {
53
+ if (typeof field !== 'string' || field === '') continue
54
+ if (bridgedToolNames.has(field)) return true
55
+ if (field.startsWith('tanstack_') && bridgedToolNames.has(field.slice(9))) {
56
+ return true
57
+ }
58
+ if (field.startsWith('tanstack.') && bridgedToolNames.has(field.slice(9))) {
59
+ return true
60
+ }
61
+ }
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * The adapter's default permission policy. Always answers immediately — never
67
+ * hangs a headless server on a question only an interactive user could
68
+ * answer.
69
+ */
70
+ export function resolvePermission(
71
+ request: OpencodePermissionRequest,
72
+ mode: OpencodePermissionMode,
73
+ bridgedToolNames: ReadonlySet<string> | undefined,
74
+ ): OpencodePermissionResponse {
75
+ if (matchBridgedToolName(request, bridgedToolNames)) {
76
+ return 'once'
77
+ }
78
+ if (mode === 'bypassPermissions') {
79
+ return 'once'
80
+ }
81
+ if (mode === 'acceptEdits' && EDIT_TYPES.has(request.type)) {
82
+ return 'once'
83
+ }
84
+ return 'reject'
85
+ }
86
+
87
+ /**
88
+ * Interactive variant: when the mode/bridge policy would reject, consult the
89
+ * client's approval decisions. Returns the OpenCode response plus, when the
90
+ * action still needs a client decision, the `approvalId`/`title` the adapter
91
+ * should surface via an `approval-requested` event.
92
+ */
93
+ export function resolveInteractivePermission(
94
+ request: OpencodePermissionRequest,
95
+ mode: OpencodePermissionMode,
96
+ bridgedToolNames: ReadonlySet<string> | undefined,
97
+ approvals: ReadonlyMap<string, boolean> | undefined,
98
+ ): {
99
+ response: OpencodePermissionResponse
100
+ approvalId?: string
101
+ title?: string
102
+ } {
103
+ if (matchBridgedToolName(request, bridgedToolNames))
104
+ return { response: 'once' }
105
+ if (mode === 'bypassPermissions') return { response: 'once' }
106
+ if (mode === 'acceptEdits' && EDIT_TYPES.has(request.type)) {
107
+ return { response: 'once' }
108
+ }
109
+
110
+ const id = approvalId({
111
+ provider: 'opencode',
112
+ kind: 'tool',
113
+ target: request.type || request.title,
114
+ })
115
+ const granted = approvals?.get(id)
116
+ if (granted === true) return { response: 'once' }
117
+ if (granted === false) return { response: 'reject' }
118
+ return { response: 'reject', approvalId: id, title: request.title }
119
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Boot an `opencode serve` HTTP server INSIDE a sandbox and expose its port so
3
+ * the host `@opencode-ai/sdk` client can connect over `baseUrl`. Mirrors the
4
+ * SDK's own server launch (`opencode serve --hostname=H --port=P`, ready when
5
+ * stdout logs `opencode server listening`).
6
+ */
7
+ import type { SandboxHandle, SpawnHandle } from '@tanstack/ai-sandbox'
8
+
9
+ const READY_MARKER = 'opencode server listening'
10
+
11
+ export interface SandboxOpencodeServer {
12
+ /** URL the host uses to reach the in-sandbox server. */
13
+ baseUrl: string
14
+ /**
15
+ * Headers that authenticate requests to {@link baseUrl}, when the provider's
16
+ * channel is token-gated (e.g. Daytona's `x-daytona-preview-token`). The host
17
+ * opencode client must send these on every request or the preview proxy 404s.
18
+ */
19
+ headers?: Record<string, string>
20
+ /** Stop the server process. */
21
+ dispose: () => Promise<void>
22
+ }
23
+
24
+ export interface StartServerOptions {
25
+ port: number
26
+ hostname?: string
27
+ cwd: string
28
+ /** Extra env for the server process (e.g. `OPENCODE_CONFIG_CONTENT`). */
29
+ env?: Record<string, string>
30
+ timeoutMs?: number
31
+ signal?: AbortSignal
32
+ }
33
+
34
+ export async function startOpencodeServerInSandbox(
35
+ sandbox: SandboxHandle,
36
+ options: StartServerOptions,
37
+ ): Promise<SandboxOpencodeServer> {
38
+ const hostname = options.hostname ?? '0.0.0.0'
39
+ const command = `opencode serve --hostname=${hostname} --port=${options.port}`
40
+ const proc: SpawnHandle = await sandbox.process.spawn(command, {
41
+ cwd: options.cwd,
42
+ ...(options.env ? { env: options.env } : {}),
43
+ ...(options.signal ? { signal: options.signal } : {}),
44
+ })
45
+
46
+ await waitForReady(proc, options.timeoutMs ?? 30_000)
47
+
48
+ const channel = await sandbox.ports.connect(options.port)
49
+ // Carry the channel's auth so the host client can reach a token-gated preview.
50
+ // Prefer ready-made `headers`; fall back to a bearer token if that's all the
51
+ // provider issued.
52
+ const headers =
53
+ channel.headers ??
54
+ (channel.token ? { Authorization: `Bearer ${channel.token}` } : undefined)
55
+ return {
56
+ baseUrl: channel.url,
57
+ ...(headers ? { headers } : {}),
58
+ dispose: () => proc.kill(),
59
+ }
60
+ }
61
+
62
+ function waitForReady(proc: SpawnHandle, timeoutMs: number): Promise<void> {
63
+ return new Promise<void>((resolve, reject) => {
64
+ let stdout = ''
65
+ // Capture stderr too: opencode logs startup failures (e.g. "address already
66
+ // in use" when a previous run's server still holds the port) to stderr, not
67
+ // stdout. Without this the error message is empty and the real cause is lost.
68
+ let stderr = ''
69
+ // Holder object so reads stay typed as `boolean` across async closures
70
+ // (a plain `let` gets flow-narrowed to a literal and trips lint).
71
+ const state = { settled: false }
72
+ const settle = (fn: () => void): void => {
73
+ if (state.settled) return
74
+ state.settled = true
75
+ clearTimeout(timer)
76
+ fn()
77
+ }
78
+ const diagnostics = (): string =>
79
+ [stdout, stderr]
80
+ .map((s) => s.trim())
81
+ .filter(Boolean)
82
+ .join('\n')
83
+ .slice(-500)
84
+ const timer = setTimeout(
85
+ () =>
86
+ settle(() =>
87
+ reject(
88
+ new Error(
89
+ `opencode serve did not become ready within ${timeoutMs}ms${
90
+ diagnostics() ? `: ${diagnostics()}` : ''
91
+ }`,
92
+ ),
93
+ ),
94
+ ),
95
+ timeoutMs,
96
+ )
97
+
98
+ // Drain stderr in the background so its text is available for diagnostics.
99
+ void (async () => {
100
+ try {
101
+ for await (const chunk of proc.stderr) stderr += chunk
102
+ } catch {
103
+ // Ignore: stderr drain errors are non-fatal; stdout drives readiness.
104
+ }
105
+ })()
106
+
107
+ void (async () => {
108
+ try {
109
+ for await (const chunk of proc.stdout) {
110
+ stdout += chunk
111
+ if (stdout.includes(READY_MARKER)) {
112
+ settle(resolve)
113
+ return
114
+ }
115
+ }
116
+ settle(() =>
117
+ reject(
118
+ new Error(
119
+ `opencode serve exited before becoming ready${
120
+ diagnostics() ? `: ${diagnostics()}` : ' (no output)'
121
+ }`,
122
+ ),
123
+ ),
124
+ )
125
+ } catch (error) {
126
+ settle(() => reject(error))
127
+ }
128
+ })()
129
+ })
130
+ }
@@ -0,0 +1,270 @@
1
+ import { createOpencode, createOpencodeClient } from '@opencode-ai/sdk'
2
+ import type { Config, Event, OpencodeClient, Part } from '@opencode-ai/sdk'
3
+ import type {
4
+ OpencodeAssistantMessage,
5
+ OpencodeEvent,
6
+ } from '../stream/sdk-types'
7
+ import type {
8
+ OpencodePermissionRequest,
9
+ OpencodePermissionResponse,
10
+ } from './permissions'
11
+
12
+ /** A live OpenCode session backed by an `opencode serve` HTTP server. */
13
+ export interface OpencodeSessionHandle {
14
+ sessionId: string
15
+ /** Whether an existing session was actually resumed. */
16
+ resumed: boolean
17
+ /**
18
+ * Run one prompt turn. Resolves with the final assistant message (finish
19
+ * reason, token usage, error) and its concatenated text once the harness
20
+ * goes idle. Streaming deltas arrive via `onEvent` while this is pending.
21
+ */
22
+ prompt: (
23
+ text: string,
24
+ ) => Promise<{ message: OpencodeAssistantMessage; text: string }>
25
+ /** Ask the harness to abort the in-flight prompt turn. */
26
+ abort: () => Promise<void>
27
+ /** Tear down the event subscription and (if owned) the server. */
28
+ dispose: () => Promise<void>
29
+ }
30
+
31
+ export interface StartOpencodeSessionOptions {
32
+ /** Connect to an already-running server instead of spawning one. */
33
+ baseUrl?: string
34
+ /**
35
+ * Headers attached to every request to the opencode server — used to
36
+ * authenticate a token-gated preview channel (e.g. Daytona). Without them a
37
+ * gated preview proxy rejects the requests (404 "Not found.").
38
+ */
39
+ headers?: Record<string, string>
40
+ /** Hostname for the spawned server. Defaults to the SDK default. */
41
+ hostname?: string
42
+ /** Port for the spawned server. Defaults to the SDK default. */
43
+ port?: number
44
+ /**
45
+ * Directory the opencode HTTP API scopes the session to. Omit to use the
46
+ * server's own launch cwd (the common case): the server is spawned with the
47
+ * correct working dir per-provider, so passing a directory here is only needed
48
+ * to override it. Passing a VIRTUAL sandbox path (e.g. `/workspace`) is wrong
49
+ * for host-running providers (local-process), where that path doesn't exist —
50
+ * the API then stalls on it. Leave undefined and rely on the server cwd.
51
+ */
52
+ directory?: string
53
+ /** Provider id (the part before `/` in the model id). */
54
+ providerID: string
55
+ /** Model id (the part after `/` in the model id). */
56
+ modelID: string
57
+ /** Extra OpenCode config merged with the adapter's mcp/permission config. */
58
+ config?: Config
59
+ /** Baseline permission policy applied to the spawned server. */
60
+ permission?: Config['permission']
61
+ /** MCP servers (e.g. the TanStack tool bridge) for the session. */
62
+ mcpServers?: Array<{ name: string; url: string }>
63
+ /** Session id to resume; falls back to a fresh session when not found. */
64
+ resumeSessionId?: string
65
+ onEvent: (event: OpencodeEvent) => void
66
+ onPermissionRequest: (
67
+ request: OpencodePermissionRequest,
68
+ ) => Promise<OpencodePermissionResponse> | OpencodePermissionResponse
69
+ /** Called when the event subscription fails mid-turn. */
70
+ onError?: (error: unknown) => void
71
+ }
72
+
73
+ /** Locate the session id an OpenCode event belongs to, when it carries one. */
74
+ function sessionIdOf(event: Event): string | undefined {
75
+ const props = event.properties as { sessionID?: string } | undefined
76
+ if (props?.sessionID !== undefined) return props.sessionID
77
+ if (event.type === 'message.part.updated') {
78
+ return event.properties.part.sessionID
79
+ }
80
+ if (event.type === 'message.updated') {
81
+ return event.properties.info.sessionID
82
+ }
83
+ if (event.type === 'permission.updated') {
84
+ return event.properties.sessionID
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ function buildConfig(options: StartOpencodeSessionOptions): Config {
90
+ const mcp: NonNullable<Config['mcp']> = { ...options.config?.mcp }
91
+ for (const server of options.mcpServers ?? []) {
92
+ mcp[server.name] = { type: 'remote', url: server.url, enabled: true }
93
+ }
94
+ return {
95
+ ...options.config,
96
+ ...(Object.keys(mcp).length > 0 && { mcp }),
97
+ ...(options.permission !== undefined && { permission: options.permission }),
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Boot (or attach to) an OpenCode HTTP server, resolve a session, and wire its
103
+ * event subscription + permission replies.
104
+ *
105
+ * This module is the only place that touches `@opencode-ai/sdk`; the rest of
106
+ * the package works with the structural types in `sdk-types.ts`.
107
+ *
108
+ * Resume semantics: when `resumeSessionId` is set and the server still knows
109
+ * the session (same machine, same data dir), it is reused. Otherwise a fresh
110
+ * session is created and `resumed: false` tells the adapter to send the
111
+ * flattened transcript.
112
+ */
113
+ export async function startOpencodeSession(
114
+ options: StartOpencodeSessionOptions,
115
+ ): Promise<OpencodeSessionHandle> {
116
+ const { directory } = options
117
+ // Spread into a `query` object only when set; omitting lets opencode use the
118
+ // server's launch cwd (correct for every provider — see `directory` docs).
119
+ const dirQuery = directory !== undefined ? { directory } : {}
120
+
121
+ let client: OpencodeClient
122
+ let ownedServer: { close: () => void } | undefined
123
+
124
+ if (options.baseUrl !== undefined) {
125
+ client = createOpencodeClient({
126
+ baseUrl: options.baseUrl,
127
+ ...(options.headers !== undefined && { headers: options.headers }),
128
+ ...(directory !== undefined && { directory }),
129
+ })
130
+ } else {
131
+ const config = buildConfig(options)
132
+ const result = await createOpencode({
133
+ ...(options.hostname !== undefined && { hostname: options.hostname }),
134
+ ...(options.port !== undefined && { port: options.port }),
135
+ ...(Object.keys(config).length > 0 && { config }),
136
+ })
137
+ client = result.client
138
+ ownedServer = result.server
139
+ }
140
+
141
+ // Mutated from several closures (the subscription loop, dispose, teardown);
142
+ // a holder object keeps reads typed as `boolean` rather than being
143
+ // flow-narrowed to a literal across those boundaries.
144
+ const lifecycle = { disposed: false }
145
+
146
+ const teardown = async (): Promise<void> => {
147
+ if (lifecycle.disposed) return
148
+ lifecycle.disposed = true
149
+ ownedServer?.close()
150
+ await Promise.resolve()
151
+ }
152
+
153
+ try {
154
+ // Resolve the session before subscribing so the event filter has an id.
155
+ let sessionId: string | undefined
156
+ let resumed = false
157
+ if (options.resumeSessionId !== undefined) {
158
+ const existing = await client.session.get({
159
+ path: { id: options.resumeSessionId },
160
+ query: dirQuery,
161
+ })
162
+ if (existing.data) {
163
+ sessionId = options.resumeSessionId
164
+ resumed = true
165
+ }
166
+ }
167
+ if (sessionId === undefined) {
168
+ const created = await client.session.create({
169
+ query: dirQuery,
170
+ body: {},
171
+ throwOnError: true,
172
+ })
173
+ sessionId = created.data.id
174
+ }
175
+ const resolvedSessionId = sessionId
176
+
177
+ const handlePermission = async (
178
+ permission: Extract<Event, { type: 'permission.updated' }>['properties'],
179
+ ): Promise<void> => {
180
+ try {
181
+ const response = await options.onPermissionRequest({
182
+ id: permission.id,
183
+ sessionID: permission.sessionID,
184
+ type: permission.type,
185
+ title: permission.title,
186
+ ...(permission.callID !== undefined && { callID: permission.callID }),
187
+ })
188
+ await client.postSessionIdPermissionsPermissionId({
189
+ path: { id: permission.sessionID, permissionID: permission.id },
190
+ query: dirQuery,
191
+ body: { response },
192
+ throwOnError: true,
193
+ })
194
+ } catch (error) {
195
+ if (!lifecycle.disposed) options.onError?.(error)
196
+ }
197
+ }
198
+
199
+ const subscription = await client.event.subscribe()
200
+ const stream = subscription.stream
201
+
202
+ void (async () => {
203
+ try {
204
+ for await (const event of stream) {
205
+ if (lifecycle.disposed) break
206
+ const sid = sessionIdOf(event)
207
+ if (sid !== undefined && sid !== resolvedSessionId) continue
208
+ if (event.type === 'permission.updated') {
209
+ void handlePermission(event.properties)
210
+ continue
211
+ }
212
+ // The SDK event union is a structural superset of the subset the
213
+ // translator consumes; unknown event types match no translator
214
+ // branch and are ignored.
215
+ options.onEvent(event as OpencodeEvent)
216
+ }
217
+ } catch (error) {
218
+ if (!lifecycle.disposed) options.onError?.(error)
219
+ }
220
+ })()
221
+
222
+ return {
223
+ sessionId: resolvedSessionId,
224
+ resumed,
225
+ prompt: async (text: string) => {
226
+ const result = await client.session.prompt({
227
+ path: { id: resolvedSessionId },
228
+ query: dirQuery,
229
+ body: {
230
+ model: { providerID: options.providerID, modelID: options.modelID },
231
+ parts: [{ type: 'text', text }],
232
+ },
233
+ throwOnError: true,
234
+ })
235
+ const data = result.data
236
+ const message = data.info as OpencodeAssistantMessage
237
+ const responseText = data.parts
238
+ .filter(
239
+ (part): part is Extract<Part, { type: 'text' }> =>
240
+ part.type === 'text',
241
+ )
242
+ .map((part) => part.text)
243
+ .join('')
244
+ return { message, text: responseText }
245
+ },
246
+ abort: async () => {
247
+ try {
248
+ await client.session.abort({
249
+ path: { id: resolvedSessionId },
250
+ query: dirQuery,
251
+ })
252
+ } catch {
253
+ // Best-effort: the turn may already be finishing.
254
+ }
255
+ },
256
+ dispose: async () => {
257
+ lifecycle.disposed = true
258
+ try {
259
+ await stream.return(undefined)
260
+ } catch {
261
+ // Ignore: stream may already be closed.
262
+ }
263
+ ownedServer?.close()
264
+ },
265
+ }
266
+ } catch (error) {
267
+ await teardown()
268
+ throw error
269
+ }
270
+ }
@@ -0,0 +1,19 @@
1
+ import type { OpencodePermissionMode } from './process/permissions'
2
+
3
+ /**
4
+ * Per-call provider options for the OpenCode adapter, passed via
5
+ * `modelOptions` on `chat()`.
6
+ */
7
+ export interface OpencodeTextProviderOptions {
8
+ /**
9
+ * Resume an existing OpenCode session. The adapter emits the session id of
10
+ * every fresh run via a CUSTOM `opencode.session-id` stream event; thread
11
+ * it back here to continue that session (only the latest user message is
12
+ * sent — the harness already holds the prior context).
13
+ */
14
+ sessionId?: string
15
+ /** Per-call override of the configured permission mode. */
16
+ permissionMode?: OpencodePermissionMode
17
+ /** Per-call override of the harness working directory. */
18
+ directory?: string
19
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Minimal promise-based async queue bridging the OpenCode event
3
+ * subscription's callback-style notifications into the async-iterable world
4
+ * the stream translator consumes.
5
+ */
6
+ export class AsyncQueue<T> implements AsyncIterable<T> {
7
+ private readonly values: Array<T> = []
8
+ private readonly waiters: Array<{
9
+ resolve: (result: IteratorResult<T>) => void
10
+ reject: (error: unknown) => void
11
+ }> = []
12
+ private ended = false
13
+ private error: unknown = undefined
14
+ private failed = false
15
+
16
+ push(value: T): void {
17
+ if (this.ended || this.failed) return
18
+ const waiter = this.waiters.shift()
19
+ if (waiter) {
20
+ waiter.resolve({ value, done: false })
21
+ } else {
22
+ this.values.push(value)
23
+ }
24
+ }
25
+
26
+ /** Signal normal completion; pending and future reads resolve as done. */
27
+ end(): void {
28
+ if (this.ended || this.failed) return
29
+ this.ended = true
30
+ for (const waiter of this.waiters.splice(0)) {
31
+ waiter.resolve({ value: undefined, done: true })
32
+ }
33
+ }
34
+
35
+ /** Signal failure; pending and future reads reject (after buffered values drain). */
36
+ fail(error: unknown): void {
37
+ if (this.ended || this.failed) return
38
+ this.failed = true
39
+ this.error = error
40
+ for (const waiter of this.waiters.splice(0)) {
41
+ waiter.reject(error)
42
+ }
43
+ }
44
+
45
+ [Symbol.asyncIterator](): AsyncIterator<T> {
46
+ return {
47
+ next: (): Promise<IteratorResult<T>> => {
48
+ if (this.values.length > 0) {
49
+ return Promise.resolve({
50
+ value: this.values.shift() as T,
51
+ done: false,
52
+ })
53
+ }
54
+ if (this.failed) return Promise.reject(this.error)
55
+ if (this.ended) {
56
+ return Promise.resolve({ value: undefined, done: true })
57
+ }
58
+ return new Promise((resolve, reject) => {
59
+ this.waiters.push({ resolve, reject })
60
+ })
61
+ },
62
+ }
63
+ }
64
+ }