@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.
- package/README.md +18 -0
- package/dist/esm/adapters/projection.d.ts +14 -0
- package/dist/esm/adapters/projection.js +83 -0
- package/dist/esm/adapters/projection.js.map +1 -0
- package/dist/esm/adapters/text.d.ts +47 -0
- package/dist/esm/adapters/text.js +231 -0
- package/dist/esm/adapters/text.js.map +1 -0
- package/dist/esm/index.d.ts +16 -0
- package/dist/esm/index.js +23 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/messages/prompt.d.ts +16 -0
- package/dist/esm/messages/prompt.js +37 -0
- package/dist/esm/messages/prompt.js.map +1 -0
- package/dist/esm/model-meta.d.ts +14 -0
- package/dist/esm/model-meta.js +13 -0
- package/dist/esm/model-meta.js.map +1 -0
- package/dist/esm/process/permissions.d.ts +50 -0
- package/dist/esm/process/permissions.js +51 -0
- package/dist/esm/process/permissions.js.map +1 -0
- package/dist/esm/process/sandbox-server.d.ts +23 -0
- package/dist/esm/process/sandbox-server.js +72 -0
- package/dist/esm/process/sandbox-server.js.map +1 -0
- package/dist/esm/process/server.d.ts +77 -0
- package/dist/esm/process/server.js +160 -0
- package/dist/esm/process/server.js.map +1 -0
- package/dist/esm/provider-options.d.ts +18 -0
- package/dist/esm/stream/queue.d.ts +18 -0
- package/dist/esm/stream/queue.js +56 -0
- package/dist/esm/stream/queue.js.map +1 -0
- package/dist/esm/stream/sdk-types.d.ts +134 -0
- package/dist/esm/stream/translate.d.ts +51 -0
- package/dist/esm/stream/translate.js +305 -0
- package/dist/esm/stream/translate.js.map +1 -0
- package/package.json +62 -0
- package/src/adapters/projection.ts +220 -0
- package/src/adapters/text.ts +368 -0
- package/src/index.ts +40 -0
- package/src/messages/prompt.ts +67 -0
- package/src/model-meta.ts +24 -0
- package/src/process/permissions.ts +119 -0
- package/src/process/sandbox-server.ts +130 -0
- package/src/process/server.ts +270 -0
- package/src/provider-options.ts +19 -0
- package/src/stream/queue.ts +64 -0
- package/src/stream/sdk-types.ts +104 -0
- 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
|
+
}
|