@agentex/agent 0.0.23 → 0.0.26

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 (177) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/dist/derived.d.ts +5 -3
  5. package/dist/derived.d.ts.map +1 -1
  6. package/dist/derived.js +11 -7
  7. package/dist/derived.js.map +1 -1
  8. package/dist/index.d.ts +7 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/providers/acp/index.d.ts +1 -1
  13. package/dist/providers/acp/index.d.ts.map +1 -1
  14. package/dist/providers/acp/index.js +5 -97
  15. package/dist/providers/acp/index.js.map +1 -1
  16. package/dist/providers/acp/session.d.ts +8 -1
  17. package/dist/providers/acp/session.d.ts.map +1 -1
  18. package/dist/providers/acp/session.js +94 -0
  19. package/dist/providers/acp/session.js.map +1 -1
  20. package/dist/providers/claude/attach.d.ts +8 -0
  21. package/dist/providers/claude/attach.d.ts.map +1 -0
  22. package/dist/providers/claude/attach.js +113 -0
  23. package/dist/providers/claude/attach.js.map +1 -0
  24. package/dist/providers/claude/execute.d.ts.map +1 -1
  25. package/dist/providers/claude/execute.js +17 -2
  26. package/dist/providers/claude/execute.js.map +1 -1
  27. package/dist/providers/claude/goal-capability.d.ts +15 -0
  28. package/dist/providers/claude/goal-capability.d.ts.map +1 -0
  29. package/dist/providers/claude/goal-capability.js +20 -0
  30. package/dist/providers/claude/goal-capability.js.map +1 -0
  31. package/dist/providers/claude/index.d.ts.map +1 -1
  32. package/dist/providers/claude/index.js +8 -4
  33. package/dist/providers/claude/index.js.map +1 -1
  34. package/dist/providers/claude/session.d.ts +11 -9
  35. package/dist/providers/claude/session.d.ts.map +1 -1
  36. package/dist/providers/claude/session.js +36 -14
  37. package/dist/providers/claude/session.js.map +1 -1
  38. package/dist/providers/codex/attach.d.ts +9 -0
  39. package/dist/providers/codex/attach.d.ts.map +1 -0
  40. package/dist/providers/codex/attach.js +93 -0
  41. package/dist/providers/codex/attach.js.map +1 -0
  42. package/dist/providers/codex/execute.d.ts.map +1 -1
  43. package/dist/providers/codex/execute.js +17 -3
  44. package/dist/providers/codex/execute.js.map +1 -1
  45. package/dist/providers/codex/goal-capability.d.ts +13 -0
  46. package/dist/providers/codex/goal-capability.d.ts.map +1 -0
  47. package/dist/providers/codex/goal-capability.js +18 -0
  48. package/dist/providers/codex/goal-capability.js.map +1 -0
  49. package/dist/providers/codex/index.d.ts +1 -0
  50. package/dist/providers/codex/index.d.ts.map +1 -1
  51. package/dist/providers/codex/index.js +9 -6
  52. package/dist/providers/codex/index.js.map +1 -1
  53. package/dist/providers/codex/session.d.ts +11 -7
  54. package/dist/providers/codex/session.d.ts.map +1 -1
  55. package/dist/providers/codex/session.js +37 -12
  56. package/dist/providers/codex/session.js.map +1 -1
  57. package/dist/providers/codex/transcript-normalize.d.ts +28 -0
  58. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
  59. package/dist/providers/codex/transcript-normalize.js +191 -0
  60. package/dist/providers/codex/transcript-normalize.js.map +1 -0
  61. package/dist/providers/cursor/index.d.ts.map +1 -1
  62. package/dist/providers/cursor/index.js +2 -2
  63. package/dist/providers/cursor/index.js.map +1 -1
  64. package/dist/providers/openclaw/index.d.ts.map +1 -1
  65. package/dist/providers/openclaw/index.js +2 -2
  66. package/dist/providers/openclaw/index.js.map +1 -1
  67. package/dist/providers/opencode/index.d.ts.map +1 -1
  68. package/dist/providers/opencode/index.js +3 -5
  69. package/dist/providers/opencode/index.js.map +1 -1
  70. package/dist/providers/pi/index.d.ts.map +1 -1
  71. package/dist/providers/pi/index.js +3 -5
  72. package/dist/providers/pi/index.js.map +1 -1
  73. package/dist/providers/process/index.d.ts.map +1 -1
  74. package/dist/providers/process/index.js +2 -2
  75. package/dist/providers/process/index.js.map +1 -1
  76. package/dist/registry.d.ts +0 -1
  77. package/dist/registry.d.ts.map +1 -1
  78. package/dist/registry.js +0 -4
  79. package/dist/registry.js.map +1 -1
  80. package/dist/sessions/index.d.ts +3 -0
  81. package/dist/sessions/index.d.ts.map +1 -0
  82. package/dist/sessions/index.js +2 -0
  83. package/dist/sessions/index.js.map +1 -0
  84. package/dist/sessions/record.d.ts +43 -0
  85. package/dist/sessions/record.d.ts.map +1 -0
  86. package/dist/sessions/record.js +85 -0
  87. package/dist/sessions/record.js.map +1 -0
  88. package/dist/types.d.ts +176 -0
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/dist/utils/endpoint.d.ts +38 -0
  92. package/dist/utils/endpoint.d.ts.map +1 -0
  93. package/dist/utils/endpoint.js +151 -0
  94. package/dist/utils/endpoint.js.map +1 -0
  95. package/dist/utils/env.d.ts.map +1 -1
  96. package/dist/utils/env.js +5 -1
  97. package/dist/utils/env.js.map +1 -1
  98. package/dist/utils/uuid.d.ts +7 -1
  99. package/dist/utils/uuid.d.ts.map +1 -1
  100. package/dist/utils/uuid.js +21 -1
  101. package/dist/utils/uuid.js.map +1 -1
  102. package/package.json +64 -7
  103. package/src/derived.ts +311 -0
  104. package/src/goals/controller.ts +442 -0
  105. package/src/goals/index.ts +21 -0
  106. package/src/goals/normalize.ts +173 -0
  107. package/src/goals/sentinel.ts +90 -0
  108. package/src/index.ts +270 -0
  109. package/src/providers/_shared/http-agent.ts +304 -0
  110. package/src/providers/acp/index.ts +103 -0
  111. package/src/providers/acp/parse.ts +131 -0
  112. package/src/providers/acp/session.ts +744 -0
  113. package/src/providers/claude/attach.ts +147 -0
  114. package/src/providers/claude/codec.ts +43 -0
  115. package/src/providers/claude/execute.ts +300 -0
  116. package/src/providers/claude/goal-capability.ts +21 -0
  117. package/src/providers/claude/index.ts +72 -0
  118. package/src/providers/claude/mcp.ts +82 -0
  119. package/src/providers/claude/parse.ts +824 -0
  120. package/src/providers/claude/session.ts +1192 -0
  121. package/src/providers/claude/transcript.ts +555 -0
  122. package/src/providers/codex/attach.ts +123 -0
  123. package/src/providers/codex/codec.ts +50 -0
  124. package/src/providers/codex/execute.ts +337 -0
  125. package/src/providers/codex/goal-capability.ts +19 -0
  126. package/src/providers/codex/index.ts +57 -0
  127. package/src/providers/codex/modes.ts +159 -0
  128. package/src/providers/codex/parse.ts +691 -0
  129. package/src/providers/codex/plan-mode.ts +49 -0
  130. package/src/providers/codex/session.ts +1287 -0
  131. package/src/providers/codex/transcript-normalize.ts +197 -0
  132. package/src/providers/codex/transcript.ts +487 -0
  133. package/src/providers/codex/usage-scanner.ts +178 -0
  134. package/src/providers/copilot/index.ts +19 -0
  135. package/src/providers/cursor/codec.ts +44 -0
  136. package/src/providers/cursor/execute.ts +271 -0
  137. package/src/providers/cursor/index.ts +25 -0
  138. package/src/providers/cursor/parse.ts +288 -0
  139. package/src/providers/gemini/index.ts +21 -0
  140. package/src/providers/openclaw/codec.ts +40 -0
  141. package/src/providers/openclaw/execute.ts +19 -0
  142. package/src/providers/openclaw/index.ts +29 -0
  143. package/src/providers/opencode/codec.ts +50 -0
  144. package/src/providers/opencode/event-parse.ts +141 -0
  145. package/src/providers/opencode/execute.ts +251 -0
  146. package/src/providers/opencode/http-session.ts +427 -0
  147. package/src/providers/opencode/index.ts +30 -0
  148. package/src/providers/opencode/parse.ts +203 -0
  149. package/src/providers/opencode/server.ts +0 -0
  150. package/src/providers/pi/codec.ts +44 -0
  151. package/src/providers/pi/execute.ts +297 -0
  152. package/src/providers/pi/index.ts +30 -0
  153. package/src/providers/pi/parse.ts +231 -0
  154. package/src/providers/pi/session.ts +381 -0
  155. package/src/providers/process/execute.ts +148 -0
  156. package/src/providers/process/index.ts +52 -0
  157. package/src/registry.ts +40 -0
  158. package/src/sessions/index.ts +8 -0
  159. package/src/sessions/record.ts +108 -0
  160. package/src/types.ts +1638 -0
  161. package/src/utils/ask-user-question.ts +57 -0
  162. package/src/utils/auth.ts +661 -0
  163. package/src/utils/binary.ts +179 -0
  164. package/src/utils/endpoint.ts +172 -0
  165. package/src/utils/env.ts +63 -0
  166. package/src/utils/execute-all.ts +68 -0
  167. package/src/utils/exit-plan-mode.ts +40 -0
  168. package/src/utils/instructions.ts +427 -0
  169. package/src/utils/process.ts +223 -0
  170. package/src/utils/runtime-config.ts +100 -0
  171. package/src/utils/runtime-homes.ts +49 -0
  172. package/src/utils/skill-commands.ts +493 -0
  173. package/src/utils/skills.ts +500 -0
  174. package/src/utils/template.ts +16 -0
  175. package/src/utils/tool-names.ts +51 -0
  176. package/src/utils/uuid.ts +21 -0
  177. package/src/utils/workspace.ts +156 -0
