@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.
- package/CHANGELOG.md +338 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/derived.d.ts +5 -3
- package/dist/derived.d.ts.map +1 -1
- package/dist/derived.js +11 -7
- package/dist/derived.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/acp/index.d.ts +1 -1
- package/dist/providers/acp/index.d.ts.map +1 -1
- package/dist/providers/acp/index.js +5 -97
- package/dist/providers/acp/index.js.map +1 -1
- package/dist/providers/acp/session.d.ts +8 -1
- package/dist/providers/acp/session.d.ts.map +1 -1
- package/dist/providers/acp/session.js +94 -0
- package/dist/providers/acp/session.js.map +1 -1
- package/dist/providers/claude/attach.d.ts +8 -0
- package/dist/providers/claude/attach.d.ts.map +1 -0
- package/dist/providers/claude/attach.js +113 -0
- package/dist/providers/claude/attach.js.map +1 -0
- package/dist/providers/claude/execute.d.ts.map +1 -1
- package/dist/providers/claude/execute.js +17 -2
- package/dist/providers/claude/execute.js.map +1 -1
- package/dist/providers/claude/goal-capability.d.ts +15 -0
- package/dist/providers/claude/goal-capability.d.ts.map +1 -0
- package/dist/providers/claude/goal-capability.js +20 -0
- package/dist/providers/claude/goal-capability.js.map +1 -0
- package/dist/providers/claude/index.d.ts.map +1 -1
- package/dist/providers/claude/index.js +8 -4
- package/dist/providers/claude/index.js.map +1 -1
- package/dist/providers/claude/session.d.ts +11 -9
- package/dist/providers/claude/session.d.ts.map +1 -1
- package/dist/providers/claude/session.js +36 -14
- package/dist/providers/claude/session.js.map +1 -1
- package/dist/providers/codex/attach.d.ts +9 -0
- package/dist/providers/codex/attach.d.ts.map +1 -0
- package/dist/providers/codex/attach.js +93 -0
- package/dist/providers/codex/attach.js.map +1 -0
- package/dist/providers/codex/execute.d.ts.map +1 -1
- package/dist/providers/codex/execute.js +17 -3
- package/dist/providers/codex/execute.js.map +1 -1
- package/dist/providers/codex/goal-capability.d.ts +13 -0
- package/dist/providers/codex/goal-capability.d.ts.map +1 -0
- package/dist/providers/codex/goal-capability.js +18 -0
- package/dist/providers/codex/goal-capability.js.map +1 -0
- package/dist/providers/codex/index.d.ts +1 -0
- package/dist/providers/codex/index.d.ts.map +1 -1
- package/dist/providers/codex/index.js +9 -6
- package/dist/providers/codex/index.js.map +1 -1
- package/dist/providers/codex/session.d.ts +11 -7
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +37 -12
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/codex/transcript-normalize.d.ts +28 -0
- package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
- package/dist/providers/codex/transcript-normalize.js +191 -0
- package/dist/providers/codex/transcript-normalize.js.map +1 -0
- package/dist/providers/cursor/index.d.ts.map +1 -1
- package/dist/providers/cursor/index.js +2 -2
- package/dist/providers/cursor/index.js.map +1 -1
- package/dist/providers/openclaw/index.d.ts.map +1 -1
- package/dist/providers/openclaw/index.js +2 -2
- package/dist/providers/openclaw/index.js.map +1 -1
- package/dist/providers/opencode/index.d.ts.map +1 -1
- package/dist/providers/opencode/index.js +3 -5
- package/dist/providers/opencode/index.js.map +1 -1
- package/dist/providers/pi/index.d.ts.map +1 -1
- package/dist/providers/pi/index.js +3 -5
- package/dist/providers/pi/index.js.map +1 -1
- package/dist/providers/process/index.d.ts.map +1 -1
- package/dist/providers/process/index.js +2 -2
- package/dist/providers/process/index.js.map +1 -1
- package/dist/registry.d.ts +0 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +0 -4
- package/dist/registry.js.map +1 -1
- package/dist/sessions/index.d.ts +3 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/record.d.ts +43 -0
- package/dist/sessions/record.d.ts.map +1 -0
- package/dist/sessions/record.js +85 -0
- package/dist/sessions/record.js.map +1 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/endpoint.d.ts +38 -0
- package/dist/utils/endpoint.d.ts.map +1 -0
- package/dist/utils/endpoint.js +151 -0
- package/dist/utils/endpoint.js.map +1 -0
- package/dist/utils/env.d.ts.map +1 -1
- package/dist/utils/env.js +5 -1
- package/dist/utils/env.js.map +1 -1
- package/dist/utils/uuid.d.ts +7 -1
- package/dist/utils/uuid.d.ts.map +1 -1
- package/dist/utils/uuid.js +21 -1
- package/dist/utils/uuid.js.map +1 -1
- package/package.json +64 -7
- package/src/derived.ts +311 -0
- package/src/goals/controller.ts +442 -0
- package/src/goals/index.ts +21 -0
- package/src/goals/normalize.ts +173 -0
- package/src/goals/sentinel.ts +90 -0
- package/src/index.ts +270 -0
- package/src/providers/_shared/http-agent.ts +304 -0
- package/src/providers/acp/index.ts +103 -0
- package/src/providers/acp/parse.ts +131 -0
- package/src/providers/acp/session.ts +744 -0
- package/src/providers/claude/attach.ts +147 -0
- package/src/providers/claude/codec.ts +43 -0
- package/src/providers/claude/execute.ts +300 -0
- package/src/providers/claude/goal-capability.ts +21 -0
- package/src/providers/claude/index.ts +72 -0
- package/src/providers/claude/mcp.ts +82 -0
- package/src/providers/claude/parse.ts +824 -0
- package/src/providers/claude/session.ts +1192 -0
- package/src/providers/claude/transcript.ts +555 -0
- package/src/providers/codex/attach.ts +123 -0
- package/src/providers/codex/codec.ts +50 -0
- package/src/providers/codex/execute.ts +337 -0
- package/src/providers/codex/goal-capability.ts +19 -0
- package/src/providers/codex/index.ts +57 -0
- package/src/providers/codex/modes.ts +159 -0
- package/src/providers/codex/parse.ts +691 -0
- package/src/providers/codex/plan-mode.ts +49 -0
- package/src/providers/codex/session.ts +1287 -0
- package/src/providers/codex/transcript-normalize.ts +197 -0
- package/src/providers/codex/transcript.ts +487 -0
- package/src/providers/codex/usage-scanner.ts +178 -0
- package/src/providers/copilot/index.ts +19 -0
- package/src/providers/cursor/codec.ts +44 -0
- package/src/providers/cursor/execute.ts +271 -0
- package/src/providers/cursor/index.ts +25 -0
- package/src/providers/cursor/parse.ts +288 -0
- package/src/providers/gemini/index.ts +21 -0
- package/src/providers/openclaw/codec.ts +40 -0
- package/src/providers/openclaw/execute.ts +19 -0
- package/src/providers/openclaw/index.ts +29 -0
- package/src/providers/opencode/codec.ts +50 -0
- package/src/providers/opencode/event-parse.ts +141 -0
- package/src/providers/opencode/execute.ts +251 -0
- package/src/providers/opencode/http-session.ts +427 -0
- package/src/providers/opencode/index.ts +30 -0
- package/src/providers/opencode/parse.ts +203 -0
- package/src/providers/opencode/server.ts +0 -0
- package/src/providers/pi/codec.ts +44 -0
- package/src/providers/pi/execute.ts +297 -0
- package/src/providers/pi/index.ts +30 -0
- package/src/providers/pi/parse.ts +231 -0
- package/src/providers/pi/session.ts +381 -0
- package/src/providers/process/execute.ts +148 -0
- package/src/providers/process/index.ts +52 -0
- package/src/registry.ts +40 -0
- package/src/sessions/index.ts +8 -0
- package/src/sessions/record.ts +108 -0
- package/src/types.ts +1638 -0
- package/src/utils/ask-user-question.ts +57 -0
- package/src/utils/auth.ts +661 -0
- package/src/utils/binary.ts +179 -0
- package/src/utils/endpoint.ts +172 -0
- package/src/utils/env.ts +63 -0
- package/src/utils/execute-all.ts +68 -0
- package/src/utils/exit-plan-mode.ts +40 -0
- package/src/utils/instructions.ts +427 -0
- package/src/utils/process.ts +223 -0
- package/src/utils/runtime-config.ts +100 -0
- package/src/utils/runtime-homes.ts +49 -0
- package/src/utils/skill-commands.ts +493 -0
- package/src/utils/skills.ts +500 -0
- package/src/utils/template.ts +16 -0
- package/src/utils/tool-names.ts +51 -0
- package/src/utils/uuid.ts +21 -0
- 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
|
+
};
|