@agentex/agent 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +58 -7
  3. package/dist/derived.d.ts.map +1 -1
  4. package/dist/derived.js +99 -2
  5. package/dist/derived.js.map +1 -1
  6. package/dist/goals/controller.d.ts.map +1 -1
  7. package/dist/goals/controller.js +21 -0
  8. package/dist/goals/controller.js.map +1 -1
  9. package/dist/goals/normalize.d.ts +1 -0
  10. package/dist/goals/normalize.d.ts.map +1 -1
  11. package/dist/goals/normalize.js +5 -0
  12. package/dist/goals/normalize.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/providers/claude/attach.d.ts.map +1 -1
  17. package/dist/providers/claude/attach.js +50 -9
  18. package/dist/providers/claude/attach.js.map +1 -1
  19. package/dist/providers/claude/index.d.ts.map +1 -1
  20. package/dist/providers/claude/index.js +8 -0
  21. package/dist/providers/claude/index.js.map +1 -1
  22. package/dist/providers/claude/session.d.ts +2 -0
  23. package/dist/providers/claude/session.d.ts.map +1 -1
  24. package/dist/providers/claude/session.js +17 -10
  25. package/dist/providers/claude/session.js.map +1 -1
  26. package/dist/providers/codex/attach.d.ts.map +1 -1
  27. package/dist/providers/codex/attach.js +40 -3
  28. package/dist/providers/codex/attach.js.map +1 -1
  29. package/dist/providers/codex/index.d.ts.map +1 -1
  30. package/dist/providers/codex/index.js +8 -0
  31. package/dist/providers/codex/index.js.map +1 -1
  32. package/dist/providers/codex/session.d.ts.map +1 -1
  33. package/dist/providers/codex/session.js +6 -0
  34. package/dist/providers/codex/session.js.map +1 -1
  35. package/dist/providers/codex/transcript.d.ts +3 -1
  36. package/dist/providers/codex/transcript.d.ts.map +1 -1
  37. package/dist/providers/codex/transcript.js +3 -1
  38. package/dist/providers/codex/transcript.js.map +1 -1
  39. package/dist/providers/cursor/discovery.d.ts +6 -0
  40. package/dist/providers/cursor/discovery.d.ts.map +1 -0
  41. package/dist/providers/cursor/discovery.js +118 -0
  42. package/dist/providers/cursor/discovery.js.map +1 -0
  43. package/dist/providers/cursor/execute.d.ts.map +1 -1
  44. package/dist/providers/cursor/execute.js +83 -45
  45. package/dist/providers/cursor/execute.js.map +1 -1
  46. package/dist/providers/cursor/index.d.ts.map +1 -1
  47. package/dist/providers/cursor/index.js +17 -4
  48. package/dist/providers/cursor/index.js.map +1 -1
  49. package/dist/providers/cursor/parse.js +1 -1
  50. package/dist/providers/cursor/parse.js.map +1 -1
  51. package/dist/providers/cursor/probe.d.ts +3 -0
  52. package/dist/providers/cursor/probe.d.ts.map +1 -0
  53. package/dist/providers/cursor/probe.js +89 -0
  54. package/dist/providers/cursor/probe.js.map +1 -0
  55. package/dist/providers/cursor/runtime.d.ts +4 -0
  56. package/dist/providers/cursor/runtime.d.ts.map +1 -0
  57. package/dist/providers/cursor/runtime.js +12 -0
  58. package/dist/providers/cursor/runtime.js.map +1 -0
  59. package/dist/providers/cursor/session.d.ts +3 -0
  60. package/dist/providers/cursor/session.d.ts.map +1 -0
  61. package/dist/providers/cursor/session.js +14 -0
  62. package/dist/providers/cursor/session.js.map +1 -0
  63. package/dist/providers/opencode/client.d.ts +14 -0
  64. package/dist/providers/opencode/client.d.ts.map +1 -0
  65. package/dist/providers/opencode/client.js +40 -0
  66. package/dist/providers/opencode/client.js.map +1 -0
  67. package/dist/providers/opencode/discovery.d.ts +4 -0
  68. package/dist/providers/opencode/discovery.d.ts.map +1 -0
  69. package/dist/providers/opencode/discovery.js +68 -0
  70. package/dist/providers/opencode/discovery.js.map +1 -0
  71. package/dist/providers/opencode/execute.d.ts.map +1 -1
  72. package/dist/providers/opencode/execute.js +219 -164
  73. package/dist/providers/opencode/execute.js.map +1 -1
  74. package/dist/providers/opencode/history.d.ts +24 -0
  75. package/dist/providers/opencode/history.d.ts.map +1 -0
  76. package/dist/providers/opencode/history.js +242 -0
  77. package/dist/providers/opencode/history.js.map +1 -0
  78. package/dist/providers/opencode/http-session.d.ts.map +1 -1
  79. package/dist/providers/opencode/http-session.js +333 -49
  80. package/dist/providers/opencode/http-session.js.map +1 -1
  81. package/dist/providers/opencode/index.d.ts.map +1 -1
  82. package/dist/providers/opencode/index.js +26 -3
  83. package/dist/providers/opencode/index.js.map +1 -1
  84. package/dist/providers/opencode/manager.d.ts +11 -0
  85. package/dist/providers/opencode/manager.d.ts.map +1 -0
  86. package/dist/providers/opencode/manager.js +240 -0
  87. package/dist/providers/opencode/manager.js.map +1 -0
  88. package/dist/providers/opencode/modes.d.ts +3 -0
  89. package/dist/providers/opencode/modes.d.ts.map +1 -0
  90. package/dist/providers/opencode/modes.js +30 -0
  91. package/dist/providers/opencode/modes.js.map +1 -0
  92. package/dist/providers/opencode/parse.d.ts.map +1 -1
  93. package/dist/providers/opencode/parse.js +2 -22
  94. package/dist/providers/opencode/parse.js.map +1 -1
  95. package/dist/providers/opencode/probe.d.ts +3 -0
  96. package/dist/providers/opencode/probe.d.ts.map +1 -0
  97. package/dist/providers/opencode/probe.js +107 -0
  98. package/dist/providers/opencode/probe.js.map +1 -0
  99. package/dist/providers/opencode/runtime.d.ts +8 -0
  100. package/dist/providers/opencode/runtime.d.ts.map +1 -0
  101. package/dist/providers/opencode/runtime.js +12 -0
  102. package/dist/providers/opencode/runtime.js.map +1 -0
  103. package/dist/providers/opencode/server.d.ts +18 -3
  104. package/dist/providers/opencode/server.d.ts.map +1 -1
  105. package/dist/providers/opencode/server.js +0 -0
  106. package/dist/providers/opencode/server.js.map +1 -1
  107. package/dist/providers/opencode/skill-config.d.ts +11 -0
  108. package/dist/providers/opencode/skill-config.d.ts.map +1 -0
  109. package/dist/providers/opencode/skill-config.js +57 -0
  110. package/dist/providers/opencode/skill-config.js.map +1 -0
  111. package/dist/sessions/exec-backed.d.ts +10 -0
  112. package/dist/sessions/exec-backed.d.ts.map +1 -0
  113. package/dist/sessions/exec-backed.js +141 -0
  114. package/dist/sessions/exec-backed.js.map +1 -0
  115. package/dist/sessions/history.d.ts +4 -0
  116. package/dist/sessions/history.d.ts.map +1 -0
  117. package/dist/sessions/history.js +59 -0
  118. package/dist/sessions/history.js.map +1 -0
  119. package/dist/sessions/index.d.ts +3 -0
  120. package/dist/sessions/index.d.ts.map +1 -1
  121. package/dist/sessions/index.js +2 -0
  122. package/dist/sessions/index.js.map +1 -1
  123. package/dist/types.d.ts +149 -5
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/types.js.map +1 -1
  126. package/dist/utils/auth.d.ts.map +1 -1
  127. package/dist/utils/auth.js +36 -6
  128. package/dist/utils/auth.js.map +1 -1
  129. package/dist/utils/binary.d.ts.map +1 -1
  130. package/dist/utils/binary.js +3 -0
  131. package/dist/utils/binary.js.map +1 -1
  132. package/dist/utils/endpoint.js +3 -3
  133. package/dist/utils/endpoint.js.map +1 -1
  134. package/package.json +4 -2
  135. package/src/derived.ts +126 -3
  136. package/src/goals/controller.ts +17 -0
  137. package/src/goals/normalize.ts +5 -0
  138. package/src/index.ts +13 -0
  139. package/src/providers/claude/attach.ts +48 -6
  140. package/src/providers/claude/index.ts +8 -0
  141. package/src/providers/claude/session.ts +15 -10
  142. package/src/providers/codex/attach.ts +48 -3
  143. package/src/providers/codex/index.ts +8 -0
  144. package/src/providers/codex/session.ts +5 -0
  145. package/src/providers/codex/transcript.ts +5 -1
  146. package/src/providers/cursor/discovery.ts +114 -0
  147. package/src/providers/cursor/execute.ts +67 -30
  148. package/src/providers/cursor/index.ts +19 -5
  149. package/src/providers/cursor/parse.ts +1 -1
  150. package/src/providers/cursor/probe.ts +90 -0
  151. package/src/providers/cursor/runtime.ts +11 -0
  152. package/src/providers/cursor/session.ts +15 -0
  153. package/src/providers/opencode/client.ts +44 -0
  154. package/src/providers/opencode/discovery.ts +69 -0
  155. package/src/providers/opencode/execute.ts +64 -12
  156. package/src/providers/opencode/history.ts +270 -0
  157. package/src/providers/opencode/http-session.ts +319 -50
  158. package/src/providers/opencode/index.ts +26 -3
  159. package/src/providers/opencode/manager.ts +269 -0
  160. package/src/providers/opencode/modes.ts +31 -0
  161. package/src/providers/opencode/parse.ts +2 -22
  162. package/src/providers/opencode/probe.ts +113 -0
  163. package/src/providers/opencode/runtime.ts +13 -0
  164. package/src/providers/opencode/server.ts +0 -0
  165. package/src/providers/opencode/skill-config.ts +64 -0
  166. package/src/sessions/exec-backed.ts +169 -0
  167. package/src/sessions/history.ts +76 -0
  168. package/src/sessions/index.ts +3 -0
  169. package/src/types.ts +160 -3
  170. package/src/utils/auth.ts +35 -6
  171. package/src/utils/binary.ts +3 -0
  172. package/src/utils/endpoint.ts +3 -3