@@ -0,0 +1,251 @@
1
+ import * as path from "node:path";
2
+ import type { ExecutionContext, ExecutionResult } from "../../types.js";
3
+ import { findBinary } from "../../utils/binary.js";
4
+ import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
5
+ import { runChildProcess, deriveErrorCode } from "../../utils/process.js";
6
+ import { injectHomeSkills } from "../../utils/skills.js";
7
+ import { resolveInstructions } from "../../utils/instructions.js";
8
+ import { prepareWorkspace } from "../../utils/workspace.js";
9
+ import type { PreparedWorkspace } from "../../utils/workspace.js";
10
+ import { uuidv7 } from "../../utils/uuid.js";
11
+ import {
12
+ parseOpenCodeJsonl,
13
+ parseOpenCodeStreamLine,
14
+ isOpenCodeUnknownSessionError,
15
+ isOpenCodeAuthRequired,
16
+ } from "./parse.js";
17
+
18
+ export async function executeOpenCodeProvider(ctx: ExecutionContext): Promise<ExecutionResult> {
19
+ const runId = ctx.runId ?? uuidv7();
20
+ let cwd = ctx.cwd ?? process.cwd();
21
+ const model = ctx.model ?? ctx.config?.model ?? "";
22
+ const config = ctx.config ?? {};
23
+ const startedAt = new Date().toISOString();
24
+
25
+ // 1. Resolve binary
26
+ ctx.onLifecycle?.({ phase: "preparing", step: "binary" });
27
+ let resolvedBinary;
28
+ try {
29
+ resolvedBinary = await findBinary("opencode", config.command);
30
+ } catch (err) {
31
+ const errorMessage = err instanceof Error ? err.message : "Binary not found";
32
+ ctx.onLifecycle?.({ phase: "error", message: errorMessage });
33
+ return {
34
+ runId,
35
+ exitCode: null,
36
+ signal: null,
37
+ status: "failed" as const,
38
+ startedAt,
39
+ completedAt: new Date().toISOString(),
40
+ durationMs: Date.now() - new Date(startedAt).getTime(),
41
+ errorMessage,
42
+ errorCode: "binary_not_found",
43
+ costUsd: null,
44
+ model: null,
45
+ summary: null,
46
+ sessionParams: null,
47
+ sessionDisplayId: null,
48
+ clearSession: false,
49
+ billingType: null,
50
+ };
51
+ }
52
+
53
+ // 2. Workspace isolation
54
+ let workspace: PreparedWorkspace | undefined;
55
+ if (config.workspace) {
56
+ ctx.onLifecycle?.({ phase: "preparing", step: "workspace" });
57
+ workspace = await prepareWorkspace(cwd, config.workspace);
58
+ cwd = workspace.cwd;
59
+ }
60
+
61
+ // 3. Build env
62
+ const env = buildEnv(ctx.env);
63
+ ensurePathInEnv(env);
64
+
65
+ // 3. Resolve instructions
66
+ ctx.onLifecycle?.({ phase: "preparing", step: "instructions" });
67
+ const instructions = await resolveInstructions(config.instructionsFile);
68
+ const fullPrompt = instructions ? `${instructions}\n\n${ctx.prompt}` : ctx.prompt;
69
+
70
+ // 4. Inject skills into ~/.claude/skills/ (OpenCode shares Claude's skills dir)
71
+ ctx.onLifecycle?.({ phase: "preparing", step: "skills" });
72
+ if (config.skillDirs && config.skillDirs.length > 0) {
73
+ try {
74
+ await injectHomeSkills(config.skillDirs, "opencode");
75
+ } catch {
76
+ // Non-fatal
77
+ }
78
+ }
79
+
80
+ // 4. Determine session resume
81
+ const sessionParams = ctx.sessionParams ?? null;
82
+ const sessionId = (() => {
83
+ if (!sessionParams) return null;
84
+ const id =
85
+ (sessionParams["sessionId"] as string | undefined) ??
86
+ (sessionParams["session_id"] as string | undefined) ??
87
+ (sessionParams["sessionID"] as string | undefined);
88
+ if (!id || typeof id !== "string") return null;
89
+ const sessionCwd = sessionParams["cwd"] as string | undefined;
90
+ if (sessionCwd && path.resolve(sessionCwd) !== path.resolve(cwd)) return null;
91
+ return id;
92
+ })();
93
+
94
+ // 5. Build args
95
+ const buildArgs = (resumeSessionId: string | null): string[] => {
96
+ const args = [...resolvedBinary.prefixArgs, "run", "--format", "json"];
97
+ if (resumeSessionId) args.push("--session", resumeSessionId);
98
+ if (model) args.push("--model", model);
99
+ if (config.extraArgs) args.push(...config.extraArgs);
100
+ return args;
101
+ };
102
+
103
+ // 6. Run attempt
104
+ const runAttempt = async (resumeSessionId: string | null) => {
105
+ const args = buildArgs(resumeSessionId);
106
+ let lineBuffer = "";
107
+
108
+ ctx.onLifecycle?.({ phase: "spawning" });
109
+ const proc = await runChildProcess({
110
+ runId,
111
+ command: resolvedBinary.bin,
112
+ args,
113
+ cwd,
114
+ env,
115
+ stdin: fullPrompt,
116
+ timeoutSec: config.timeoutSec,
117
+ graceSec: config.graceSec,
118
+ onStart: (pid) => {
119
+ ctx.onLifecycle?.({ phase: "running", pid });
120
+ ctx.onStart?.(pid);
121
+ },
122
+ signal: ctx.signal,
123
+ onOutput: async (stream, chunk) => {
124
+ if (ctx.onOutput) {
125
+ try { await ctx.onOutput(stream, chunk); } catch { /* swallow */ }
126
+ }
127
+
128
+ if (stream === "stdout" && ctx.onEvent) {
129
+ lineBuffer += chunk;
130
+ const lines = lineBuffer.split("\n");
131
+ lineBuffer = lines.pop() ?? "";
132
+ for (const line of lines) {
133
+ const trimmed = line.trim();
134
+ if (!trimmed) continue;
135
+ const event = parseOpenCodeStreamLine(trimmed);
136
+ if (event) {
137
+ try { await ctx.onEvent(event); } catch { /* swallow */ }
138
+ }
139
+ }
140
+ }
141
+ },
142
+ });
143
+
144
+ if (lineBuffer.trim() && ctx.onEvent) {
145
+ const event = parseOpenCodeStreamLine(lineBuffer.trim());
146
+ if (event) {
147
+ try { await ctx.onEvent(event); } catch { /* swallow */ }
148
+ }
149
+ }
150
+
151
+ return proc;
152
+ };
153
+
154
+ // 7. Initial attempt
155
+ let proc = await runAttempt(sessionId);
156
+ let clearSession = false;
157
+
158
+ // 8. Check for unknown session — retry once
159
+ if (
160
+ sessionId &&
161
+ !proc.timedOut &&
162
+ (proc.exitCode ?? 0) !== 0 &&
163
+ isOpenCodeUnknownSessionError(proc.stdout, proc.stderr)
164
+ ) {
165
+ proc = await runAttempt(null);
166
+ clearSession = true;
167
+ }
168
+
169
+ // 9. Parse result
170
+ const parsed = parseOpenCodeJsonl(proc.stdout);
171
+ const processErrorCode = deriveErrorCode(proc);
172
+
173
+ let errorCode = processErrorCode;
174
+ if (!errorCode && parsed.errorMessage) {
175
+ errorCode = "execution_error";
176
+ }
177
+ if (!errorCode && isOpenCodeAuthRequired(proc.stdout, proc.stderr)) {
178
+ errorCode = "auth_required";
179
+ if (ctx.onEvent) {
180
+ try {
181
+ await ctx.onEvent({
182
+ type: "auth_required",
183
+ httpStatus: null,
184
+ reason: "missing",
185
+ loginCommand: "opencode auth login",
186
+ message: parsed.errorMessage,
187
+ timestamp: new Date().toISOString(),
188
+ providerType: "opencode",
189
+ sessionId: parsed.sessionId,
190
+ messageId: null,
191
+ eventId: null,
192
+ turnId: null,
193
+ parentToolCallId: null,
194
+ raw: {},
195
+ });
196
+ } catch { /* swallow */ }
197
+ }
198
+ }
199
+
200
+ const errorMessage = (() => {
201
+ if (proc.timedOut) return `Timed out after ${config.timeoutSec ?? 0}s`;
202
+ if (errorCode === "auth_required") return "OpenCode requires API key configuration.";
203
+ if (parsed.errorMessage) return parsed.errorMessage;
204
+ if ((proc.exitCode ?? 0) !== 0 && !parsed.summary) {
205
+ const stderrLine = proc.stderr.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
206
+ return stderrLine ?? `OpenCode exited with code ${proc.exitCode ?? -1}`;
207
+ }
208
+ return null;
209
+ })();
210
+
211
+ const resolvedSessionId = parsed.sessionId;
212
+ const resultSessionParams = resolvedSessionId ? { sessionId: resolvedSessionId, cwd } : null;
213
+ const completedAt = new Date().toISOString();
214
+ const resolvedModel = model || null;
215
+ const status = proc.aborted ? "aborted" as const
216
+ : proc.timedOut ? "timeout" as const
217
+ : (errorCode || errorMessage) ? "failed" as const
218
+ : "completed" as const;
219
+
220
+ if (status === "completed") {
221
+ ctx.onLifecycle?.({ phase: "completed" });
222
+ } else if (status === "aborted") {
223
+ ctx.onLifecycle?.({ phase: "cancelled" });
224
+ } else {
225
+ ctx.onLifecycle?.({ phase: "error", message: errorMessage ?? "Unknown error" });
226
+ }
227
+
228
+ return {
229
+ runId,
230
+ exitCode: proc.exitCode,
231
+ signal: proc.signal ?? null,
232
+ status,
233
+ startedAt,
234
+ completedAt,
235
+ durationMs: new Date(completedAt).getTime() - new Date(startedAt).getTime(),
236
+ errorMessage,
237
+ errorCode,
238
+ usage: parsed.usage && resolvedModel
239
+ ? { [resolvedModel]: { inputTokens: parsed.usage.inputTokens, outputTokens: parsed.usage.outputTokens, cachedInputTokens: parsed.usage.cachedInputTokens } }
240
+ : undefined,
241
+ costUsd: parsed.costUsd,
242
+ model: resolvedModel,
243
+ summary: parsed.summary,
244
+ sessionParams: resultSessionParams,
245
+ sessionDisplayId: resolvedSessionId,
246
+ clearSession,
247
+ billingType: "api",
248
+ raw: null,
249
+ workspace,
250
+ };
251
+ }
@@ -0,0 +1,427 @@
1
+ import type {
2
+ AgentSession,
3
+ CancelResult,
4
+ ClearGoalResult,
5
+ GoalOptions,
6
+ GoalState,
7
+ StopTaskResult,
8
+ SendHandle,
9
+ SendOptions,
10
+ SessionContext,
11
+ SessionState,
12
+ SetGoalResult,
13
+ StreamEvent,
14
+ TurnResult,
15
+ } from "../../types.js";
16
+ import { GoalController, EMULATED_GOAL_CAPABILITY } from "../../goals/index.js";
17
+ import { findBinary } from "../../utils/binary.js";
18
+ import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
19
+ import { uuidv7 } from "../../utils/uuid.js";
20
+ import { acquireOpenCodeServer, type OpenCodeServerHandle } from "./server.js";
21
+ import { opencodeSessionCodec } from "./codec.js";
22
+ import {
23
+ assistantTextFromParts,
24
+ mapOpenCodePart,
25
+ mapOpenCodeToolCall,
26
+ turnStatusFromMessage,
27
+ usageFromMessage,
28
+ type OcBaseInfo,
29
+ } from "./event-parse.js";
30
+
31
+ function str(v: unknown): string | null {
32
+ return typeof v === "string" && v.length > 0 ? v : null;
33
+ }
34
+ function num(v: unknown): number | null {
35
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
36
+ }
37
+ function rec(v: unknown): Record<string, unknown> | null {
38
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
39
+ }
40
+
41
+ /** Parse a "providerID/modelID" string into opencode's model param. */
42
+ function parseModel(model: string | undefined): { providerID: string; modelID: string } | null {
43
+ if (!model) return null;
44
+ const idx = model.indexOf("/");
45
+ if (idx <= 0) return null;
46
+ return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) };
47
+ }
48
+
49
+ function readResumeId(sessionParams: Record<string, unknown> | null | undefined): string | null {
50
+ const decoded = opencodeSessionCodec.deserialize(sessionParams ?? null);
51
+ const id = decoded?.["sessionId"];
52
+ return typeof id === "string" && id.length > 0 ? id : null;
53
+ }
54
+
55
+ /** Create + connect an OpenCode HTTP/SSE session. */
56
+ export async function createOpenCodeSession(ctx: SessionContext): Promise<AgentSession> {
57
+ const session = new OpenCodeSession(ctx);
58
+ await session.connect();
59
+ return session;
60
+ }
61
+
62
+ class OpenCodeSession implements AgentSession {
63
+ private _state: SessionState = "idle";
64
+ private _sessionId: string | null = null;
65
+ private server: OpenCodeServerHandle | null = null;
66
+ private url = "";
67
+ private readonly model: { providerID: string; modelID: string } | null;
68
+
69
+ private _turnActive = false;
70
+ private _inFlight: Promise<TurnResult> | null = null;
71
+ private _draining = false;
72
+ /** AbortController for the in-flight `POST /message`, so `close()` can cancel it. */
73
+ private _activeController: AbortController | null = null;
74
+
75
+ // Live-stream dedup state (the global SSE feed re-sends full parts as they grow).
76
+ // Cleared at the start of every turn so a prior turn's part ids can't suppress
77
+ // this turn's events.
78
+ private readonly _seenTextLen = new Map<string, number>();
79
+ private readonly _emittedToolCall = new Set<string>();
80
+ private readonly _emittedToolResult = new Set<string>();
81
+ private _sse: AbortController | null = null;
82
+
83
+ /** Goal engine. OpenCode has no native goal surface — always emulated. */
84
+ private readonly _goals: GoalController;
85
+
86
+ constructor(private readonly ctx: SessionContext) {
87
+ this.model = parseModel(ctx.config?.model);
88
+ this._goals = new GoalController({
89
+ providerType: "opencode",
90
+ capability: EMULATED_GOAL_CAPABILITY,
91
+ getSessionId: () => this._sessionId,
92
+ send: (m) => this.send(m),
93
+ dispatch: (event: StreamEvent) => {
94
+ if (this.ctx.onEvent) void Promise.resolve(this.ctx.onEvent(event)).catch(() => {});
95
+ },
96
+ });
97
+ }
98
+
99
+ get sessionId(): string | null {
100
+ return this._sessionId;
101
+ }
102
+ get state(): SessionState {
103
+ return this._state;
104
+ }
105
+
106
+ async connect(): Promise<void> {
107
+ const config = this.ctx.config ?? {};
108
+ const resolved = await findBinary("opencode", config.command);
109
+ const env = buildEnv(this.ctx.env);
110
+ ensurePathInEnv(env);
111
+ const cwd = this.ctx.cwd ?? process.cwd();
112
+
113
+ this.server = await acquireOpenCodeServer(resolved.bin, resolved.prefixArgs, env, cwd);
114
+ this.url = this.server.url;
115
+
116
+ // Everything after acquiring the server must release it on failure, or the
117
+ // pooled `opencode serve` process leaks (refCount stuck at 1 forever).
118
+ try {
119
+ const resumeId = readResumeId(this.ctx.sessionParams);
120
+ if (resumeId) {
121
+ this._sessionId = resumeId;
122
+ } else {
123
+ const res = await fetch(`${this.url}/session`, {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json" },
126
+ body: JSON.stringify({}),
127
+ });
128
+ if (!res.ok) throw new Error(`opencode: failed to create session (${res.status})`);
129
+ const data = (await res.json()) as Record<string, unknown>;
130
+ this._sessionId = str(data["id"]);
131
+ }
132
+
133
+ // Live event stream (global; filtered to our session).
134
+ this._sse = new AbortController();
135
+ void this.readSse(this._sse.signal);
136
+ } catch (err) {
137
+ if (this._sse) {
138
+ try {
139
+ this._sse.abort();
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+ this._sse = null;
144
+ }
145
+ this.server.release();
146
+ this.server = null;
147
+ this._state = "closed";
148
+ throw err;
149
+ }
150
+
151
+ if (this.ctx.signal) {
152
+ if (this.ctx.signal.aborted) void this.close();
153
+ else this.ctx.signal.addEventListener("abort", () => void this.close(), { once: true });
154
+ }
155
+ }
156
+
157
+ // -------------------------------------------------------------------------
158
+ // SSE live stream
159
+ // -------------------------------------------------------------------------
160
+
161
+ private async readSse(signal: AbortSignal): Promise<void> {
162
+ try {
163
+ const res = await fetch(`${this.url}/global/event`, { signal });
164
+ if (!res.body) return;
165
+ const reader = res.body.getReader();
166
+ const decoder = new TextDecoder();
167
+ let buf = "";
168
+ for (;;) {
169
+ const { done, value } = await reader.read();
170
+ if (done) break;
171
+ buf += decoder.decode(value, { stream: true });
172
+ let nl: number;
173
+ while ((nl = buf.indexOf("\n")) >= 0) {
174
+ const line = buf.slice(0, nl).trimEnd();
175
+ buf = buf.slice(nl + 1);
176
+ if (line.startsWith("data:")) {
177
+ const json = line.slice(5).trim();
178
+ if (json) await this.handleSse(json);
179
+ }
180
+ }
181
+ }
182
+ } catch {
183
+ // Stream closed/aborted — the POST /message response remains authoritative.
184
+ }
185
+ }
186
+
187
+ private async handleSse(json: string): Promise<void> {
188
+ let parsed: unknown;
189
+ try {
190
+ parsed = JSON.parse(json);
191
+ } catch {
192
+ return;
193
+ }
194
+ const payload = rec(rec(parsed)?.["payload"]);
195
+ if (!payload || payload["type"] !== "message.part.updated") return;
196
+ const props = rec(payload["properties"]);
197
+ const part = rec(props?.["part"]);
198
+ if (!part || str(part["sessionID"]) !== this._sessionId) return;
199
+ await this.emitPart(part);
200
+ }
201
+
202
+ private async emitPart(part: Record<string, unknown>): Promise<void> {
203
+ // The SSE feed is global and async: drop stragglers that arrive while no turn
204
+ // is active so a finished turn's late parts don't surface against the next one.
205
+ if (!this._turnActive) return;
206
+ const info: OcBaseInfo = {
207
+ provider: "opencode",
208
+ sessionId: this._sessionId,
209
+ timestamp: new Date().toISOString(),
210
+ };
211
+ const id = str(part["id"]) ?? "";
212
+ const type = part["type"];
213
+
214
+ if (type === "text" || type === "reasoning") {
215
+ if (part["synthetic"] === true || part["ignored"] === true) return;
216
+ const full = typeof part["text"] === "string" ? part["text"] : "";
217
+ const seen = this._seenTextLen.get(id) ?? 0;
218
+ if (full.length <= seen) return;
219
+ this._seenTextLen.set(id, full.length);
220
+ const ev = mapOpenCodePart(part, info, full.slice(seen));
221
+ await this.emit(ev);
222
+ } else if (type === "tool") {
223
+ const callId = str(part["callID"]) ?? id;
224
+ const status = str(rec(part["state"])?.["status"]);
225
+ if (!this._emittedToolCall.has(callId)) {
226
+ this._emittedToolCall.add(callId);
227
+ await this.emit(mapOpenCodeToolCall(part, info));
228
+ }
229
+ if ((status === "completed" || status === "error") && !this._emittedToolResult.has(callId)) {
230
+ this._emittedToolResult.add(callId);
231
+ await this.emit(mapOpenCodePart(part, info));
232
+ }
233
+ }
234
+ }
235
+
236
+ private async emit(ev: ReturnType<typeof mapOpenCodePart>): Promise<void> {
237
+ if (ev && this.ctx.onEvent) {
238
+ try {
239
+ await this.ctx.onEvent(ev);
240
+ } catch {
241
+ /* a throwing handler must not break the stream */
242
+ }
243
+ }
244
+ }
245
+
246
+ // -------------------------------------------------------------------------
247
+ // Public API
248
+ // -------------------------------------------------------------------------
249
+
250
+ async send(message: string, options?: SendOptions): Promise<SendHandle> {
251
+ if (this._state === "closed") throw new Error("Session is closed");
252
+ if (this._draining) throw new Error("Session is draining — no new sends accepted");
253
+ if (this._turnActive) {
254
+ throw new Error("OpenCode session is busy — a turn is already in progress (concurrentSend not supported)");
255
+ }
256
+ const uuid = uuidv7();
257
+ this._turnActive = true;
258
+ this._state = "thinking";
259
+ const result = this.runTurn(message, options);
260
+ this._inFlight = result;
261
+ // Advance any emulated goal loop once the turn settles (finishTurn runs in
262
+ // runTurn's finally, so the session is idle by the time this fires).
263
+ void result.then((r) => this._goals.onTurnSettled(r)).catch(() => {});
264
+ return { uuid, result };
265
+ }
266
+
267
+ private finishTurn(): void {
268
+ this._turnActive = false;
269
+ this._inFlight = null;
270
+ if (this._state !== "closed") this._state = "idle";
271
+ }
272
+
273
+ private async runTurn(message: string, options?: SendOptions): Promise<TurnResult> {
274
+ if (!this._sessionId) {
275
+ this.finishTurn();
276
+ return {
277
+ summary: null,
278
+ costUsd: null,
279
+ status: "failed",
280
+ errorCode: "not_initialized",
281
+ errorMessage: "OpenCode session is not initialized",
282
+ };
283
+ }
284
+
285
+ // Fresh per-turn live-stream dedup so a previous turn's part ids can't
286
+ // suppress this turn's events.
287
+ this._seenTextLen.clear();
288
+ this._emittedToolCall.clear();
289
+ this._emittedToolResult.clear();
290
+
291
+ const ac = new AbortController();
292
+ this._activeController = ac;
293
+ const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
294
+ const timer =
295
+ timeoutSec && timeoutSec > 0 ? setTimeout(() => ac.abort("timeout"), timeoutSec * 1000) : null;
296
+ const onAbort = (): void => ac.abort("aborted");
297
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
298
+
299
+ const body: Record<string, unknown> = { parts: [{ type: "text", text: message }] };
300
+ if (this.model) body["model"] = this.model;
301
+
302
+ 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 {
312
+ summary: null,
313
+ costUsd: null,
314
+ status: "failed",
315
+ errorCode: "http_error",
316
+ errorMessage: `opencode message failed: ${res.status} ${text}`.trim(),
317
+ };
318
+ }
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
+ } catch (err) {
332
+ 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";
336
+ if (ac.signal.reason !== "closed") await this.interrupt().catch(() => {});
337
+ return {
338
+ summary: null,
339
+ costUsd: null,
340
+ status: reason,
341
+ errorCode: reason,
342
+ errorMessage: reason === "timeout" ? "Turn exceeded its timeout" : "Turn aborted",
343
+ };
344
+ }
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
+ } finally {
353
+ if (timer) clearTimeout(timer);
354
+ options?.signal?.removeEventListener("abort", onAbort);
355
+ if (this._activeController === ac) this._activeController = null;
356
+ this.finishTurn();
357
+ }
358
+ }
359
+
360
+ async cancel(_uuid: string): Promise<CancelResult> {
361
+ return { cancelled: false };
362
+ }
363
+
364
+ async stopTask(_taskId: string): Promise<StopTaskResult> {
365
+ // OpenCode has no per-task stop control; capabilities.stopTask is false.
366
+ return { stopped: false };
367
+ }
368
+
369
+ setGoal(objective: string, options?: GoalOptions): Promise<SetGoalResult> {
370
+ return this._goals.setGoal(objective, options);
371
+ }
372
+
373
+ clearGoal(options?: { reason?: "cleared" | "blocked" }): Promise<ClearGoalResult> {
374
+ return this._goals.clearGoal(options);
375
+ }
376
+
377
+ getGoal(): GoalState | null {
378
+ return this._goals.getGoal();
379
+ }
380
+
381
+ async interrupt(): Promise<void> {
382
+ if (!this._sessionId) return;
383
+ try {
384
+ await fetch(`${this.url}/session/${this._sessionId}/abort`, { method: "POST" });
385
+ } catch {
386
+ /* best effort */
387
+ }
388
+ }
389
+
390
+ async drain(): Promise<void> {
391
+ this._draining = true;
392
+ if (this._inFlight) {
393
+ try {
394
+ await this._inFlight;
395
+ } catch {
396
+ /* ignore */
397
+ }
398
+ }
399
+ await this.close();
400
+ }
401
+
402
+ async close(): Promise<void> {
403
+ this._state = "closed";
404
+ // Abort an in-flight POST /message so close() doesn't leave it racing a
405
+ // killed server (and so the turn settles as aborted, not a confusing error).
406
+ if (this._activeController) {
407
+ try {
408
+ this._activeController.abort("closed");
409
+ } catch {
410
+ /* ignore */
411
+ }
412
+ this._activeController = null;
413
+ }
414
+ if (this._sse) {
415
+ try {
416
+ this._sse.abort();
417
+ } catch {
418
+ /* ignore */
419
+ }
420
+ this._sse = null;
421
+ }
422
+ if (this.server) {
423
+ this.server.release();
424
+ this.server = null;
425
+ }
426
+ }
427
+ }
@@ -0,0 +1,30 @@
1
+ import type { ProviderModule, SessionContext, AgentSession } from "../../types.js";
2
+ import { opencodeSessionCodec } from "./codec.js";
3
+ import { resolveAuthForProvider } from "../../utils/auth.js";
4
+ import { EMULATED_GOAL_CAPABILITY } from "../../goals/index.js";
5
+
6
+ export const opencodeProvider: ProviderModule = {
7
+ type: "opencode",
8
+ capabilities: {
9
+ sessions: true,
10
+ modelDiscovery: false,
11
+ quotaProbing: false,
12
+ mcp: false,
13
+ skills: true,
14
+ instructions: true,
15
+ workspace: true,
16
+ planMode: false,
17
+ concurrentSend: false,
18
+ cancelQueuedMessage: false,
19
+ stopTask: false,
20
+ modes: false,
21
+ goals: EMULATED_GOAL_CAPABILITY,
22
+ },
23
+ // One-shot `opencode run` for execute(); live HTTP/SSE sessions via the
24
+ // `opencode serve` daemon for createSession(). Both load lazily on first use.
25
+ execute: async (ctx) => (await import("./execute.js")).executeOpenCodeProvider(ctx),
26
+ createSession: async (ctx: SessionContext): Promise<AgentSession> =>
27
+ (await import("./http-session.js")).createOpenCodeSession(ctx),
28
+ resolveAuth: (ctx) => resolveAuthForProvider("opencode", ctx),
29
+ sessionCodec: opencodeSessionCodec,
30
+ };