@liontree/opencode-agent-sdk 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 ADDED
@@ -0,0 +1,220 @@
1
+ # opencode-agent-sdk
2
+
3
+ High-level agent runtime for `@opencode-ai/sdk/v2`.
4
+
5
+ This package wraps OpenCode's lower-level session and SSE APIs with a more agent-oriented model:
6
+
7
+ - declare agents once
8
+ - create reusable sessions
9
+ - call `query()` / `receiveResponse()` or `run()`
10
+ - consume normalized text, tool-call, status, error, and final-result events
11
+
12
+ It is inspired by the usability level of Claude's agent SDK, but it does not try to be API-compatible 1:1.
13
+
14
+ ## Config behavior
15
+
16
+ This SDK follows OpenCode's normal config resolution and adds programmatic overrides on top.
17
+
18
+ - OpenCode still loads its normal config chain such as global config, project config, `.opencode/`, and managed config
19
+ - if `OPENCODE_CONFIG_CONTENT` is already present in the parent process, this SDK inherits and merges it by default
20
+ - options passed to `createAgentRuntime()` are applied as inline runtime overrides on top of the inherited config
21
+
22
+ In practice, the resolved precedence for config passed through this SDK is:
23
+
24
+ 1. existing `OPENCODE_CONFIG_CONTENT` from the parent environment
25
+ 2. `options.config`
26
+ 3. SDK-managed overrides such as `agents`, `mcp`, `permission`, and `model`
27
+
28
+ Pass `rawConfigContent` if you want to override the inherited inline config content explicitly.
29
+
30
+ ## Why
31
+
32
+ `@opencode-ai/sdk` is a solid transport/client layer, but most applications still need to rebuild the same higher-level pieces:
33
+
34
+ - session lifecycle helpers
35
+ - per-turn completion handling
36
+ - stream filtering by session
37
+ - message delta vs snapshot reconciliation
38
+ - tool call lifecycle normalization
39
+ - final assistant message lookup
40
+
41
+ `@liontree/opencode-agent-sdk` packages those concerns into a reusable runtime.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ npm install @liontree/opencode-agent-sdk
47
+ ```
48
+
49
+ This package declares `@opencode-ai/sdk` and `opencode-ai` as dependencies, so you do not need to install them separately.
50
+
51
+ ## Requirements
52
+
53
+ - a working OpenCode provider/auth setup available through normal OpenCode config resolution or inline config passed to this SDK
54
+
55
+ ## Quick Start
56
+
57
+ ```bash
58
+ npm install @liontree/opencode-agent-sdk
59
+ ```
60
+
61
+ ```ts
62
+ import { createAgentRuntime } from "@liontree/opencode-agent-sdk"
63
+
64
+ const runtime = await createAgentRuntime({
65
+ directory: "/app",
66
+ model: "openai/gpt-5.4",
67
+ permission: {
68
+ "*": "allow",
69
+ },
70
+ mcp: {
71
+ terminal: {
72
+ type: "remote",
73
+ url: "http://127.0.0.1:8000/mcp",
74
+ oauth: false,
75
+ timeout: 3600000,
76
+ },
77
+ },
78
+ agents: {
79
+ fuzzer: {
80
+ description: "Expert in AFL++ fuzzing workflows.",
81
+ prompt: "You are the fuzzing agent...",
82
+ },
83
+ },
84
+ })
85
+
86
+ const session = await runtime.createSession({ agent: "fuzzer" })
87
+
88
+ await session.query("Start fuzzing target libpng")
89
+
90
+ for await (const event of session.receiveResponse()) {
91
+ switch (event.type) {
92
+ case "status":
93
+ console.log("status:", event.status)
94
+ break
95
+ case "text":
96
+ process.stdout.write(event.text)
97
+ break
98
+ case "tool_call":
99
+ console.log("tool:", event.toolName, event.status)
100
+ break
101
+ case "error":
102
+ console.error("error:", event.error)
103
+ break
104
+ case "result":
105
+ console.log("final text:\n", event.result.text)
106
+ break
107
+ }
108
+ }
109
+
110
+ await runtime.dispose()
111
+ ```
112
+
113
+ This example assumes your OpenCode provider/auth configuration is already available through OpenCode's normal config resolution, for example:
114
+
115
+ - `~/.config/opencode/opencode.json`
116
+ - `opencode.json` in the project
117
+ - environment variables consumed by your provider config
118
+ - existing `OPENCODE_CONFIG_CONTENT`
119
+
120
+ If you want to provide inline config explicitly, pass `rawConfigContent` or `config`.
121
+
122
+ ```ts
123
+ const runtime = await createAgentRuntime({
124
+ directory: "/app",
125
+ rawConfigContent: JSON.stringify({
126
+ provider: {
127
+ openai: {
128
+ options: {
129
+ apiKey: "{env:OPENAI_API_KEY}",
130
+ },
131
+ },
132
+ },
133
+ }),
134
+ agents: {
135
+ researcher: {
136
+ prompt: "You are a focused research agent.",
137
+ },
138
+ },
139
+ })
140
+ ```
141
+
142
+ If you only need the final answer instead of a streamed event loop, use `run()`:
143
+
144
+ ```ts
145
+ const result = await session.run("Summarize the current repository")
146
+
147
+ console.log(result.text)
148
+ ```
149
+
150
+ ## Main API
151
+
152
+ ### `createAgentRuntime(options)`
153
+
154
+ Creates a managed OpenCode runtime and injects:
155
+
156
+ - agent prompts
157
+ - optional default model
158
+ - optional MCP config
159
+ - optional permission config
160
+ - optional extra config merged with inherited inline config
161
+
162
+ ### `runtime.createSession({ agent, model })`
163
+
164
+ Creates a reusable OpenCode session and returns an `OpencodeAgentSession`.
165
+
166
+ ### `session.query(prompt, options)`
167
+
168
+ Starts one turn on the session.
169
+
170
+ ### `session.receiveResponse()`
171
+
172
+ Consumes the active turn as an async stream of normalized events:
173
+
174
+ - `status`
175
+ - `text`
176
+ - `tool_call`
177
+ - `error`
178
+ - `result`
179
+
180
+ `receiveResponse()` is single-consumer per turn.
181
+
182
+ ### `session.run(prompt, options)`
183
+
184
+ Convenience helper that internally calls `query()` and consumes the response stream until a final result is available.
185
+
186
+ ### `session.abort()` / `session.interrupt()`
187
+
188
+ Both currently map to OpenCode's session abort behavior.
189
+
190
+ ## Event Model
191
+
192
+ ### `status`
193
+
194
+ Emitted on deduplicated session status transitions such as `busy` and `idle`.
195
+
196
+ ### `text`
197
+
198
+ Emitted for assistant text updates.
199
+
200
+ - `format: "delta"` means incremental text
201
+ - `format: "snapshot"` means the full part text was emitted to recover from a missing delta or stream correction
202
+
203
+ ### `tool_call`
204
+
205
+ Emitted when a tool part changes lifecycle state.
206
+
207
+ ### `error`
208
+
209
+ Emitted for prompt failures, session errors, SSE errors, or shutdown problems.
210
+
211
+ ### `result`
212
+
213
+ Emitted exactly once when the final assistant message can be resolved.
214
+
215
+ ## Notes
216
+
217
+ - This SDK is higher-level than raw OpenCode, not a workflow engine.
218
+ - It does not implement multi-agent orchestration for you.
219
+ - It does not aim for complete Claude SDK compatibility.
220
+ - Model resolution order is: per-call override -> session default -> agent default -> runtime default -> OpenCode config.
@@ -0,0 +1,18 @@
1
+ import type { AgentDefinition, JsonObject, JsonValue, ModelReference, ModelSpec } from "./types.js";
2
+ export type PlainObject = JsonObject;
3
+ type BuildRuntimeConfigOptions = {
4
+ agents: Record<string, AgentDefinition>;
5
+ config?: PlainObject;
6
+ mcp?: PlainObject;
7
+ model?: ModelReference;
8
+ permission?: PlainObject;
9
+ rawConfigContent?: string;
10
+ };
11
+ export declare function isPlainObject(value: JsonValue | object): value is PlainObject;
12
+ export declare function deepMergeConfig(base: PlainObject, overlay: PlainObject): PlainObject;
13
+ export declare function parseRuntimeConfigContent(raw?: string): PlainObject;
14
+ export declare function resolveInlineConfigContent(raw?: string): string | undefined;
15
+ export declare function normalizeModelReference(model: ModelReference): ModelSpec;
16
+ export declare function parseModelSpec(raw?: string): ModelSpec | undefined;
17
+ export declare function buildRuntimeConfig({ agents, config, mcp, model, permission, rawConfigContent, }: BuildRuntimeConfigOptions): PlainObject;
18
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,88 @@
1
+ const INLINE_CONFIG_ENV_VAR = "OPENCODE_CONFIG_CONTENT";
2
+ export function isPlainObject(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+ export function deepMergeConfig(base, overlay) {
6
+ const result = { ...base };
7
+ for (const [key, value] of Object.entries(overlay)) {
8
+ const existing = result[key];
9
+ if (existing && isPlainObject(existing) && isPlainObject(value)) {
10
+ result[key] = deepMergeConfig(existing, value);
11
+ continue;
12
+ }
13
+ result[key] = value;
14
+ }
15
+ return result;
16
+ }
17
+ export function parseRuntimeConfigContent(raw) {
18
+ if (!raw || !raw.trim()) {
19
+ return {};
20
+ }
21
+ const parsed = JSON.parse(raw);
22
+ if (!isPlainObject(parsed)) {
23
+ throw new Error("rawConfigContent must be a JSON object");
24
+ }
25
+ return parsed;
26
+ }
27
+ export function resolveInlineConfigContent(raw) {
28
+ if (typeof raw === "string") {
29
+ return raw;
30
+ }
31
+ return process.env[INLINE_CONFIG_ENV_VAR];
32
+ }
33
+ export function normalizeModelReference(model) {
34
+ if (typeof model !== "string") {
35
+ return model;
36
+ }
37
+ const splitIndex = model.indexOf("/");
38
+ if (splitIndex <= 0 || splitIndex === model.length - 1) {
39
+ throw new Error(`Invalid model reference: ${model}`);
40
+ }
41
+ return {
42
+ providerID: model.slice(0, splitIndex).trim(),
43
+ modelID: model.slice(splitIndex + 1).trim(),
44
+ };
45
+ }
46
+ export function parseModelSpec(raw) {
47
+ if (!raw || !raw.includes("/")) {
48
+ return undefined;
49
+ }
50
+ const splitIndex = raw.indexOf("/");
51
+ const providerID = raw.slice(0, splitIndex).trim();
52
+ const modelID = raw.slice(splitIndex + 1).trim();
53
+ if (!providerID || !modelID) {
54
+ return undefined;
55
+ }
56
+ return { providerID, modelID };
57
+ }
58
+ function toModelString(model) {
59
+ const normalized = normalizeModelReference(model);
60
+ return `${normalized.providerID}/${normalized.modelID}`;
61
+ }
62
+ function buildAgentConfig(agents) {
63
+ const entries = Object.entries(agents).map(([name, definition]) => {
64
+ const config = {
65
+ mode: definition.mode ?? "primary",
66
+ prompt: definition.prompt,
67
+ };
68
+ return [name, config];
69
+ });
70
+ return Object.fromEntries(entries);
71
+ }
72
+ export function buildRuntimeConfig({ agents, config, mcp, model, permission, rawConfigContent, }) {
73
+ const inheritedInlineConfig = parseRuntimeConfigContent(resolveInlineConfigContent(rawConfigContent));
74
+ const optionConfig = config ?? {};
75
+ const managedConfig = {
76
+ agent: buildAgentConfig(agents),
77
+ };
78
+ if (mcp) {
79
+ managedConfig.mcp = mcp;
80
+ }
81
+ if (permission) {
82
+ managedConfig.permission = permission;
83
+ }
84
+ if (model) {
85
+ managedConfig.model = toModelString(model);
86
+ }
87
+ return deepMergeConfig(deepMergeConfig(inheritedInlineConfig, optionConfig), managedConfig);
88
+ }
@@ -0,0 +1,3 @@
1
+ export { buildRuntimeConfig, deepMergeConfig, normalizeModelReference, parseModelSpec, parseRuntimeConfigContent, resolveInlineConfigContent, } from "./config.js";
2
+ export { createAgentRuntime, OpencodeAgentRuntime, OpencodeAgentSession, type Session } from "./runtime.js";
3
+ export type { AgentDefinition, AgentErrorEvent, AgentErrorInfo, AgentQueryOptions, AgentResponseEvent, AgentResultEvent, AgentRunOptions, AgentRuntimeOptions, AgentRuntimeRunOptions, AgentSessionOptions, AgentStatusEvent, AgentTextEvent, AgentToolCallEvent, AgentTurnResult, JsonArray, JsonObject, JsonPrimitive, JsonValue, ModelReference, ModelSpec, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { buildRuntimeConfig, deepMergeConfig, normalizeModelReference, parseModelSpec, parseRuntimeConfigContent, resolveInlineConfigContent, } from "./config.js";
2
+ export { createAgentRuntime, OpencodeAgentRuntime, OpencodeAgentSession } from "./runtime.js";
@@ -0,0 +1,36 @@
1
+ import { type OpencodeClient, type Session } from "@opencode-ai/sdk/v2";
2
+ import type { AgentDefinition, AgentQueryOptions, AgentResponseEvent, AgentRunOptions, AgentRuntimeOptions, AgentRuntimeRunOptions, AgentSessionOptions, AgentTurnResult, ModelReference, ModelSpec } from "./types.js";
3
+ type ManagedServer = {
4
+ close: () => void;
5
+ };
6
+ export declare class OpencodeAgentSession {
7
+ private readonly runtime;
8
+ readonly id: string;
9
+ private readonly defaultAgent?;
10
+ private readonly defaultModel?;
11
+ private activeTurn;
12
+ private startingTurn;
13
+ constructor(runtime: OpencodeAgentRuntime, id: string, defaultAgent?: string | undefined, defaultModel?: ModelReference | undefined);
14
+ private assertAvailable;
15
+ query(prompt: string, options?: AgentQueryOptions): Promise<void>;
16
+ receiveResponse(): AsyncGenerator<AgentResponseEvent, void, unknown>;
17
+ run(prompt: string, options?: AgentRunOptions): Promise<AgentTurnResult>;
18
+ abort(): Promise<void>;
19
+ interrupt(): Promise<void>;
20
+ }
21
+ export declare class OpencodeAgentRuntime {
22
+ readonly client: OpencodeClient;
23
+ readonly directory: string;
24
+ private readonly managedServer?;
25
+ private readonly defaultModel?;
26
+ private disposed;
27
+ private readonly agentDefinitions;
28
+ constructor(client: OpencodeClient, directory: string, agents: Record<string, AgentDefinition>, managedServer?: ManagedServer | undefined, defaultModel?: ModelReference | undefined);
29
+ dispose(): Promise<void>;
30
+ getAgent(name: string): AgentDefinition;
31
+ resolveModel(agentName: string, override?: ModelReference): Promise<ModelSpec>;
32
+ createSession(options?: AgentSessionOptions): Promise<OpencodeAgentSession>;
33
+ run(options: AgentRuntimeRunOptions): Promise<AgentTurnResult>;
34
+ }
35
+ export declare function createAgentRuntime(options: AgentRuntimeOptions): Promise<OpencodeAgentRuntime>;
36
+ export type { Session };
@@ -0,0 +1,618 @@
1
+ import { createOpencode, } from "@opencode-ai/sdk/v2";
2
+ import { buildRuntimeConfig, normalizeModelReference, parseModelSpec } from "./config.js";
3
+ import { delay, extractTextFromParts, formatErrorInfo, requireData, toErrorInfo } from "./utils.js";
4
+ const POST_IDLE_EVENT_DRAIN_MS = 300;
5
+ const EVENT_STREAM_SHUTDOWN_TIMEOUT_MS = 2000;
6
+ function isSessionEvent(event) {
7
+ return (event.type === "message.part.updated" ||
8
+ event.type === "message.part.delta" ||
9
+ event.type === "message.updated" ||
10
+ event.type === "session.status" ||
11
+ event.type === "session.idle" ||
12
+ event.type === "session.error");
13
+ }
14
+ function getEventSessionID(event) {
15
+ switch (event.type) {
16
+ case "message.part.updated":
17
+ return event.properties.part.sessionID;
18
+ case "message.part.delta":
19
+ return event.properties.sessionID;
20
+ case "message.updated":
21
+ return event.properties.info.sessionID;
22
+ case "session.status":
23
+ return event.properties.sessionID;
24
+ case "session.idle":
25
+ return event.properties.sessionID;
26
+ case "session.error":
27
+ return event.properties.sessionID;
28
+ }
29
+ }
30
+ function createIdleGate() {
31
+ let resolved = false;
32
+ let resolveSignal = () => { };
33
+ const signal = new Promise((resolve) => {
34
+ resolveSignal = resolve;
35
+ });
36
+ return {
37
+ signal,
38
+ mark() {
39
+ if (!resolved) {
40
+ resolved = true;
41
+ resolveSignal();
42
+ }
43
+ },
44
+ };
45
+ }
46
+ function createTurnContext() {
47
+ return {
48
+ assistantMessageIDs: new Set(),
49
+ idleGate: createIdleGate(),
50
+ lastStatus: "",
51
+ latestAssistantMessageID: null,
52
+ sawBusy: false,
53
+ textByPartID: new Map(),
54
+ toolStatusByPartID: new Map(),
55
+ };
56
+ }
57
+ function getAssistantMessageError(info) {
58
+ if (!info || info.role !== "assistant" || !info.error) {
59
+ return null;
60
+ }
61
+ return toErrorInfo(info.error);
62
+ }
63
+ class AsyncEventQueue {
64
+ closed = false;
65
+ failure = null;
66
+ values = [];
67
+ waiters = [];
68
+ push(value) {
69
+ if (this.closed || this.failure) {
70
+ return;
71
+ }
72
+ const waiter = this.waiters.shift();
73
+ if (waiter) {
74
+ waiter.resolve({ value, done: false });
75
+ return;
76
+ }
77
+ this.values.push(value);
78
+ }
79
+ close() {
80
+ if (this.closed || this.failure) {
81
+ return;
82
+ }
83
+ this.closed = true;
84
+ for (const waiter of this.waiters.splice(0)) {
85
+ waiter.resolve({ value: undefined, done: true });
86
+ }
87
+ }
88
+ fail(error) {
89
+ if (this.closed || this.failure) {
90
+ return;
91
+ }
92
+ this.failure = error;
93
+ for (const waiter of this.waiters.splice(0)) {
94
+ waiter.reject(error);
95
+ }
96
+ }
97
+ next() {
98
+ if (this.values.length > 0) {
99
+ const value = this.values.shift();
100
+ return Promise.resolve({ value: value, done: false });
101
+ }
102
+ if (this.failure) {
103
+ return Promise.reject(this.failure);
104
+ }
105
+ if (this.closed) {
106
+ return Promise.resolve({ value: undefined, done: true });
107
+ }
108
+ return new Promise((resolve, reject) => {
109
+ this.waiters.push({ resolve, reject });
110
+ });
111
+ }
112
+ [Symbol.asyncIterator]() {
113
+ return {
114
+ next: () => this.next(),
115
+ };
116
+ }
117
+ }
118
+ class ActiveTurn {
119
+ args;
120
+ claimed = false;
121
+ finalResult = null;
122
+ queue = new AsyncEventQueue();
123
+ worker;
124
+ constructor(args) {
125
+ this.args = args;
126
+ this.worker = this.run();
127
+ }
128
+ async *consume() {
129
+ if (this.claimed) {
130
+ throw new Error("receiveResponse() can only be consumed once per query()");
131
+ }
132
+ this.claimed = true;
133
+ for await (const event of this.queue) {
134
+ yield event;
135
+ }
136
+ }
137
+ getResult() {
138
+ return this.finalResult;
139
+ }
140
+ emit(event) {
141
+ this.queue.push(event);
142
+ }
143
+ emitError(error) {
144
+ this.emit({
145
+ type: "error",
146
+ agent: this.args.agent,
147
+ sessionID: this.args.sessionID,
148
+ error: toErrorInfo(error),
149
+ });
150
+ }
151
+ markIdleIfReady(context) {
152
+ if (context.sawBusy || context.latestAssistantMessageID !== null) {
153
+ context.idleGate.mark();
154
+ }
155
+ }
156
+ emitStatus(context, status) {
157
+ if (!status || status === context.lastStatus) {
158
+ return;
159
+ }
160
+ context.lastStatus = status;
161
+ this.emit({
162
+ type: "status",
163
+ agent: this.args.agent,
164
+ sessionID: this.args.sessionID,
165
+ status,
166
+ });
167
+ }
168
+ emitTextEvent(event) {
169
+ this.emit({
170
+ type: "text",
171
+ agent: this.args.agent,
172
+ sessionID: this.args.sessionID,
173
+ ...event,
174
+ });
175
+ }
176
+ emitToolCallEvent(event) {
177
+ this.emit({
178
+ type: "tool_call",
179
+ agent: this.args.agent,
180
+ sessionID: this.args.sessionID,
181
+ ...event,
182
+ });
183
+ }
184
+ handleMessagePartDelta(event, context) {
185
+ if (event.properties.field !== "text" || !event.properties.delta) {
186
+ return;
187
+ }
188
+ const previous = context.textByPartID.get(event.properties.partID) ?? "";
189
+ context.textByPartID.set(event.properties.partID, `${previous}${event.properties.delta}`);
190
+ this.emitTextEvent({
191
+ format: "delta",
192
+ messageID: event.properties.messageID,
193
+ partID: event.properties.partID,
194
+ text: event.properties.delta,
195
+ });
196
+ }
197
+ handleTextPartUpdated(part, context) {
198
+ const previous = context.textByPartID.get(part.id) ?? "";
199
+ let format = null;
200
+ let text = "";
201
+ if (part.text.length > previous.length && part.text.startsWith(previous)) {
202
+ text = part.text.slice(previous.length);
203
+ format = text ? "delta" : null;
204
+ }
205
+ else if (part.text !== previous) {
206
+ text = part.text;
207
+ format = "snapshot";
208
+ }
209
+ context.textByPartID.set(part.id, part.text);
210
+ if (!format || !text) {
211
+ return;
212
+ }
213
+ this.emitTextEvent({
214
+ format,
215
+ messageID: part.messageID,
216
+ partID: part.id,
217
+ text,
218
+ });
219
+ }
220
+ handleToolPartUpdated(part, context) {
221
+ const status = part.state.status;
222
+ if (context.toolStatusByPartID.get(part.id) === status) {
223
+ return;
224
+ }
225
+ context.toolStatusByPartID.set(part.id, status);
226
+ const event = {
227
+ attachments: 0,
228
+ callID: part.callID,
229
+ input: part.state.input,
230
+ messageID: part.messageID,
231
+ partID: part.id,
232
+ status,
233
+ toolName: part.tool,
234
+ };
235
+ if (part.state.status === "running" && part.state.title) {
236
+ event.title = part.state.title;
237
+ }
238
+ if (part.state.status === "completed") {
239
+ event.attachments = part.state.attachments?.length ?? 0;
240
+ event.output = part.state.output;
241
+ event.title = part.state.title;
242
+ }
243
+ if (part.state.status === "error") {
244
+ event.error = part.state.error;
245
+ }
246
+ this.emitToolCallEvent({
247
+ ...event,
248
+ });
249
+ }
250
+ handleSessionEvent(event, context) {
251
+ switch (event.type) {
252
+ case "session.status":
253
+ if (event.properties.status.type === "busy") {
254
+ context.sawBusy = true;
255
+ }
256
+ if (event.properties.status.type === "idle") {
257
+ this.markIdleIfReady(context);
258
+ }
259
+ this.emitStatus(context, event.properties.status.type);
260
+ return;
261
+ case "session.idle":
262
+ this.markIdleIfReady(context);
263
+ this.emitStatus(context, "idle");
264
+ return;
265
+ case "session.error":
266
+ this.emitError(event.properties.error);
267
+ return;
268
+ case "message.updated":
269
+ if (event.properties.info.role === "assistant") {
270
+ context.assistantMessageIDs.add(event.properties.info.id);
271
+ context.latestAssistantMessageID = event.properties.info.id;
272
+ }
273
+ return;
274
+ case "message.part.delta":
275
+ if (context.assistantMessageIDs.has(event.properties.messageID)) {
276
+ this.handleMessagePartDelta(event, context);
277
+ }
278
+ return;
279
+ case "message.part.updated":
280
+ if (!context.assistantMessageIDs.has(event.properties.part.messageID)) {
281
+ return;
282
+ }
283
+ if (event.properties.part.type === "text") {
284
+ this.handleTextPartUpdated(event.properties.part, context);
285
+ return;
286
+ }
287
+ if (event.properties.part.type === "tool") {
288
+ this.handleToolPartUpdated(event.properties.part, context);
289
+ }
290
+ return;
291
+ }
292
+ }
293
+ async subscribeToEvents() {
294
+ const controller = new AbortController();
295
+ const subscription = (await this.args.client.event.subscribe({ directory: this.args.directory }, {
296
+ signal: controller.signal,
297
+ onSseError: (error) => {
298
+ if (!controller.signal.aborted) {
299
+ this.emitError(error);
300
+ }
301
+ },
302
+ }));
303
+ const stream = (async function* () {
304
+ for await (const event of subscription.stream) {
305
+ if (isSessionEvent(event)) {
306
+ yield event;
307
+ }
308
+ }
309
+ })();
310
+ return {
311
+ abort: () => {
312
+ controller.abort();
313
+ },
314
+ signal: controller.signal,
315
+ stream,
316
+ };
317
+ }
318
+ async resolveLatestAssistantMessage(sessionID, preferredMessageID) {
319
+ if (preferredMessageID) {
320
+ const message = await requireData("session.message", this.args.client.session.message({
321
+ sessionID,
322
+ messageID: preferredMessageID,
323
+ directory: this.args.directory,
324
+ }));
325
+ return {
326
+ info: message.info ?? null,
327
+ parts: message.parts ?? [],
328
+ };
329
+ }
330
+ const messages = await requireData("session.messages", this.args.client.session.messages({
331
+ sessionID,
332
+ directory: this.args.directory,
333
+ }));
334
+ const latestAssistant = [...messages].reverse().find((entry) => entry.info.role === "assistant");
335
+ if (!latestAssistant) {
336
+ return {
337
+ info: null,
338
+ parts: [],
339
+ };
340
+ }
341
+ return {
342
+ info: latestAssistant.info ?? null,
343
+ parts: latestAssistant.parts ?? [],
344
+ };
345
+ }
346
+ async run() {
347
+ const context = createTurnContext();
348
+ const events = await this.subscribeToEvents();
349
+ const consumeTask = (async () => {
350
+ try {
351
+ for await (const event of events.stream) {
352
+ if (getEventSessionID(event) !== this.args.sessionID) {
353
+ continue;
354
+ }
355
+ this.handleSessionEvent(event, context);
356
+ }
357
+ }
358
+ catch (error) {
359
+ if (!events.signal.aborted) {
360
+ this.emitError(error);
361
+ }
362
+ }
363
+ })();
364
+ let turnError = null;
365
+ try {
366
+ const response = await this.args.client.session.promptAsync({
367
+ sessionID: this.args.sessionID,
368
+ directory: this.args.directory,
369
+ agent: this.args.agent,
370
+ model: {
371
+ providerID: this.args.model.providerID,
372
+ modelID: this.args.model.modelID,
373
+ },
374
+ parts: [{ type: "text", text: this.args.prompt }],
375
+ });
376
+ if (response.error) {
377
+ throw new Error(`session.promptAsync failed: ${JSON.stringify(response.error)}`);
378
+ }
379
+ await context.idleGate.signal;
380
+ }
381
+ catch (error) {
382
+ turnError = toErrorInfo(error);
383
+ this.emit({
384
+ type: "error",
385
+ agent: this.args.agent,
386
+ sessionID: this.args.sessionID,
387
+ error: turnError,
388
+ });
389
+ }
390
+ finally {
391
+ await delay(POST_IDLE_EVENT_DRAIN_MS);
392
+ events.abort();
393
+ let shutdownTimer;
394
+ try {
395
+ await Promise.race([
396
+ consumeTask,
397
+ new Promise((_, reject) => {
398
+ shutdownTimer = setTimeout(() => {
399
+ reject(new Error("Timed out shutting down session event stream after abort"));
400
+ }, EVENT_STREAM_SHUTDOWN_TIMEOUT_MS);
401
+ }),
402
+ ]);
403
+ }
404
+ catch (error) {
405
+ const shutdownError = toErrorInfo(error);
406
+ turnError ??= shutdownError;
407
+ this.emit({
408
+ type: "error",
409
+ agent: this.args.agent,
410
+ sessionID: this.args.sessionID,
411
+ error: shutdownError,
412
+ });
413
+ }
414
+ finally {
415
+ if (shutdownTimer) {
416
+ clearTimeout(shutdownTimer);
417
+ }
418
+ }
419
+ }
420
+ try {
421
+ const resolved = await this.resolveLatestAssistantMessage(this.args.sessionID, context.latestAssistantMessageID);
422
+ const result = {
423
+ agent: this.args.agent,
424
+ error: turnError ?? getAssistantMessageError(resolved.info),
425
+ info: resolved.info,
426
+ messageID: resolved.info?.id ?? context.latestAssistantMessageID,
427
+ parts: resolved.parts,
428
+ sessionID: this.args.sessionID,
429
+ text: extractTextFromParts(resolved.parts),
430
+ };
431
+ if (result.info || result.parts.length > 0 || result.error) {
432
+ this.finalResult = result;
433
+ this.emit({
434
+ type: "result",
435
+ agent: this.args.agent,
436
+ sessionID: this.args.sessionID,
437
+ result,
438
+ });
439
+ }
440
+ }
441
+ catch (error) {
442
+ const resolveError = toErrorInfo(error);
443
+ this.emit({
444
+ type: "error",
445
+ agent: this.args.agent,
446
+ sessionID: this.args.sessionID,
447
+ error: resolveError,
448
+ });
449
+ }
450
+ finally {
451
+ this.queue.close();
452
+ }
453
+ }
454
+ }
455
+ export class OpencodeAgentSession {
456
+ runtime;
457
+ id;
458
+ defaultAgent;
459
+ defaultModel;
460
+ activeTurn = null;
461
+ startingTurn = false;
462
+ constructor(runtime, id, defaultAgent, defaultModel) {
463
+ this.runtime = runtime;
464
+ this.id = id;
465
+ this.defaultAgent = defaultAgent;
466
+ this.defaultModel = defaultModel;
467
+ }
468
+ assertAvailable() {
469
+ if (this.startingTurn || this.activeTurn) {
470
+ throw new Error("A turn is already active for this session");
471
+ }
472
+ }
473
+ async query(prompt, options = {}) {
474
+ this.assertAvailable();
475
+ this.startingTurn = true;
476
+ try {
477
+ const agent = options.agent ?? this.defaultAgent;
478
+ if (!agent) {
479
+ throw new Error("No agent selected. Pass agent to createSession(), query(), or run().");
480
+ }
481
+ const model = await this.runtime.resolveModel(agent, options.model ?? this.defaultModel);
482
+ this.activeTurn = new ActiveTurn({
483
+ agent,
484
+ client: this.runtime.client,
485
+ directory: this.runtime.directory,
486
+ model,
487
+ prompt,
488
+ sessionID: this.id,
489
+ });
490
+ }
491
+ finally {
492
+ this.startingTurn = false;
493
+ }
494
+ }
495
+ async *receiveResponse() {
496
+ const turn = this.activeTurn;
497
+ if (!turn) {
498
+ throw new Error("No active turn. Call query() first.");
499
+ }
500
+ try {
501
+ for await (const event of turn.consume()) {
502
+ yield event;
503
+ }
504
+ }
505
+ finally {
506
+ if (this.activeTurn === turn) {
507
+ this.activeTurn = null;
508
+ }
509
+ }
510
+ }
511
+ async run(prompt, options = {}) {
512
+ await this.query(prompt, options);
513
+ let lastError = null;
514
+ let result = null;
515
+ for await (const event of this.receiveResponse()) {
516
+ if (event.type === "error") {
517
+ lastError = event.error;
518
+ }
519
+ if (event.type === "result") {
520
+ result = event.result;
521
+ }
522
+ }
523
+ if (result) {
524
+ return result;
525
+ }
526
+ if (lastError) {
527
+ throw new Error(formatErrorInfo(lastError));
528
+ }
529
+ throw new Error("Agent turn completed without a final result");
530
+ }
531
+ async abort() {
532
+ await requireData("session.abort", this.runtime.client.session.abort({
533
+ sessionID: this.id,
534
+ directory: this.runtime.directory,
535
+ }));
536
+ }
537
+ async interrupt() {
538
+ await this.abort();
539
+ }
540
+ }
541
+ export class OpencodeAgentRuntime {
542
+ client;
543
+ directory;
544
+ managedServer;
545
+ defaultModel;
546
+ disposed = false;
547
+ agentDefinitions;
548
+ constructor(client, directory, agents, managedServer, defaultModel) {
549
+ this.client = client;
550
+ this.directory = directory;
551
+ this.managedServer = managedServer;
552
+ this.defaultModel = defaultModel;
553
+ this.agentDefinitions = new Map(Object.entries(agents));
554
+ }
555
+ async dispose() {
556
+ if (this.disposed) {
557
+ return;
558
+ }
559
+ this.disposed = true;
560
+ this.managedServer?.close();
561
+ }
562
+ getAgent(name) {
563
+ const agent = this.agentDefinitions.get(name);
564
+ if (!agent) {
565
+ throw new Error(`Unknown agent '${name}'`);
566
+ }
567
+ return agent;
568
+ }
569
+ async resolveModel(agentName, override) {
570
+ const agentModel = this.getAgent(agentName).model;
571
+ const selected = override ?? agentModel ?? this.defaultModel;
572
+ if (selected) {
573
+ return normalizeModelReference(selected);
574
+ }
575
+ const config = await requireData("config.get", this.client.config.get({
576
+ directory: this.directory,
577
+ }));
578
+ const resolved = parseModelSpec(config.model);
579
+ if (!resolved) {
580
+ throw new Error("Unable to resolve model from OpenCode config. Set runtime.model or configure a default model in OpenCode.");
581
+ }
582
+ return resolved;
583
+ }
584
+ async createSession(options = {}) {
585
+ if (this.disposed) {
586
+ throw new Error("Runtime has been disposed");
587
+ }
588
+ const session = await requireData("session.create", this.client.session.create({
589
+ directory: this.directory,
590
+ }));
591
+ return new OpencodeAgentSession(this, session.id, options.agent, options.model);
592
+ }
593
+ async run(options) {
594
+ const sessionOptions = {
595
+ agent: options.agent,
596
+ ...(options.model ? { model: options.model } : {}),
597
+ };
598
+ const session = await this.createSession(sessionOptions);
599
+ return session.run(options.prompt);
600
+ }
601
+ }
602
+ export async function createAgentRuntime(options) {
603
+ const runtimeConfig = buildRuntimeConfig({
604
+ agents: options.agents,
605
+ ...(options.config ? { config: options.config } : {}),
606
+ ...(options.mcp ? { mcp: options.mcp } : {}),
607
+ ...(options.model ? { model: options.model } : {}),
608
+ ...(options.permission ? { permission: options.permission } : {}),
609
+ ...(options.rawConfigContent ? { rawConfigContent: options.rawConfigContent } : {}),
610
+ });
611
+ const { client, server } = await createOpencode({
612
+ hostname: options.hostname ?? "127.0.0.1",
613
+ port: options.port ?? 0,
614
+ timeout: options.timeoutMs ?? 15000,
615
+ config: runtimeConfig,
616
+ });
617
+ return new OpencodeAgentRuntime(client, options.directory, options.agents, server, options.model);
618
+ }
@@ -0,0 +1,100 @@
1
+ import type { Message, Part, ToolState } from "@opencode-ai/sdk/v2";
2
+ export type JsonPrimitive = boolean | null | number | string;
3
+ export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
4
+ export type JsonArray = JsonValue[];
5
+ export type JsonObject = {
6
+ [key: string]: JsonValue;
7
+ };
8
+ export type ModelSpec = {
9
+ modelID: string;
10
+ providerID: string;
11
+ };
12
+ export type ModelReference = ModelSpec | `${string}/${string}`;
13
+ export type AgentDefinition = {
14
+ description?: string;
15
+ model?: ModelReference;
16
+ mode?: string;
17
+ prompt: string;
18
+ };
19
+ export type AgentRuntimeOptions = {
20
+ agents: Record<string, AgentDefinition>;
21
+ config?: JsonObject;
22
+ directory: string;
23
+ hostname?: string;
24
+ mcp?: JsonObject;
25
+ model?: ModelReference;
26
+ permission?: JsonObject;
27
+ port?: number;
28
+ rawConfigContent?: string;
29
+ timeoutMs?: number;
30
+ };
31
+ export type AgentSessionOptions = {
32
+ agent?: string;
33
+ model?: ModelReference;
34
+ };
35
+ export type AgentQueryOptions = AgentSessionOptions;
36
+ export type AgentRunOptions = AgentSessionOptions;
37
+ export type AgentRuntimeRunOptions = {
38
+ agent: string;
39
+ model?: ModelReference;
40
+ prompt: string;
41
+ };
42
+ export type AgentErrorInfo = {
43
+ code?: string;
44
+ message: string;
45
+ name: string;
46
+ status?: number;
47
+ statusCode?: number;
48
+ };
49
+ export type AgentStatusEvent = {
50
+ agent: string;
51
+ sessionID: string;
52
+ status: string;
53
+ type: "status";
54
+ };
55
+ export type AgentTextEvent = {
56
+ agent: string;
57
+ format: "delta" | "snapshot";
58
+ messageID: string;
59
+ partID: string;
60
+ sessionID: string;
61
+ text: string;
62
+ type: "text";
63
+ };
64
+ export type AgentToolCallEvent = {
65
+ agent: string;
66
+ attachments: number;
67
+ callID: string;
68
+ error?: string;
69
+ input?: unknown;
70
+ messageID: string;
71
+ output?: string;
72
+ partID: string;
73
+ sessionID: string;
74
+ status: ToolState["status"];
75
+ title?: string;
76
+ toolName: string;
77
+ type: "tool_call";
78
+ };
79
+ export type AgentErrorEvent = {
80
+ agent: string;
81
+ error: AgentErrorInfo;
82
+ sessionID: string;
83
+ type: "error";
84
+ };
85
+ export type AgentTurnResult = {
86
+ agent: string;
87
+ error: AgentErrorInfo | null;
88
+ info: Message | null;
89
+ messageID: string | null;
90
+ parts: Part[];
91
+ sessionID: string;
92
+ text: string;
93
+ };
94
+ export type AgentResultEvent = {
95
+ agent: string;
96
+ result: AgentTurnResult;
97
+ sessionID: string;
98
+ type: "result";
99
+ };
100
+ export type AgentResponseEvent = AgentErrorEvent | AgentResultEvent | AgentStatusEvent | AgentTextEvent | AgentToolCallEvent;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import type { Part } from "@opencode-ai/sdk/v2";
2
+ import type { AgentErrorInfo } from "./types.js";
3
+ type ResponseEnvelope<TData, TError> = {
4
+ data?: TData | null | undefined;
5
+ error?: TError | undefined;
6
+ };
7
+ export declare function requireData<TData, TError>(label: string, promise: Promise<ResponseEnvelope<TData, TError>>): Promise<Exclude<TData, null | undefined>>;
8
+ export declare function delay(ms: number): Promise<void>;
9
+ export declare function formatError(error: Error | object | string | number | boolean | null | undefined): string;
10
+ export declare function extractTextFromParts(parts: Part[]): string;
11
+ export declare function toErrorInfo(error: unknown): AgentErrorInfo;
12
+ export declare function formatErrorInfo(error: AgentErrorInfo): string;
13
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,90 @@
1
+ export async function requireData(label, promise) {
2
+ const response = await promise;
3
+ if (response.error) {
4
+ throw new Error(`${label} failed: ${JSON.stringify(response.error)}`);
5
+ }
6
+ if (response.data == null) {
7
+ throw new Error(`${label} failed: empty response data`);
8
+ }
9
+ return response.data;
10
+ }
11
+ export function delay(ms) {
12
+ return new Promise((resolve) => {
13
+ setTimeout(resolve, ms);
14
+ });
15
+ }
16
+ export function formatError(error) {
17
+ if (!(error instanceof Error)) {
18
+ return String(error);
19
+ }
20
+ const errorWithMetadata = error;
21
+ const details = [
22
+ typeof errorWithMetadata.code === "string" && `code=${JSON.stringify(errorWithMetadata.code)}`,
23
+ typeof errorWithMetadata.status === "number" && `status=${errorWithMetadata.status}`,
24
+ typeof errorWithMetadata.statusCode === "number" && `statusCode=${errorWithMetadata.statusCode}`,
25
+ errorWithMetadata.cause instanceof Error &&
26
+ errorWithMetadata.cause.message &&
27
+ `cause=${JSON.stringify(errorWithMetadata.cause.message)}`,
28
+ ].filter((detail) => Boolean(detail));
29
+ if (details.length === 0) {
30
+ return `${error.name}: ${error.message}`;
31
+ }
32
+ return `${error.name}: ${error.message} | ${details.join(", ")}`;
33
+ }
34
+ export function extractTextFromParts(parts) {
35
+ return parts
36
+ .filter((part) => part.type === "text")
37
+ .map((part) => part.text)
38
+ .join("");
39
+ }
40
+ export function toErrorInfo(error) {
41
+ if (error instanceof Error) {
42
+ const errorWithMetadata = error;
43
+ const result = {
44
+ name: error.name,
45
+ message: error.message,
46
+ };
47
+ if (typeof errorWithMetadata.code === "string") {
48
+ result.code = errorWithMetadata.code;
49
+ }
50
+ if (typeof errorWithMetadata.status === "number") {
51
+ result.status = errorWithMetadata.status;
52
+ }
53
+ if (typeof errorWithMetadata.statusCode === "number") {
54
+ result.statusCode = errorWithMetadata.statusCode;
55
+ }
56
+ return result;
57
+ }
58
+ if (typeof error === "object" && error !== null) {
59
+ const candidate = error;
60
+ const result = {
61
+ name: typeof candidate.name === "string" ? candidate.name : "Error",
62
+ message: typeof candidate.message === "string" ? candidate.message : JSON.stringify(error),
63
+ };
64
+ if (typeof candidate.code === "string") {
65
+ result.code = candidate.code;
66
+ }
67
+ if (typeof candidate.status === "number") {
68
+ result.status = candidate.status;
69
+ }
70
+ if (typeof candidate.statusCode === "number") {
71
+ result.statusCode = candidate.statusCode;
72
+ }
73
+ return result;
74
+ }
75
+ return {
76
+ name: "Error",
77
+ message: String(error),
78
+ };
79
+ }
80
+ export function formatErrorInfo(error) {
81
+ const details = [
82
+ typeof error.code === "string" && `code=${JSON.stringify(error.code)}`,
83
+ typeof error.status === "number" && `status=${error.status}`,
84
+ typeof error.statusCode === "number" && `statusCode=${error.statusCode}`,
85
+ ].filter((detail) => Boolean(detail));
86
+ if (details.length === 0) {
87
+ return `${error.name}: ${error.message}`;
88
+ }
89
+ return `${error.name}: ${error.message} | ${details.join(", ")}`;
90
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@liontree/opencode-agent-sdk",
3
+ "version": "0.1.0",
4
+ "description": "High-level agent runtime built on top of @opencode-ai/sdk",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "check": "tsc -p tsconfig.json --noEmit"
21
+ },
22
+ "dependencies": {
23
+ "@opencode-ai/sdk": "^1.2.15",
24
+ "opencode-ai": "^1.2.15"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.5.2",
28
+ "typescript": "^5.9.2"
29
+ },
30
+ "keywords": [
31
+ "opencode",
32
+ "agent",
33
+ "sdk"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/LioTree/opencode-agent-sdk.git"
38
+ },
39
+ "homepage": "https://github.com/LioTree/opencode-agent-sdk#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/LioTree/opencode-agent-sdk/issues"
42
+ },
43
+ "license": "MIT"
44
+ }