@@ -12,13 +12,17 @@ import type {
12
12
  SetGoalResult,
13
13
  StreamEvent,
14
14
  TurnResult,
15
+ UserInputResponse,
15
16
  } from "../../types.js";
16
17
  import { GoalController, EMULATED_GOAL_CAPABILITY } from "../../goals/index.js";
17
18
  import { findBinary } from "../../utils/binary.js";
18
19
  import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
19
20
  import { uuidv7 } from "../../utils/uuid.js";
20
21
  import { acquireOpenCodeServer, type OpenCodeServerHandle } from "./server.js";
22
+ import type { OpenCodeClient } from "./client.js";
23
+ import { createSessionRecord } from "../../sessions/record.js";
21
24
  import { opencodeSessionCodec } from "./codec.js";
25
+ import { prepareOpenCodeSkillConfig } from "./skill-config.js";
22
26
  import {
23
27
  assistantTextFromParts,
24
28
  mapOpenCodePart,
@@ -63,7 +67,8 @@ class OpenCodeSession implements AgentSession {
63
67
  private _state: SessionState = "idle";
64
68
  private _sessionId: string | null = null;
65
69
  private server: OpenCodeServerHandle | null = null;
66
- private url = "";
70
+ private client: OpenCodeClient | null = null;
71
+ private cwd = "";
67
72
  private readonly model: { providerID: string; modelID: string } | null;
68
73
 
69
74
  private _turnActive = false;
@@ -79,6 +84,12 @@ class OpenCodeSession implements AgentSession {
79
84
  private readonly _emittedToolCall = new Set<string>();
80
85
  private readonly _emittedToolResult = new Set<string>();
81
86
  private _sse: AbortController | null = null;
87
+ private readonly _handledInput = new Set<string>();
88
+ private readonly _inputInFlight = new Set<string>();
89
+ private readonly _inputReplies = new Map<string, { path: string; body?: Record<string, unknown> }>();
90
+ private readonly _inputAbort = new AbortController();
91
+ private _pendingPoll: ReturnType<typeof setInterval> | null = null;
92
+ private _skillConfigCleanup: (() => Promise<void>) | null = null;
82
93
 
83
94
  /** Goal engine. OpenCode has no native goal surface — always emulated. */
84
95
  private readonly _goals: GoalController;
@@ -106,12 +117,22 @@ class OpenCodeSession implements AgentSession {
106
117
  async connect(): Promise<void> {
107
118
  const config = this.ctx.config ?? {};
108
119
  const resolved = await findBinary("opencode", config.command);
109
- const env = buildEnv(this.ctx.env);
110
- ensurePathInEnv(env);
120
+ const baseEnv = buildEnv(this.ctx.env);
121
+ ensurePathInEnv(baseEnv);
122
+ const skillConfig = await prepareOpenCodeSkillConfig(baseEnv, config.skillDirs);
123
+ const env = skillConfig.env;
124
+ this._skillConfigCleanup = skillConfig.cleanup;
111
125
  const cwd = this.ctx.cwd ?? process.cwd();
126
+ this.cwd = cwd;
112
127
 
113
- this.server = await acquireOpenCodeServer(resolved.bin, resolved.prefixArgs, env, cwd);
114
- this.url = this.server.url;
128
+ try {
129
+ this.server = await acquireOpenCodeServer(resolved.bin, resolved.prefixArgs, env, cwd);
130
+ } catch (error) {
131
+ await this._skillConfigCleanup();
132
+ this._skillConfigCleanup = null;
133
+ throw error;
134
+ }
135
+ this.client = this.server.client;
115
136
 
116
137
  // Everything after acquiring the server must release it on failure, or the
117
138
  // pooled `opencode serve` process leaks (refCount stuck at 1 forever).
@@ -120,7 +141,7 @@ class OpenCodeSession implements AgentSession {
120
141
  if (resumeId) {
121
142
  this._sessionId = resumeId;
122
143
  } else {
123
- const res = await fetch(`${this.url}/session`, {
144
+ const res = await this.client.request("/session", {
124
145
  method: "POST",
125
146
  headers: { "content-type": "application/json" },
126
147
  body: JSON.stringify({}),
@@ -132,7 +153,8 @@ class OpenCodeSession implements AgentSession {
132
153
 
133
154
  // Live event stream (global; filtered to our session).
134
155
  this._sse = new AbortController();
135
- void this.readSse(this._sse.signal);
156
+ await this.startSse(this._sse.signal);
157
+ await this.reconcilePendingInput();
136
158
  } catch (err) {
137
159
  if (this._sse) {
138
160
  try {
@@ -144,6 +166,8 @@ class OpenCodeSession implements AgentSession {
144
166
  }
145
167
  this.server.release();
146
168
  this.server = null;
169
+ await this._skillConfigCleanup?.();
170
+ this._skillConfigCleanup = null;
147
171
  this._state = "closed";
148
172
  throw err;
149
173
  }
@@ -158,9 +182,17 @@ class OpenCodeSession implements AgentSession {
158
182
  // SSE live stream
159
183
  // -------------------------------------------------------------------------
160
184
 
161
- private async readSse(signal: AbortSignal): Promise<void> {
185
+ private async startSse(signal: AbortSignal): Promise<void> {
186
+ if (!this.client) throw new Error("OpenCode client is unavailable");
187
+ const response = await this.client.request("/global/event", { signal });
188
+ if (!response.ok || !response.body) {
189
+ throw new Error(`opencode: failed to establish event stream (${response.status})`);
190
+ }
191
+ void this.readSse(response, signal);
192
+ }
193
+
194
+ private async readSse(res: Response, signal: AbortSignal): Promise<void> {
162
195
  try {
163
- const res = await fetch(`${this.url}/global/event`, { signal });
164
196
  if (!res.body) return;
165
197
  const reader = res.body.getReader();
166
198
  const decoder = new TextDecoder();
@@ -180,7 +212,17 @@ class OpenCodeSession implements AgentSession {
180
212
  }
181
213
  }
182
214
  } catch {
183
- // Stream closed/aborted — the POST /message response remains authoritative.
215
+ // Reconciliation polling remains authoritative while a turn is active.
216
+ } finally {
217
+ if (!signal.aborted && this._state !== "closed") {
218
+ await new Promise((resolve) => setTimeout(resolve, 250));
219
+ try {
220
+ await this.startSse(signal);
221
+ await this.reconcilePendingInput();
222
+ } catch {
223
+ // The active-turn polling path will keep pending input visible.
224
+ }
225
+ }
184
226
  }
185
227
  }
186
228
 
@@ -192,13 +234,171 @@ class OpenCodeSession implements AgentSession {
192
234
  return;
193
235
  }
194
236
  const payload = rec(rec(parsed)?.["payload"]);
195
- if (!payload || payload["type"] !== "message.part.updated") return;
237
+ if (!payload) return;
238
+ if (payload["type"] === "permission.asked" || payload["type"] === "permission.updated") {
239
+ const request = rec(rec(payload["properties"])?.["request"]) ?? rec(payload["properties"]);
240
+ if (request) await this.handlePermission(request);
241
+ return;
242
+ }
243
+ if (payload["type"] === "question.asked") {
244
+ const request = rec(rec(payload["properties"])?.["request"]) ?? rec(payload["properties"]);
245
+ if (request) await this.handleQuestion(request);
246
+ return;
247
+ }
248
+ if (payload["type"] !== "message.part.updated") return;
196
249
  const props = rec(payload["properties"]);
197
250
  const part = rec(props?.["part"]);
198
251
  if (!part || str(part["sessionID"]) !== this._sessionId) return;
199
252
  await this.emitPart(part);
200
253
  }
201
254
 
255
+ private async reconcilePendingInput(): Promise<void> {
256
+ if (!this.client || !this._sessionId || this._state === "closed") return;
257
+ const [permissions, questions] = await Promise.all([
258
+ this.client.json<unknown[]>("/permission").catch(() => []),
259
+ this.client.json<unknown[]>("/question").catch(() => []),
260
+ ]);
261
+ for (const value of permissions) {
262
+ const request = rec(value);
263
+ if (request && str(request["sessionID"]) === this._sessionId) await this.handlePermission(request);
264
+ }
265
+ for (const value of questions) {
266
+ const request = rec(value);
267
+ if (request && str(request["sessionID"]) === this._sessionId) await this.handleQuestion(request);
268
+ }
269
+ }
270
+
271
+ private async hostInput(
272
+ request: Parameters<NonNullable<SessionContext["onUserInputRequest"]>>[0],
273
+ ): Promise<UserInputResponse> {
274
+ const fallback: UserInputResponse = {
275
+ allow: (this.ctx.config?.unattendedPermissionPolicy ?? "allow") === "allow",
276
+ };
277
+ if (!this.ctx.onUserInputRequest) return fallback;
278
+ const timeoutSec = this.ctx.config?.inputRequestTimeoutSec ?? 300;
279
+ let timer: ReturnType<typeof setTimeout> | null = null;
280
+ let abortListener: (() => void) | null = null;
281
+ try {
282
+ const timeout = new Promise<typeof fallback>((resolve) => {
283
+ timer = setTimeout(() => resolve(fallback), Math.max(0, timeoutSec) * 1000);
284
+ });
285
+ const closed = new Promise<typeof fallback>((resolve) => {
286
+ abortListener = () => resolve(fallback);
287
+ this._inputAbort.signal.addEventListener("abort", abortListener, { once: true });
288
+ });
289
+ return await Promise.race([
290
+ Promise.resolve(this.ctx.onUserInputRequest(request)).catch(() => fallback),
291
+ timeout,
292
+ closed,
293
+ ]);
294
+ } finally {
295
+ if (timer) clearTimeout(timer);
296
+ if (abortListener) this._inputAbort.signal.removeEventListener("abort", abortListener);
297
+ }
298
+ }
299
+
300
+ private restoreAfterInput(): void {
301
+ if (this._state !== "closed") this._state = this._turnActive ? "thinking" : "idle";
302
+ }
303
+
304
+ private async handlePermission(request: Record<string, unknown>): Promise<void> {
305
+ const id = str(request["id"]);
306
+ if (!id || this._handledInput.has(id) || this._inputInFlight.has(id)
307
+ || str(request["sessionID"]) !== this._sessionId || !this.client) return;
308
+ this._inputInFlight.add(id);
309
+ try {
310
+ let reply = this._inputReplies.get(id);
311
+ if (!reply) {
312
+ this._state = "waiting_for_approval";
313
+ const permission = str(request["permission"]) ?? "Tool";
314
+ const response = await this.hostInput({
315
+ toolName: permission,
316
+ input: rec(request["metadata"]) ?? {},
317
+ toolUseId: id,
318
+ title: permission,
319
+ description: Array.isArray(request["patterns"])
320
+ ? request["patterns"].filter((item): item is string => typeof item === "string").join(", ")
321
+ : undefined,
322
+ });
323
+ reply = {
324
+ path: `/permission/${encodeURIComponent(id)}/reply`,
325
+ body: {
326
+ reply: response.allow ? "once" : "reject",
327
+ ...(response.message ? { message: response.message } : {}),
328
+ },
329
+ };
330
+ this._inputReplies.set(id, reply);
331
+ }
332
+ await this.client.ok(reply.path, {
333
+ method: "POST",
334
+ body: JSON.stringify(reply.body ?? {}),
335
+ });
336
+ this._handledInput.add(id);
337
+ this._inputReplies.delete(id);
338
+ } catch {
339
+ // Keep the cached host decision so reconciliation retries only the reply.
340
+ } finally {
341
+ this._inputInFlight.delete(id);
342
+ this.restoreAfterInput();
343
+ }
344
+ }
345
+
346
+ private async handleQuestion(request: Record<string, unknown>): Promise<void> {
347
+ const id = str(request["id"]);
348
+ if (!id || this._handledInput.has(id) || this._inputInFlight.has(id)
349
+ || str(request["sessionID"]) !== this._sessionId || !this.client) return;
350
+ this._inputInFlight.add(id);
351
+ try {
352
+ let reply = this._inputReplies.get(id);
353
+ if (!reply) {
354
+ this._state = "waiting_for_input";
355
+ const questions = (Array.isArray(request["questions"]) ? request["questions"] : []).map((value) => {
356
+ const question = rec(value) ?? {};
357
+ return {
358
+ question: str(question["question"]) ?? str(question["header"]) ?? "Question",
359
+ header: str(question["header"]) ?? "Question",
360
+ options: (Array.isArray(question["options"]) ? question["options"] : []).map((raw) => {
361
+ const option = rec(raw) ?? {};
362
+ return {
363
+ label: str(option["label"]) ?? "",
364
+ description: str(option["description"]) ?? "",
365
+ };
366
+ }).filter((option) => option.label),
367
+ ...(question["multiple"] === true ? { multiSelect: true } : {}),
368
+ };
369
+ });
370
+ const response = await this.hostInput({
371
+ toolName: "AskUserQuestion",
372
+ input: { questions },
373
+ toolUseId: id,
374
+ });
375
+ if (!response.allow) {
376
+ reply = { path: `/question/${encodeURIComponent(id)}/reject` };
377
+ } else {
378
+ const answerMap = rec(response.updatedInput?.["answers"]) ?? {};
379
+ const answers = questions.map((question) => {
380
+ const raw = answerMap[question.question] ?? answerMap[question.header];
381
+ if (Array.isArray(raw)) return raw.filter((item): item is string => typeof item === "string");
382
+ return typeof raw === "string" ? [raw] : [];
383
+ });
384
+ reply = { path: `/question/${encodeURIComponent(id)}/reply`, body: { answers } };
385
+ }
386
+ this._inputReplies.set(id, reply);
387
+ }
388
+ await this.client.ok(reply.path, {
389
+ method: "POST",
390
+ ...(reply.body ? { body: JSON.stringify(reply.body) } : {}),
391
+ });
392
+ this._handledInput.add(id);
393
+ this._inputReplies.delete(id);
394
+ } catch {
395
+ // Keep the cached host decision so reconciliation retries only the reply.
396
+ } finally {
397
+ this._inputInFlight.delete(id);
398
+ this.restoreAfterInput();
399
+ }
400
+ }
401
+
202
402
  private async emitPart(part: Record<string, unknown>): Promise<void> {
203
403
  // The SSE feed is global and async: drop stragglers that arrive while no turn
204
404
  // is active so a finished turn's late parts don't surface against the next one.
@@ -233,7 +433,7 @@ class OpenCodeSession implements AgentSession {
233
433
  }
234
434
  }
235
435
 
236
- private async emit(ev: ReturnType<typeof mapOpenCodePart>): Promise<void> {
436
+ private async emit(ev: StreamEvent | null): Promise<void> {
237
437
  if (ev && this.ctx.onEvent) {
238
438
  try {
239
439
  await this.ctx.onEvent(ev);
@@ -271,15 +471,18 @@ class OpenCodeSession implements AgentSession {
271
471
  }
272
472
 
273
473
  private async runTurn(message: string, options?: SendOptions): Promise<TurnResult> {
474
+ const startedAt = Date.now();
274
475
  if (!this._sessionId) {
275
- this.finishTurn();
276
- return {
476
+ const outcome: TurnResult = {
277
477
  summary: null,
278
478
  costUsd: null,
279
479
  status: "failed",
280
480
  errorCode: "not_initialized",
281
481
  errorMessage: "OpenCode session is not initialized",
282
482
  };
483
+ await this.emitTerminal(outcome, {}, startedAt);
484
+ this.finishTurn();
485
+ return outcome;
283
486
  }
284
487
 
285
488
  // Fresh per-turn live-stream dedup so a previous turn's part ids can't
@@ -298,63 +501,113 @@ class OpenCodeSession implements AgentSession {
298
501
 
299
502
  const body: Record<string, unknown> = { parts: [{ type: "text", text: message }] };
300
503
  if (this.model) body["model"] = this.model;
504
+ if (this.ctx.config?.modelVariant) body["variant"] = this.ctx.config.modelVariant;
505
+ const agent = this.ctx.config?.planMode ? "plan" : (this.ctx.config?.modeId ?? this.ctx.config?.mode);
506
+ if (agent) body["agent"] = agent;
301
507
 
508
+ let outcome: TurnResult;
509
+ let terminalRaw: Record<string, unknown> = {};
302
510
  try {
303
- const res = await fetch(`${this.url}/session/${this._sessionId}/message`, {
304
- method: "POST",
305
- headers: { "content-type": "application/json" },
306
- body: JSON.stringify(body),
307
- signal: ac.signal,
308
- });
309
- if (!res.ok) {
310
- const text = await res.text().catch(() => "");
311
- return {
511
+ if (!this.server?.isCurrent()) {
512
+ outcome = {
312
513
  summary: null,
313
514
  costUsd: null,
314
515
  status: "failed",
315
- errorCode: "http_error",
316
- errorMessage: `opencode message failed: ${res.status} ${text}`.trim(),
516
+ errorCode: "runtime_reconfigured",
517
+ errorMessage: "OpenCode runtime configuration changed. Start a fresh session.",
317
518
  };
519
+ } else {
520
+ this._pendingPoll = setInterval(() => void this.reconcilePendingInput(), 750);
521
+ const res = await this.client!.request(`/session/${this._sessionId}/message`, {
522
+ method: "POST",
523
+ headers: { "content-type": "application/json" },
524
+ body: JSON.stringify(body),
525
+ signal: ac.signal,
526
+ });
527
+ if (!res.ok) {
528
+ outcome = {
529
+ summary: null,
530
+ costUsd: null,
531
+ status: "failed",
532
+ errorCode: "http_error",
533
+ errorMessage: `OpenCode message request failed (${res.status})`,
534
+ };
535
+ terminalRaw = { status: res.status };
536
+ } else {
537
+ const data = (await res.json()) as Record<string, unknown>;
538
+ terminalRaw = data;
539
+ const info = rec(data["info"]);
540
+ const status = turnStatusFromMessage(info);
541
+ const usage = usageFromMessage(info);
542
+ const summary = assistantTextFromParts(data["parts"]) || null;
543
+ const costUsd = info ? num(info["cost"]) : null;
544
+ const errorMessage = status === "failed" ? JSON.stringify(info?.["error"] ?? null) : null;
545
+ outcome = {
546
+ summary,
547
+ ...(usage ? { usage } : {}),
548
+ costUsd,
549
+ status,
550
+ errorCode: status === "failed" ? "agent_error" : null,
551
+ errorMessage,
552
+ };
553
+ }
318
554
  }
319
- const data = (await res.json()) as Record<string, unknown>;
320
- const info = rec(data["info"]);
321
- const status = turnStatusFromMessage(info);
322
- const usage = usageFromMessage(info);
323
- return {
324
- summary: assistantTextFromParts(data["parts"]) || null,
325
- ...(usage ? { usage } : {}),
326
- costUsd: info ? num(info["cost"]) : null,
327
- status,
328
- errorCode: status === "failed" ? "agent_error" : null,
329
- errorMessage: status === "failed" ? JSON.stringify(info?.["error"] ?? null) : null,
330
- };
331
555
  } catch (err) {
332
556
  if (ac.signal.aborted) {
333
- // Don't POST /abort if we're tearing the whole session down (close()
334
- // already killed the server); only interrupt on timeout/caller-abort.
335
- const reason = ac.signal.reason === "timeout" ? "timeout" : ac.signal.reason === "closed" ? "aborted" : "aborted";
557
+ const reason = ac.signal.reason === "timeout" ? "timeout" : "aborted";
336
558
  if (ac.signal.reason !== "closed") await this.interrupt().catch(() => {});
337
- return {
559
+ outcome = {
338
560
  summary: null,
339
561
  costUsd: null,
340
562
  status: reason,
341
563
  errorCode: reason,
342
564
  errorMessage: reason === "timeout" ? "Turn exceeded its timeout" : "Turn aborted",
343
565
  };
566
+ } else {
567
+ outcome = {
568
+ summary: null,
569
+ costUsd: null,
570
+ status: "failed",
571
+ errorCode: "request_error",
572
+ errorMessage: err instanceof Error ? err.message : String(err),
573
+ };
344
574
  }
345
- return {
346
- summary: null,
347
- costUsd: null,
348
- status: "failed",
349
- errorCode: "request_error",
350
- errorMessage: err instanceof Error ? err.message : String(err),
351
- };
352
575
  } finally {
576
+ if (this._pendingPoll) clearInterval(this._pendingPoll);
577
+ this._pendingPoll = null;
353
578
  if (timer) clearTimeout(timer);
354
579
  options?.signal?.removeEventListener("abort", onAbort);
355
580
  if (this._activeController === ac) this._activeController = null;
356
- this.finishTurn();
357
581
  }
582
+ await this.emitTerminal(outcome, terminalRaw, startedAt);
583
+ this.finishTurn();
584
+ return outcome;
585
+ }
586
+
587
+ private async emitTerminal(
588
+ outcome: TurnResult,
589
+ raw: Record<string, unknown>,
590
+ startedAt: number,
591
+ ): Promise<void> {
592
+ const info = rec(raw["info"]);
593
+ await this.emit({
594
+ type: "result",
595
+ text: outcome.summary ?? outcome.errorMessage ?? "",
596
+ costUsd: outcome.costUsd,
597
+ isError: outcome.status !== "completed",
598
+ stopReason: null,
599
+ terminalReason: outcome.status,
600
+ numTurns: 1,
601
+ durationMs: Date.now() - startedAt,
602
+ timestamp: new Date().toISOString(),
603
+ providerType: "opencode",
604
+ sessionId: this._sessionId,
605
+ messageId: info ? str(info["id"]) : null,
606
+ eventId: info ? str(info["id"]) : null,
607
+ turnId: null,
608
+ parentToolCallId: null,
609
+ raw,
610
+ });
358
611
  }
359
612
 
360
613
  async cancel(_uuid: string): Promise<CancelResult> {
@@ -381,7 +634,7 @@ class OpenCodeSession implements AgentSession {
381
634
  async interrupt(): Promise<void> {
382
635
  if (!this._sessionId) return;
383
636
  try {
384
- await fetch(`${this.url}/session/${this._sessionId}/abort`, { method: "POST" });
637
+ await this.client?.ok(`/session/${this._sessionId}/abort`, { method: "POST" });
385
638
  } catch {
386
639
  /* best effort */
387
640
  }
@@ -401,6 +654,7 @@ class OpenCodeSession implements AgentSession {
401
654
 
402
655
  async close(): Promise<void> {
403
656
  this._state = "closed";
657
+ this._inputAbort.abort("closed");
404
658
  // Abort an in-flight POST /message so close() doesn't leave it racing a
405
659
  // killed server (and so the turn settles as aborted, not a confusing error).
406
660
  if (this._activeController) {
@@ -419,9 +673,24 @@ class OpenCodeSession implements AgentSession {
419
673
  }
420
674
  this._sse = null;
421
675
  }
676
+ if (this._pendingPoll) clearInterval(this._pendingPoll);
677
+ this._pendingPoll = null;
422
678
  if (this.server) {
423
679
  this.server.release();
424
680
  this.server = null;
425
681
  }
682
+ await this._skillConfigCleanup?.();
683
+ this._skillConfigCleanup = null;
684
+ this.client = null;
685
+ }
686
+
687
+ describeHistory() {
688
+ if (!this._sessionId) return null;
689
+ return createSessionRecord({
690
+ providerType: "opencode",
691
+ params: { sessionId: this._sessionId, cwd: this.cwd },
692
+ cwd: this.cwd,
693
+ displayId: this._sessionId,
694
+ });
426
695
  }
427
696
  }
@@ -7,18 +7,28 @@ export const opencodeProvider: ProviderModule = {
7
7
  type: "opencode",
8
8
  capabilities: {
9
9
  sessions: true,
10
- modelDiscovery: false,
10
+ modelDiscovery: true,
11
11
  quotaProbing: false,
12
12
  mcp: false,
13
13
  skills: true,
14
14
  instructions: true,
15
15
  workspace: true,
16
- planMode: false,
16
+ planMode: true,
17
17
  concurrentSend: false,
18
18
  cancelQueuedMessage: false,
19
19
  stopTask: false,
20
- modes: false,
20
+ modes: true,
21
21
  goals: EMULATED_GOAL_CAPABILITY,
22
+ durableHistory: true,
23
+ resume: true,
24
+ modelVariants: true,
25
+ permissionRequests: true,
26
+ questionRequests: true,
27
+ upstreamProviderDisconnect: true,
28
+ sessionModelChange: true,
29
+ sessionVariantChange: true,
30
+ sessionEffortChange: false,
31
+ sessionModeChange: true,
22
32
  },
23
33
  // One-shot `opencode run` for execute(); live HTTP/SSE sessions via the
24
34
  // `opencode serve` daemon for createSession(). Both load lazily on first use.
@@ -27,4 +37,17 @@ export const opencodeProvider: ProviderModule = {
27
37
  (await import("./http-session.js")).createOpenCodeSession(ctx),
28
38
  resolveAuth: (ctx) => resolveAuthForProvider("opencode", ctx),
29
39
  sessionCodec: opencodeSessionCodec,
40
+ listModels: (options) => import("./discovery.js").then((m) => m.listOpenCodeModels(options)),
41
+ listModes: (options) => import("./modes.js").then((m) => m.listOpenCodeModes(options)),
42
+ probeCapabilities: (ctx) => import("./probe.js").then((m) => m.probeOpenCodeCapabilities(ctx)),
43
+ upstreamProviders: {
44
+ list: (ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.list(ctx)),
45
+ authMethods: (providerId, ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.authMethods(providerId, ctx)),
46
+ setApiKey: (providerId, key, ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.setApiKey(providerId, key, ctx)),
47
+ beginOAuth: (providerId, methodId, inputs, ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.beginOAuth(providerId, methodId, inputs, ctx)),
48
+ completeOAuth: (flowId, code, ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.completeOAuth(flowId, code, ctx)),
49
+ canDisconnect: (providerId, ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.canDisconnect(providerId, ctx)),
50
+ disconnect: (providerId, ctx) => import("./manager.js").then((m) => m.openCodeUpstreamProviders.disconnect(providerId, ctx)),
51
+ },
52
+ attachHistory: (record, options) => import("./history.js").then((m) => m.attachOpenCodeHistory(record, options)),
30
53
  };