@liontree/opencode-agent-sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ This package wraps OpenCode's lower-level session and SSE APIs with a more agent
6
6
 
7
7
  - declare agents once
8
8
  - create reusable sessions
9
- - call `query()` / `receiveResponse()` or `run()`
9
+ - call `query()` / `receiveResponse()` or `runAgent()`
10
10
  - consume normalized text, tool-call, status, error, and final-result events
11
11
 
12
12
  It is inspired by the usability level of Claude's agent SDK, but it does not try to be API-compatible 1:1.
@@ -58,15 +58,14 @@ This package declares `@opencode-ai/sdk` and `opencode-ai` as dependencies, so y
58
58
  npm install @liontree/opencode-agent-sdk
59
59
  ```
60
60
 
61
+ See `examples/subagents.ts` for a complete subagent lineage and `resumeAgent()` example, and `examples/attach.ts` for attaching to an existing OpenCode server.
62
+
61
63
  ```ts
62
64
  import { createAgentRuntime } from "@liontree/opencode-agent-sdk"
63
65
 
64
66
  const runtime = await createAgentRuntime({
65
67
  directory: "/app",
66
68
  model: "openai/gpt-5.4",
67
- permission: {
68
- "*": "allow",
69
- },
70
69
  mcp: {
71
70
  terminal: {
72
71
  type: "remote",
@@ -139,34 +138,113 @@ const runtime = await createAgentRuntime({
139
138
  })
140
139
  ```
141
140
 
142
- If you only need the final answer instead of a streamed event loop, use `run()`:
141
+ If you already have an OpenCode server running, attach to it with `serverUrl`:
142
+
143
+ ```ts
144
+ const runtime = await createAgentRuntime({
145
+ directory: "/app",
146
+ serverUrl: "http://127.0.0.1:40937",
147
+ model: "openai/gpt-5.4",
148
+ })
149
+ ```
150
+
151
+ In attach mode, `model` still works as a local default and is sent with each prompt. Other runtime config such as `config`, `mcp`, `permission`, and `rawConfigContent` is not pushed into the existing server.
152
+
153
+ If you only need the final answer instead of a streamed event loop, use `runAgent()`:
143
154
 
144
155
  ```ts
145
- const result = await session.run("Summarize the current repository")
156
+ const result = await session.runAgent("Summarize the current repository")
146
157
 
147
158
  console.log(result.text)
148
159
  ```
149
160
 
161
+ To stream descendant subagent activity in the same turn, pass `includeSubagents: true` and read `event.source.chainText`:
162
+
163
+ ```ts
164
+ await session.query("Investigate the auth flow", { includeSubagents: true })
165
+
166
+ for await (const event of session.receiveResponse()) {
167
+ if (event.type === "tool_call") {
168
+ console.log(`[${event.source.chainText}]`, event.toolName, event.status)
169
+ }
170
+ }
171
+ ```
172
+
173
+ You can also reopen an existing session and optionally continue it with another turn:
174
+
175
+ ```ts
176
+ const reopened = await runtime.resumeAgent({
177
+ sessionID: "sess_123",
178
+ prompt: "Continue from the last auth findings.",
179
+ })
180
+
181
+ for await (const event of reopened.receiveResponse()) {
182
+ if (event.type === "text") {
183
+ process.stdout.write(event.text)
184
+ }
185
+ }
186
+ ```
187
+
188
+ If you want a subagent to be able to launch another subagent via the `task` tool, you must allow it in that agent's permissions. OpenCode controls `task` separately from normal read/edit/bash permissions.
189
+
190
+ ```ts
191
+ const runtime = await createAgentRuntime({
192
+ directory: "/app",
193
+ model: "openai/gpt-5.4",
194
+ config: {
195
+ agent: {
196
+ general: {
197
+ permission: {
198
+ task: {
199
+ "*": "allow",
200
+ },
201
+ },
202
+ },
203
+ },
204
+ },
205
+ })
206
+ ```
207
+
208
+ Use `config.agent.<name>.permission.task` when you want to override a built-in agent such as `general`. For custom agents declared in `options.agents`, you can also set `permission` directly on the agent definition.
209
+
150
210
  ## Main API
151
211
 
152
212
  ### `createAgentRuntime(options)`
153
213
 
154
- Creates a managed OpenCode runtime and injects:
214
+ Creates an OpenCode runtime in one of two modes:
215
+
216
+ - managed mode: starts and manages an OpenCode server, then injects agent prompts, optional default model, optional MCP config, optional permission config, and optional extra config merged with inherited inline config
217
+ - attach mode: connects to an existing OpenCode server with `serverUrl`
155
218
 
156
- - agent prompts
157
- - optional default model
158
- - optional MCP config
159
- - optional permission config
160
- - optional extra config merged with inherited inline config
219
+ Attach mode does not push `config`, `mcp`, `permission`, or `rawConfigContent` into the existing server. `model` still works as a local default and is sent with each prompt. `serverUrl` cannot be combined with `hostname`, `port`, or `timeoutMs`.
161
220
 
162
221
  ### `runtime.createSession({ agent, model })`
163
222
 
164
223
  Creates a reusable OpenCode session and returns an `OpencodeAgentSession`.
165
224
 
225
+ ### `runtime.openSession(sessionID, { agent, model })`
226
+
227
+ Opens an existing OpenCode session and returns an `OpencodeAgentSession` handle.
228
+
229
+ ### `runtime.listAgents()`
230
+
231
+ Lists the agents currently available from the OpenCode runtime, including built-in subagents such as `general` and `explore`.
232
+
233
+ ### `runtime.runAgent({ agent, prompt, model })`
234
+
235
+ Creates a fresh session, runs one turn, and resolves the final result.
236
+
237
+ ### `runtime.resumeAgent({ sessionID, prompt?, agent?, model?, includeSubagents? })`
238
+
239
+ Reopens an existing session and optionally starts another turn on it.
240
+
166
241
  ### `session.query(prompt, options)`
167
242
 
168
243
  Starts one turn on the session.
169
244
 
245
+ - pass `includeSubagents: true` to receive descendant subagent-session events in the same stream
246
+ - to let a subagent launch more subagents, configure that agent's `permission.task`
247
+
170
248
  ### `session.receiveResponse()`
171
249
 
172
250
  Consumes the active turn as an async stream of normalized events:
@@ -179,7 +257,7 @@ Consumes the active turn as an async stream of normalized events:
179
257
 
180
258
  `receiveResponse()` is single-consumer per turn.
181
259
 
182
- ### `session.run(prompt, options)`
260
+ ### `session.runAgent(prompt, options)`
183
261
 
184
262
  Convenience helper that internally calls `query()` and consumes the response stream until a final result is available.
185
263
 
@@ -212,9 +290,24 @@ Emitted for prompt failures, session errors, SSE errors, or shutdown problems.
212
290
 
213
291
  Emitted exactly once when the final assistant message can be resolved.
214
292
 
293
+ ## Source Metadata
294
+
295
+ Every normalized event now includes a `source` object describing where it came from.
296
+
297
+ - `source.agentType`: raw agent name such as `build`, `general`, or `explore`
298
+ - `source.agentLabel`: display label with sibling ordinal when needed, such as `general#2`
299
+ - `source.chainText`: readable lineage such as `build -> general#2 -> explore#1`
300
+ - `source.sessionID`: the session that produced the event
301
+ - `source.parentSessionID`: parent session ID when the event came from a descendant session
302
+ - `source.rootSessionID`: root session for the current lineage
303
+ - `source.taskID`: session ID when the event came from a subagent task, otherwise `null`
304
+ - `source.sourceToolCallID`: originating `task` tool call when known
305
+
306
+ `event.agent` and `result.agent` are preserved for compatibility and match `source.agentType`.
307
+
215
308
  ## Notes
216
309
 
217
310
  - This SDK is higher-level than raw OpenCode, not a workflow engine.
218
- - It does not implement multi-agent orchestration for you.
311
+ - It exposes OpenCode subagent lineage metadata, but it does not implement orchestration policy for you.
219
312
  - It does not aim for complete Claude SDK compatibility.
220
313
  - Model resolution order is: per-call override -> session default -> agent default -> runtime default -> OpenCode config.
package/dist/config.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { AgentDefinition, JsonObject, JsonValue, ModelReference, ModelSpec } from "./types.js";
2
2
  export type PlainObject = JsonObject;
3
3
  type BuildRuntimeConfigOptions = {
4
- agents: Record<string, AgentDefinition>;
4
+ agents?: Record<string, AgentDefinition>;
5
5
  config?: PlainObject;
6
6
  mcp?: PlainObject;
7
7
  model?: ModelReference;
package/dist/config.js CHANGED
@@ -65,15 +65,25 @@ function buildAgentConfig(agents) {
65
65
  mode: definition.mode ?? "primary",
66
66
  prompt: definition.prompt,
67
67
  };
68
+ if (definition.description) {
69
+ config.description = definition.description;
70
+ }
71
+ if (definition.model) {
72
+ config.model = toModelString(definition.model);
73
+ }
74
+ if (definition.permission) {
75
+ config.permission = definition.permission;
76
+ }
68
77
  return [name, config];
69
78
  });
70
79
  return Object.fromEntries(entries);
71
80
  }
72
81
  export function buildRuntimeConfig({ agents, config, mcp, model, permission, rawConfigContent, }) {
82
+ const resolvedAgents = agents ?? {};
73
83
  const inheritedInlineConfig = parseRuntimeConfigContent(resolveInlineConfigContent(rawConfigContent));
74
84
  const optionConfig = config ?? {};
75
85
  const managedConfig = {
76
- agent: buildAgentConfig(agents),
86
+ agent: buildAgentConfig(resolvedAgents),
77
87
  };
78
88
  if (mcp) {
79
89
  managedConfig.mcp = mcp;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { buildRuntimeConfig, deepMergeConfig, normalizeModelReference, parseModelSpec, parseRuntimeConfigContent, resolveInlineConfigContent, } from "./config.js";
2
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";
3
+ export type { AgentDefinition, AgentErrorEvent, AgentErrorInfo, AgentEventSource, AgentMode, AgentQueryOptions, AgentResponseEvent, AgentResultEvent, AgentRunOptions, AgentRuntimeOptions, AgentRuntimeRunOptions, AgentSessionOptions, AgentStatusEvent, AgentTextEvent, AgentToolCallEvent, AgentTurnResult, JsonArray, JsonObject, JsonPrimitive, JsonValue, ModelReference, ModelSpec, ResumeAgentOptions, RuntimeAgentInfo, } from "./types.js";
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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";
2
+ import type { AgentDefinition, AgentEventSource, AgentQueryOptions, AgentResponseEvent, AgentRunOptions, AgentRuntimeOptions, AgentRuntimeRunOptions, AgentSessionOptions, AgentTurnResult, ModelReference, ModelSpec, ResumeAgentOptions, RuntimeAgentInfo } from "./types.js";
3
3
  type ManagedServer = {
4
4
  close: () => void;
5
5
  };
@@ -15,6 +15,7 @@ export declare class OpencodeAgentSession {
15
15
  query(prompt: string, options?: AgentQueryOptions): Promise<void>;
16
16
  receiveResponse(): AsyncGenerator<AgentResponseEvent, void, unknown>;
17
17
  run(prompt: string, options?: AgentRunOptions): Promise<AgentTurnResult>;
18
+ runAgent(prompt: string, options?: AgentRunOptions): Promise<AgentTurnResult>;
18
19
  abort(): Promise<void>;
19
20
  interrupt(): Promise<void>;
20
21
  }
@@ -25,12 +26,34 @@ export declare class OpencodeAgentRuntime {
25
26
  private readonly defaultModel?;
26
27
  private disposed;
27
28
  private readonly agentDefinitions;
29
+ private runtimeAgentsPromise;
30
+ private readonly sessionInfoCache;
31
+ private readonly sessionNodeCache;
28
32
  constructor(client: OpencodeClient, directory: string, agents: Record<string, AgentDefinition>, managedServer?: ManagedServer | undefined, defaultModel?: ModelReference | undefined);
33
+ private getSessionNode;
34
+ private getSiblingOrdinal;
35
+ trackSessionInfo(session: Session): void;
36
+ trackSessionAgentType(sessionID: string, agentType: string, force?: boolean): void;
37
+ trackTaskSessionLink(input: {
38
+ agentType: string | null;
39
+ childSessionID: string;
40
+ parentSessionID: string;
41
+ sourceToolCallID: string | null;
42
+ }): void;
43
+ getSessionInfo(sessionID: string): Promise<Session>;
44
+ ensureSessionAgentType(sessionID: string, fallbackAgentType?: string): Promise<string | null>;
45
+ primeSessionLineage(sessionID: string, fallbackAgentType?: string): Promise<void>;
46
+ getKnownEventSource(sessionID: string, fallbackAgentType?: string): AgentEventSource;
29
47
  dispose(): Promise<void>;
30
48
  getAgent(name: string): AgentDefinition;
49
+ listAgents(): Promise<RuntimeAgentInfo[]>;
50
+ private getRuntimeAgentInfo;
31
51
  resolveModel(agentName: string, override?: ModelReference): Promise<ModelSpec>;
52
+ openSession(sessionID: string, options?: AgentSessionOptions): Promise<OpencodeAgentSession>;
32
53
  createSession(options?: AgentSessionOptions): Promise<OpencodeAgentSession>;
33
54
  run(options: AgentRuntimeRunOptions): Promise<AgentTurnResult>;
55
+ runAgent(options: AgentRuntimeRunOptions): Promise<AgentTurnResult>;
56
+ resumeAgent(options: ResumeAgentOptions): Promise<OpencodeAgentSession>;
34
57
  }
35
58
  export declare function createAgentRuntime(options: AgentRuntimeOptions): Promise<OpencodeAgentRuntime>;
36
59
  export type { Session };
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createOpencode, } from "@opencode-ai/sdk/v2";
1
+ import { createOpencode, createOpencodeClient, } from "@opencode-ai/sdk/v2";
2
2
  import { buildRuntimeConfig, normalizeModelReference, parseModelSpec } from "./config.js";
3
3
  import { delay, extractTextFromParts, formatErrorInfo, requireData, toErrorInfo } from "./utils.js";
4
4
  const POST_IDLE_EVENT_DRAIN_MS = 300;
@@ -7,9 +7,11 @@ function isSessionEvent(event) {
7
7
  return (event.type === "message.part.updated" ||
8
8
  event.type === "message.part.delta" ||
9
9
  event.type === "message.updated" ||
10
+ event.type === "session.created" ||
10
11
  event.type === "session.status" ||
11
12
  event.type === "session.idle" ||
12
- event.type === "session.error");
13
+ event.type === "session.error" ||
14
+ event.type === "session.updated");
13
15
  }
14
16
  function getEventSessionID(event) {
15
17
  switch (event.type) {
@@ -19,14 +21,43 @@ function getEventSessionID(event) {
19
21
  return event.properties.sessionID;
20
22
  case "message.updated":
21
23
  return event.properties.info.sessionID;
24
+ case "session.created":
25
+ return event.properties.info.id;
22
26
  case "session.status":
23
27
  return event.properties.sessionID;
24
28
  case "session.idle":
25
29
  return event.properties.sessionID;
26
30
  case "session.error":
27
31
  return event.properties.sessionID;
32
+ case "session.updated":
33
+ return event.properties.info.id;
28
34
  }
29
35
  }
36
+ function isRecord(value) {
37
+ return value !== null && typeof value === "object";
38
+ }
39
+ function getRecordString(value, key) {
40
+ if (!isRecord(value)) {
41
+ return null;
42
+ }
43
+ const candidate = value[key];
44
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
45
+ }
46
+ function parseSubagentFromTitle(title) {
47
+ if (!title) {
48
+ return null;
49
+ }
50
+ const match = /\(@([^()]+) subagent\)$/.exec(title.trim());
51
+ return match?.[1] ?? null;
52
+ }
53
+ function compareSessionNodes(a, b, sessionInfoCache) {
54
+ const aCreated = sessionInfoCache.get(a.sessionID)?.time.created ?? Number.MAX_SAFE_INTEGER;
55
+ const bCreated = sessionInfoCache.get(b.sessionID)?.time.created ?? Number.MAX_SAFE_INTEGER;
56
+ if (aCreated !== bCreated) {
57
+ return aCreated - bCreated;
58
+ }
59
+ return a.sessionID.localeCompare(b.sessionID);
60
+ }
30
61
  function createIdleGate() {
31
62
  let resolved = false;
32
63
  let resolveSignal = () => { };
@@ -43,14 +74,16 @@ function createIdleGate() {
43
74
  },
44
75
  };
45
76
  }
46
- function createTurnContext() {
77
+ function createTurnContext(rootSessionID) {
47
78
  return {
48
79
  assistantMessageIDs: new Set(),
80
+ hasAssistantMessage: false,
49
81
  idleGate: createIdleGate(),
50
- lastStatus: "",
51
- latestAssistantMessageID: null,
82
+ lastStatusBySessionID: new Map([[rootSessionID, "pending"]]),
83
+ latestRootAssistantMessageID: null,
52
84
  sawBusy: false,
53
85
  textByPartID: new Map(),
86
+ trackedSessionIDs: new Set([rootSessionID]),
54
87
  toolStatusByPartID: new Map(),
55
88
  };
56
89
  }
@@ -140,44 +173,64 @@ class ActiveTurn {
140
173
  emit(event) {
141
174
  this.queue.push(event);
142
175
  }
143
- emitError(error) {
176
+ getEventSource(sessionID, fallbackAgentType) {
177
+ const rootFallback = sessionID === this.args.sessionID ? this.args.agent : undefined;
178
+ return this.args.runtime.getKnownEventSource(sessionID, fallbackAgentType ?? rootFallback);
179
+ }
180
+ emitError(error, sessionID = this.args.sessionID, fallbackAgentType) {
181
+ const source = this.getEventSource(sessionID, fallbackAgentType);
144
182
  this.emit({
145
183
  type: "error",
146
- agent: this.args.agent,
147
- sessionID: this.args.sessionID,
184
+ agent: source.agentType,
185
+ sessionID,
186
+ source,
148
187
  error: toErrorInfo(error),
149
188
  });
150
189
  }
151
190
  markIdleIfReady(context) {
152
- if (context.sawBusy || context.latestAssistantMessageID !== null) {
191
+ if (context.sawBusy || context.hasAssistantMessage) {
192
+ if (this.args.includeSubagents) {
193
+ for (const sessionID of context.trackedSessionIDs) {
194
+ if (context.lastStatusBySessionID.get(sessionID) !== "idle") {
195
+ return;
196
+ }
197
+ }
198
+ }
153
199
  context.idleGate.mark();
154
200
  }
155
201
  }
156
- emitStatus(context, status) {
157
- if (!status || status === context.lastStatus) {
202
+ emitStatus(sessionID, context, status, fallbackAgentType) {
203
+ const previous = context.lastStatusBySessionID.get(sessionID) ?? "";
204
+ if (!status || status === previous) {
158
205
  return;
159
206
  }
160
- context.lastStatus = status;
207
+ context.lastStatusBySessionID.set(sessionID, status);
208
+ const source = this.getEventSource(sessionID, fallbackAgentType);
161
209
  this.emit({
162
210
  type: "status",
163
- agent: this.args.agent,
164
- sessionID: this.args.sessionID,
211
+ agent: source.agentType,
212
+ sessionID,
213
+ source,
165
214
  status,
166
215
  });
167
216
  }
168
- emitTextEvent(event) {
217
+ emitTextEvent(sessionID, event) {
218
+ const source = this.getEventSource(sessionID);
169
219
  this.emit({
170
220
  type: "text",
171
- agent: this.args.agent,
172
- sessionID: this.args.sessionID,
221
+ agent: source.agentType,
222
+ sessionID,
223
+ source,
173
224
  ...event,
174
225
  });
175
226
  }
176
- emitToolCallEvent(event) {
227
+ emitToolCallEvent(sessionID, event) {
228
+ const source = this.getEventSource(sessionID);
177
229
  this.emit({
178
230
  type: "tool_call",
179
- agent: this.args.agent,
180
- sessionID: this.args.sessionID,
231
+ agent: source.agentType,
232
+ sessionID,
233
+ source,
181
234
  ...event,
182
235
  });
183
236
  }
@@ -187,7 +240,7 @@ class ActiveTurn {
187
240
  }
188
241
  const previous = context.textByPartID.get(event.properties.partID) ?? "";
189
242
  context.textByPartID.set(event.properties.partID, `${previous}${event.properties.delta}`);
190
- this.emitTextEvent({
243
+ this.emitTextEvent(event.properties.sessionID, {
191
244
  format: "delta",
192
245
  messageID: event.properties.messageID,
193
246
  partID: event.properties.partID,
@@ -210,7 +263,7 @@ class ActiveTurn {
210
263
  if (!format || !text) {
211
264
  return;
212
265
  }
213
- this.emitTextEvent({
266
+ this.emitTextEvent(part.sessionID, {
214
267
  format,
215
268
  messageID: part.messageID,
216
269
  partID: part.id,
@@ -243,32 +296,97 @@ class ActiveTurn {
243
296
  if (part.state.status === "error") {
244
297
  event.error = part.state.error;
245
298
  }
246
- this.emitToolCallEvent({
299
+ this.emitToolCallEvent(part.sessionID, {
247
300
  ...event,
248
301
  });
249
302
  }
303
+ extractTaskChildSessionID(part) {
304
+ if (part.tool !== "task") {
305
+ return null;
306
+ }
307
+ const metadata = "metadata" in part.state ? part.state.metadata : undefined;
308
+ return getRecordString(metadata, "sessionId") ?? getRecordString(part.state.input, "task_id");
309
+ }
310
+ extractTaskAgentType(part) {
311
+ if (part.tool !== "task") {
312
+ return null;
313
+ }
314
+ return getRecordString(part.state.input, "subagent_type");
315
+ }
316
+ trackDiscoveredSessions(event, context) {
317
+ switch (event.type) {
318
+ case "session.created":
319
+ case "session.updated": {
320
+ this.args.runtime.trackSessionInfo(event.properties.info);
321
+ if (this.args.includeSubagents &&
322
+ event.properties.info.parentID &&
323
+ context.trackedSessionIDs.has(event.properties.info.parentID)) {
324
+ context.trackedSessionIDs.add(event.properties.info.id);
325
+ if (!context.lastStatusBySessionID.has(event.properties.info.id)) {
326
+ context.lastStatusBySessionID.set(event.properties.info.id, "pending");
327
+ }
328
+ }
329
+ return;
330
+ }
331
+ case "message.updated":
332
+ this.args.runtime.trackSessionAgentType(event.properties.info.sessionID, event.properties.info.agent);
333
+ return;
334
+ case "message.part.updated": {
335
+ if (!this.args.includeSubagents || event.properties.part.type !== "tool") {
336
+ return;
337
+ }
338
+ const part = event.properties.part;
339
+ if (!context.trackedSessionIDs.has(part.sessionID)) {
340
+ return;
341
+ }
342
+ const childSessionID = this.extractTaskChildSessionID(part);
343
+ if (!childSessionID) {
344
+ return;
345
+ }
346
+ this.args.runtime.trackTaskSessionLink({
347
+ agentType: this.extractTaskAgentType(part),
348
+ childSessionID,
349
+ parentSessionID: part.sessionID,
350
+ sourceToolCallID: part.callID,
351
+ });
352
+ context.trackedSessionIDs.add(childSessionID);
353
+ if (!context.lastStatusBySessionID.has(childSessionID)) {
354
+ context.lastStatusBySessionID.set(childSessionID, "pending");
355
+ }
356
+ return;
357
+ }
358
+ default:
359
+ return;
360
+ }
361
+ }
250
362
  handleSessionEvent(event, context) {
251
363
  switch (event.type) {
364
+ case "session.created":
365
+ case "session.updated":
366
+ return;
252
367
  case "session.status":
253
368
  if (event.properties.status.type === "busy") {
254
369
  context.sawBusy = true;
255
370
  }
371
+ this.emitStatus(event.properties.sessionID, context, event.properties.status.type);
256
372
  if (event.properties.status.type === "idle") {
257
373
  this.markIdleIfReady(context);
258
374
  }
259
- this.emitStatus(context, event.properties.status.type);
260
375
  return;
261
376
  case "session.idle":
262
377
  this.markIdleIfReady(context);
263
- this.emitStatus(context, "idle");
378
+ this.emitStatus(event.properties.sessionID, context, "idle");
264
379
  return;
265
380
  case "session.error":
266
- this.emitError(event.properties.error);
381
+ this.emitError(event.properties.error, event.properties.sessionID);
267
382
  return;
268
383
  case "message.updated":
269
384
  if (event.properties.info.role === "assistant") {
270
385
  context.assistantMessageIDs.add(event.properties.info.id);
271
- context.latestAssistantMessageID = event.properties.info.id;
386
+ context.hasAssistantMessage = true;
387
+ if (event.properties.info.sessionID === this.args.sessionID) {
388
+ context.latestRootAssistantMessageID = event.properties.info.id;
389
+ }
272
390
  }
273
391
  return;
274
392
  case "message.part.delta":
@@ -344,12 +462,15 @@ class ActiveTurn {
344
462
  };
345
463
  }
346
464
  async run() {
347
- const context = createTurnContext();
465
+ await this.args.runtime.primeSessionLineage(this.args.sessionID, this.args.agent);
466
+ const context = createTurnContext(this.args.sessionID);
348
467
  const events = await this.subscribeToEvents();
349
468
  const consumeTask = (async () => {
350
469
  try {
351
470
  for await (const event of events.stream) {
352
- if (getEventSessionID(event) !== this.args.sessionID) {
471
+ this.trackDiscoveredSessions(event, context);
472
+ const sessionID = getEventSessionID(event);
473
+ if (!sessionID || !context.trackedSessionIDs.has(sessionID)) {
353
474
  continue;
354
475
  }
355
476
  this.handleSessionEvent(event, context);
@@ -380,12 +501,7 @@ class ActiveTurn {
380
501
  }
381
502
  catch (error) {
382
503
  turnError = toErrorInfo(error);
383
- this.emit({
384
- type: "error",
385
- agent: this.args.agent,
386
- sessionID: this.args.sessionID,
387
- error: turnError,
388
- });
504
+ this.emitError(turnError);
389
505
  }
390
506
  finally {
391
507
  await delay(POST_IDLE_EVENT_DRAIN_MS);
@@ -404,12 +520,7 @@ class ActiveTurn {
404
520
  catch (error) {
405
521
  const shutdownError = toErrorInfo(error);
406
522
  turnError ??= shutdownError;
407
- this.emit({
408
- type: "error",
409
- agent: this.args.agent,
410
- sessionID: this.args.sessionID,
411
- error: shutdownError,
412
- });
523
+ this.emitError(shutdownError);
413
524
  }
414
525
  finally {
415
526
  if (shutdownTimer) {
@@ -418,34 +529,31 @@ class ActiveTurn {
418
529
  }
419
530
  }
420
531
  try {
421
- const resolved = await this.resolveLatestAssistantMessage(this.args.sessionID, context.latestAssistantMessageID);
532
+ const resolved = await this.resolveLatestAssistantMessage(this.args.sessionID, context.latestRootAssistantMessageID);
533
+ const source = this.getEventSource(this.args.sessionID, this.args.agent);
422
534
  const result = {
423
- agent: this.args.agent,
535
+ agent: source.agentType,
424
536
  error: turnError ?? getAssistantMessageError(resolved.info),
425
537
  info: resolved.info,
426
- messageID: resolved.info?.id ?? context.latestAssistantMessageID,
538
+ messageID: resolved.info?.id ?? context.latestRootAssistantMessageID,
427
539
  parts: resolved.parts,
428
540
  sessionID: this.args.sessionID,
541
+ source,
429
542
  text: extractTextFromParts(resolved.parts),
430
543
  };
431
544
  if (result.info || result.parts.length > 0 || result.error) {
432
545
  this.finalResult = result;
433
546
  this.emit({
434
547
  type: "result",
435
- agent: this.args.agent,
548
+ agent: source.agentType,
436
549
  sessionID: this.args.sessionID,
550
+ source,
437
551
  result,
438
552
  });
439
553
  }
440
554
  }
441
555
  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
- });
556
+ this.emitError(toErrorInfo(error));
449
557
  }
450
558
  finally {
451
559
  this.queue.close();
@@ -478,13 +586,16 @@ export class OpencodeAgentSession {
478
586
  if (!agent) {
479
587
  throw new Error("No agent selected. Pass agent to createSession(), query(), or run().");
480
588
  }
589
+ this.runtime.trackSessionAgentType(this.id, agent, true);
481
590
  const model = await this.runtime.resolveModel(agent, options.model ?? this.defaultModel);
482
591
  this.activeTurn = new ActiveTurn({
483
592
  agent,
484
593
  client: this.runtime.client,
485
594
  directory: this.runtime.directory,
595
+ includeSubagents: options.includeSubagents ?? false,
486
596
  model,
487
597
  prompt,
598
+ runtime: this.runtime,
488
599
  sessionID: this.id,
489
600
  });
490
601
  }
@@ -528,6 +639,9 @@ export class OpencodeAgentSession {
528
639
  }
529
640
  throw new Error("Agent turn completed without a final result");
530
641
  }
642
+ async runAgent(prompt, options = {}) {
643
+ return this.run(prompt, options);
644
+ }
531
645
  async abort() {
532
646
  await requireData("session.abort", this.runtime.client.session.abort({
533
647
  sessionID: this.id,
@@ -545,6 +659,9 @@ export class OpencodeAgentRuntime {
545
659
  defaultModel;
546
660
  disposed = false;
547
661
  agentDefinitions;
662
+ runtimeAgentsPromise = null;
663
+ sessionInfoCache = new Map();
664
+ sessionNodeCache = new Map();
548
665
  constructor(client, directory, agents, managedServer, defaultModel) {
549
666
  this.client = client;
550
667
  this.directory = directory;
@@ -552,6 +669,149 @@ export class OpencodeAgentRuntime {
552
669
  this.defaultModel = defaultModel;
553
670
  this.agentDefinitions = new Map(Object.entries(agents));
554
671
  }
672
+ getSessionNode(sessionID) {
673
+ let node = this.sessionNodeCache.get(sessionID);
674
+ if (!node) {
675
+ node = {
676
+ agentType: null,
677
+ parentSessionID: null,
678
+ sessionID,
679
+ sourceToolCallID: null,
680
+ title: null,
681
+ };
682
+ this.sessionNodeCache.set(sessionID, node);
683
+ }
684
+ return node;
685
+ }
686
+ getSiblingOrdinal(node) {
687
+ if (!node.parentSessionID || !node.agentType) {
688
+ return null;
689
+ }
690
+ const siblings = [...this.sessionNodeCache.values()]
691
+ .filter((candidate) => candidate.parentSessionID === node.parentSessionID && candidate.agentType === node.agentType)
692
+ .sort((left, right) => compareSessionNodes(left, right, this.sessionInfoCache));
693
+ const index = siblings.findIndex((candidate) => candidate.sessionID === node.sessionID);
694
+ return index >= 0 ? index + 1 : null;
695
+ }
696
+ trackSessionInfo(session) {
697
+ this.sessionInfoCache.set(session.id, session);
698
+ const node = this.getSessionNode(session.id);
699
+ node.parentSessionID = session.parentID ?? null;
700
+ node.title = session.title;
701
+ const titleAgent = parseSubagentFromTitle(session.title);
702
+ if (titleAgent && !node.agentType) {
703
+ node.agentType = titleAgent;
704
+ }
705
+ }
706
+ trackSessionAgentType(sessionID, agentType, force = false) {
707
+ if (!agentType) {
708
+ return;
709
+ }
710
+ const node = this.getSessionNode(sessionID);
711
+ if (force || !node.agentType) {
712
+ node.agentType = agentType;
713
+ }
714
+ }
715
+ trackTaskSessionLink(input) {
716
+ const node = this.getSessionNode(input.childSessionID);
717
+ if (!node.parentSessionID) {
718
+ node.parentSessionID = input.parentSessionID;
719
+ }
720
+ if (!node.sourceToolCallID && input.sourceToolCallID) {
721
+ node.sourceToolCallID = input.sourceToolCallID;
722
+ }
723
+ if (!node.agentType && input.agentType) {
724
+ node.agentType = input.agentType;
725
+ }
726
+ }
727
+ async getSessionInfo(sessionID) {
728
+ const cached = this.sessionInfoCache.get(sessionID);
729
+ if (cached) {
730
+ return cached;
731
+ }
732
+ const session = await requireData("session.get", this.client.session.get({
733
+ sessionID,
734
+ directory: this.directory,
735
+ }));
736
+ this.trackSessionInfo(session);
737
+ return session;
738
+ }
739
+ async ensureSessionAgentType(sessionID, fallbackAgentType) {
740
+ const node = this.getSessionNode(sessionID);
741
+ if (node.agentType) {
742
+ return node.agentType;
743
+ }
744
+ const titleAgent = parseSubagentFromTitle(node.title);
745
+ if (titleAgent) {
746
+ node.agentType = titleAgent;
747
+ return node.agentType;
748
+ }
749
+ try {
750
+ const messages = await requireData("session.messages", this.client.session.messages({
751
+ sessionID,
752
+ directory: this.directory,
753
+ }));
754
+ const messageWithAgent = [...messages]
755
+ .reverse()
756
+ .find((entry) => typeof entry.info.agent === "string" && entry.info.agent.length > 0);
757
+ if (messageWithAgent?.info.agent) {
758
+ node.agentType = messageWithAgent.info.agent;
759
+ return node.agentType;
760
+ }
761
+ }
762
+ catch {
763
+ // Ignore cache warm-up failures and fall back below.
764
+ }
765
+ if (fallbackAgentType) {
766
+ node.agentType = fallbackAgentType;
767
+ return node.agentType;
768
+ }
769
+ return null;
770
+ }
771
+ async primeSessionLineage(sessionID, fallbackAgentType) {
772
+ let currentSessionID = sessionID;
773
+ while (currentSessionID) {
774
+ const session = await this.getSessionInfo(currentSessionID);
775
+ await this.ensureSessionAgentType(currentSessionID, currentSessionID === sessionID ? fallbackAgentType : undefined);
776
+ currentSessionID = session.parentID ?? null;
777
+ }
778
+ }
779
+ getKnownEventSource(sessionID, fallbackAgentType) {
780
+ const leaf = this.getSessionNode(sessionID);
781
+ if (!leaf.agentType && fallbackAgentType) {
782
+ leaf.agentType = fallbackAgentType;
783
+ }
784
+ const lineage = [];
785
+ const seen = new Set();
786
+ let cursor = leaf;
787
+ while (cursor && !seen.has(cursor.sessionID)) {
788
+ lineage.push(cursor);
789
+ seen.add(cursor.sessionID);
790
+ cursor = cursor.parentSessionID ? this.sessionNodeCache.get(cursor.parentSessionID) : undefined;
791
+ }
792
+ const ordered = lineage.reverse();
793
+ const chain = ordered.map((node, index) => {
794
+ const agentType = node.agentType ?? (node.sessionID === sessionID ? fallbackAgentType : undefined) ?? "unknown";
795
+ if (index === 0) {
796
+ return agentType;
797
+ }
798
+ const ordinal = this.getSiblingOrdinal(node);
799
+ return ordinal ? `${agentType}#${ordinal}` : agentType;
800
+ });
801
+ const agentType = leaf.agentType ?? fallbackAgentType ?? "unknown";
802
+ return {
803
+ agentLabel: chain[chain.length - 1] ?? agentType,
804
+ agentType,
805
+ chain,
806
+ chainText: chain.join(" -> "),
807
+ depth: Math.max(chain.length - 1, 0),
808
+ parentSessionID: leaf.parentSessionID,
809
+ rootSessionID: ordered[0]?.sessionID ?? sessionID,
810
+ sessionID,
811
+ sourceToolCallID: leaf.sourceToolCallID,
812
+ taskID: leaf.parentSessionID ? sessionID : null,
813
+ };
814
+ }
555
815
  async dispose() {
556
816
  if (this.disposed) {
557
817
  return;
@@ -566,9 +826,49 @@ export class OpencodeAgentRuntime {
566
826
  }
567
827
  return agent;
568
828
  }
829
+ async listAgents() {
830
+ if (!this.runtimeAgentsPromise) {
831
+ this.runtimeAgentsPromise = requireData("app.agents", this.client.app.agents({
832
+ directory: this.directory,
833
+ })).then((agents) => agents.map((agent) => {
834
+ const info = {
835
+ mode: agent.mode,
836
+ name: agent.name,
837
+ };
838
+ if (agent.color) {
839
+ info.color = agent.color;
840
+ }
841
+ if (agent.description) {
842
+ info.description = agent.description;
843
+ }
844
+ if (typeof agent.hidden === "boolean") {
845
+ info.hidden = agent.hidden;
846
+ }
847
+ if (agent.model) {
848
+ info.model = {
849
+ modelID: agent.model.modelID,
850
+ providerID: agent.model.providerID,
851
+ };
852
+ }
853
+ if (typeof agent.native === "boolean") {
854
+ info.native = agent.native;
855
+ }
856
+ if (typeof agent.steps === "number") {
857
+ info.steps = agent.steps;
858
+ }
859
+ return info;
860
+ }));
861
+ }
862
+ return this.runtimeAgentsPromise;
863
+ }
864
+ async getRuntimeAgentInfo(name) {
865
+ const agents = await this.listAgents();
866
+ return agents.find((agent) => agent.name === name) ?? null;
867
+ }
569
868
  async resolveModel(agentName, override) {
570
- const agentModel = this.getAgent(agentName).model;
571
- const selected = override ?? agentModel ?? this.defaultModel;
869
+ const configuredModel = this.agentDefinitions.get(agentName)?.model;
870
+ const runtimeModel = (await this.getRuntimeAgentInfo(agentName))?.model;
871
+ const selected = override ?? configuredModel ?? runtimeModel ?? this.defaultModel;
572
872
  if (selected) {
573
873
  return normalizeModelReference(selected);
574
874
  }
@@ -581,6 +881,12 @@ export class OpencodeAgentRuntime {
581
881
  }
582
882
  return resolved;
583
883
  }
884
+ async openSession(sessionID, options = {}) {
885
+ const session = await this.getSessionInfo(sessionID);
886
+ await this.primeSessionLineage(sessionID, options.agent);
887
+ const defaultAgent = (await this.ensureSessionAgentType(sessionID, options.agent ?? parseSubagentFromTitle(session.title) ?? undefined)) ?? undefined;
888
+ return new OpencodeAgentSession(this, sessionID, defaultAgent, options.model);
889
+ }
584
890
  async createSession(options = {}) {
585
891
  if (this.disposed) {
586
892
  throw new Error("Runtime has been disposed");
@@ -588,6 +894,10 @@ export class OpencodeAgentRuntime {
588
894
  const session = await requireData("session.create", this.client.session.create({
589
895
  directory: this.directory,
590
896
  }));
897
+ this.trackSessionInfo(session);
898
+ if (options.agent) {
899
+ this.trackSessionAgentType(session.id, options.agent);
900
+ }
591
901
  return new OpencodeAgentSession(this, session.id, options.agent, options.model);
592
902
  }
593
903
  async run(options) {
@@ -598,10 +908,38 @@ export class OpencodeAgentRuntime {
598
908
  const session = await this.createSession(sessionOptions);
599
909
  return session.run(options.prompt);
600
910
  }
911
+ async runAgent(options) {
912
+ return this.run(options);
913
+ }
914
+ async resumeAgent(options) {
915
+ const session = await this.openSession(options.sessionID, {
916
+ ...(options.agent ? { agent: options.agent } : {}),
917
+ ...(options.model ? { model: options.model } : {}),
918
+ });
919
+ if (options.prompt) {
920
+ await session.query(options.prompt, {
921
+ ...(options.agent ? { agent: options.agent } : {}),
922
+ ...(typeof options.includeSubagents === "boolean" ? { includeSubagents: options.includeSubagents } : {}),
923
+ ...(options.model ? { model: options.model } : {}),
924
+ });
925
+ }
926
+ return session;
927
+ }
601
928
  }
602
929
  export async function createAgentRuntime(options) {
930
+ const agents = options.agents ?? {};
931
+ if (options.serverUrl) {
932
+ if (typeof options.hostname === "string" || typeof options.port === "number" || typeof options.timeoutMs === "number") {
933
+ throw new Error("serverUrl cannot be combined with hostname, port, or timeoutMs");
934
+ }
935
+ const client = createOpencodeClient({
936
+ baseUrl: options.serverUrl,
937
+ directory: options.directory,
938
+ });
939
+ return new OpencodeAgentRuntime(client, options.directory, agents, undefined, options.model);
940
+ }
603
941
  const runtimeConfig = buildRuntimeConfig({
604
- agents: options.agents,
942
+ agents,
605
943
  ...(options.config ? { config: options.config } : {}),
606
944
  ...(options.mcp ? { mcp: options.mcp } : {}),
607
945
  ...(options.model ? { model: options.model } : {}),
@@ -614,5 +952,5 @@ export async function createAgentRuntime(options) {
614
952
  timeout: options.timeoutMs ?? 15000,
615
953
  config: runtimeConfig,
616
954
  });
617
- return new OpencodeAgentRuntime(client, options.directory, options.agents, server, options.model);
955
+ return new OpencodeAgentRuntime(client, options.directory, agents, server, options.model);
618
956
  }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Message, Part, ToolState } from "@opencode-ai/sdk/v2";
1
+ import type { Agent as OpencodeAgent, Message, Part, ToolState } from "@opencode-ai/sdk/v2";
2
2
  export type JsonPrimitive = boolean | null | number | string;
3
3
  export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
4
4
  export type JsonArray = JsonValue[];
@@ -10,14 +10,16 @@ export type ModelSpec = {
10
10
  providerID: string;
11
11
  };
12
12
  export type ModelReference = ModelSpec | `${string}/${string}`;
13
+ export type AgentMode = "all" | "primary" | "subagent";
13
14
  export type AgentDefinition = {
14
15
  description?: string;
15
16
  model?: ModelReference;
16
- mode?: string;
17
+ mode?: AgentMode;
18
+ permission?: JsonObject;
17
19
  prompt: string;
18
20
  };
19
21
  export type AgentRuntimeOptions = {
20
- agents: Record<string, AgentDefinition>;
22
+ agents?: Record<string, AgentDefinition>;
21
23
  config?: JsonObject;
22
24
  directory: string;
23
25
  hostname?: string;
@@ -26,19 +28,27 @@ export type AgentRuntimeOptions = {
26
28
  permission?: JsonObject;
27
29
  port?: number;
28
30
  rawConfigContent?: string;
31
+ serverUrl?: string;
29
32
  timeoutMs?: number;
30
33
  };
31
34
  export type AgentSessionOptions = {
32
35
  agent?: string;
33
36
  model?: ModelReference;
34
37
  };
35
- export type AgentQueryOptions = AgentSessionOptions;
36
- export type AgentRunOptions = AgentSessionOptions;
38
+ export type AgentQueryOptions = AgentSessionOptions & {
39
+ includeSubagents?: boolean;
40
+ };
41
+ export type AgentRunOptions = AgentQueryOptions;
37
42
  export type AgentRuntimeRunOptions = {
38
43
  agent: string;
39
44
  model?: ModelReference;
40
45
  prompt: string;
41
46
  };
47
+ export type ResumeAgentOptions = AgentSessionOptions & {
48
+ includeSubagents?: boolean;
49
+ prompt?: string;
50
+ sessionID: string;
51
+ };
42
52
  export type AgentErrorInfo = {
43
53
  code?: string;
44
54
  message: string;
@@ -46,10 +56,26 @@ export type AgentErrorInfo = {
46
56
  status?: number;
47
57
  statusCode?: number;
48
58
  };
59
+ export type AgentEventSource = {
60
+ agentLabel: string;
61
+ agentType: string;
62
+ chain: string[];
63
+ chainText: string;
64
+ depth: number;
65
+ parentSessionID: string | null;
66
+ rootSessionID: string;
67
+ sessionID: string;
68
+ sourceToolCallID: string | null;
69
+ taskID: string | null;
70
+ };
71
+ export type RuntimeAgentInfo = Pick<OpencodeAgent, "color" | "description" | "hidden" | "mode" | "name" | "native" | "steps"> & {
72
+ model?: ModelSpec;
73
+ };
49
74
  export type AgentStatusEvent = {
50
75
  agent: string;
51
76
  sessionID: string;
52
77
  status: string;
78
+ source: AgentEventSource;
53
79
  type: "status";
54
80
  };
55
81
  export type AgentTextEvent = {
@@ -58,6 +84,7 @@ export type AgentTextEvent = {
58
84
  messageID: string;
59
85
  partID: string;
60
86
  sessionID: string;
87
+ source: AgentEventSource;
61
88
  text: string;
62
89
  type: "text";
63
90
  };
@@ -71,6 +98,7 @@ export type AgentToolCallEvent = {
71
98
  output?: string;
72
99
  partID: string;
73
100
  sessionID: string;
101
+ source: AgentEventSource;
74
102
  status: ToolState["status"];
75
103
  title?: string;
76
104
  toolName: string;
@@ -80,6 +108,7 @@ export type AgentErrorEvent = {
80
108
  agent: string;
81
109
  error: AgentErrorInfo;
82
110
  sessionID: string;
111
+ source: AgentEventSource;
83
112
  type: "error";
84
113
  };
85
114
  export type AgentTurnResult = {
@@ -89,12 +118,14 @@ export type AgentTurnResult = {
89
118
  messageID: string | null;
90
119
  parts: Part[];
91
120
  sessionID: string;
121
+ source: AgentEventSource;
92
122
  text: string;
93
123
  };
94
124
  export type AgentResultEvent = {
95
125
  agent: string;
96
126
  result: AgentTurnResult;
97
127
  sessionID: string;
128
+ source: AgentEventSource;
98
129
  type: "result";
99
130
  };
100
131
  export type AgentResponseEvent = AgentErrorEvent | AgentResultEvent | AgentStatusEvent | AgentTextEvent | AgentToolCallEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liontree/opencode-agent-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "High-level agent runtime built on top of @opencode-ai/sdk",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",