@wix/pathgrade 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pathgrade
2
2
 
3
- **Evaluate AI coding agents with Vitest or Jest.** Write evals as normal `.eval.ts` files, run Claude Code, Codex, or Cursor in isolated sandboxes, and score the result with deterministic checks, rubric judges, and tool-usage assertions.
3
+ **Evaluate AI coding agents with Vitest or Jest.** Write evals as normal `.eval.ts` files, run Claude Code, Codex, Cursor, or trusted-internal OpenCode fixtures in isolated workspaces, and score the result with deterministic checks, rubric judges, and tool-usage assertions.
4
4
 
5
5
  ## Why Pathgrade?
6
6
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## Quick Start
15
15
 
16
- **Prerequisites**: Node.js 20.11+, Vitest 4+ or Jest 30+, and at least one configured agent runtime. Claude uses the bundled `@anthropic-ai/claude-agent-sdk` binary by default; Codex requires the `codex` CLI; Cursor requires the `cursor-agent` CLI.
16
+ **Prerequisites**: Node.js 20.11+, Vitest 4+ or Jest 30+, and at least one configured agent runtime. Claude uses the bundled `@anthropic-ai/claude-agent-sdk` binary by default; Codex requires the `codex` CLI; Cursor requires the `cursor-agent` CLI. OpenCode requires the exact v1.18.18 macOS ARM64 or Linux ARM64 executable.
17
17
 
18
18
  ```bash
19
19
  yarn add -D @wix/pathgrade
@@ -40,6 +40,9 @@ By default, Pathgrade tries to reuse the agent CLI's native auth before falling
40
40
  - forwards `CURSOR_API_KEY` when set
41
41
  - macOS: reuses `cursor-agent login` OAuth tokens from the login Keychain
42
42
  - surfaces a clear error when neither is available (run `cursor-agent login` or set `CURSOR_API_KEY`)
43
+ - **OpenCode**
44
+ - requires an explicit `ANTHROPIC_API_KEY` in `createAgent({ env })`; ambient credentials and login state are never read
45
+ - accepts an optional explicit HTTPS `ANTHROPIC_BASE_URL` ending in `/v1`
43
46
 
44
47
  If you set `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, or `CURSOR_API_BASE_URL`, set the matching API key too.
45
48
 
@@ -231,7 +234,24 @@ toolUsage('expected-workflow', [
231
234
 
232
235
  ## Conversations
233
236
 
234
- Pathgrade currently supports three agent backends: `claude`, `codex`, and `cursor`. Set the backend per test via `createAgent({ agent: 'claude' })`, or omit `agent` and use `PATHGRADE_AGENT` as the process-wide fallback.
237
+ Pathgrade supports `claude`, `codex`, `cursor`, and `opencode`. Set the backend per test via `createAgent({ agent: 'claude' })`, or omit `agent` and use `PATHGRADE_AGENT` as the process-wide fallback.
238
+
239
+ ### OpenCode trusted-internal backend
240
+
241
+ OpenCode v1 is only for fixtures, prompts, skills, and generated MCP mocks controlled by trusted Pathgrade maintainers. It is not a security sandbox: OpenCode and Bash children run with the host user's filesystem, process, and network authority and receive the provider credentials. Do not use it for external repositories, user-supplied prompts, or adversarial fixtures.
242
+
243
+ ```typescript
244
+ const agent = await createAgent({
245
+ agent: 'opencode',
246
+ opencodeExecutable: '/absolute/path/to/opencode-v1.18.18',
247
+ env: {
248
+ ANTHROPIC_API_KEY: process.env.APP_ANTHROPIC_API_KEY!,
249
+ ANTHROPIC_BASE_URL: 'https://api.example.com/v1',
250
+ },
251
+ });
252
+ ```
253
+
254
+ The backend supports only `anthropic/claude-sonnet-5`, generated stdio `mcpMock` servers, and clean hosts without system-managed OpenCode configuration. `PATHGRADE_AGENT=opencode` selects the backend but does not supply the required executable or credentials.
235
255
 
236
256
  ### `agent.prompt()` - One shot
237
257
 
@@ -409,13 +429,13 @@ Notes:
409
429
 
410
430
  | Variable | Purpose |
411
431
  |----------|---------|
412
- | `ANTHROPIC_API_KEY` | Claude auth and the required key when using `ANTHROPIC_BASE_URL` |
432
+ | `ANTHROPIC_API_KEY` | Claude auth; also required explicitly in `AgentOptions.env` for OpenCode |
413
433
  | `OPENAI_API_KEY` | Codex auth and the required key when using `OPENAI_BASE_URL` |
414
434
  | `CURSOR_API_KEY` | Cursor auth and the required key when using `CURSOR_API_BASE_URL` |
415
435
  | `ANTHROPIC_BASE_URL` | Custom Anthropic-compatible endpoint |
416
436
  | `OPENAI_BASE_URL` | Custom OpenAI-compatible endpoint |
417
437
  | `CURSOR_API_BASE_URL` | Custom Cursor-compatible endpoint |
418
- | `PATHGRADE_AGENT` | Fallback agent for all tests (`claude`, `codex`, or `cursor`). `createAgent({ agent })` wins over this. |
438
+ | `PATHGRADE_AGENT` | Fallback agent for all tests (`claude`, `codex`, `cursor`, or `opencode`). `createAgent({ agent })` wins over this. |
419
439
  | `PATHGRADE_CODEX_TRANSPORT` | Fallback Codex transport (`exec` or `app-server`). `createAgent({ transport })` wins over this. |
420
440
  | `PATHGRADE_VERBOSE` | `1` enables live per-turn streaming to stderr |
421
441
  | `PATHGRADE_DIAGNOSTICS` | `1` prints full diagnostics for passing evals too |
@@ -0,0 +1,24 @@
1
+ import type { MockMcpServerDescriptor } from '../core/mcp-mock.types.js';
2
+ import type { AgentName, AgentOptions } from '../sdk/types.js';
3
+ export declare const OPENCODE_MODEL = "anthropic/claude-sonnet-5";
4
+ export declare const OPENCODE_VERSION = "1.18.18";
5
+ export interface OpenCodeRuntimeLockEntry {
6
+ version: typeof OPENCODE_VERSION;
7
+ executableSha256: string;
8
+ }
9
+ export declare const OPENCODE_RUNTIME_LOCK: {
10
+ readonly 'darwin-arm64': {
11
+ readonly version: "1.18.18";
12
+ readonly executableSha256: "4f5979c2dadb06fbff1335335afaaea274e58f92e79aa43cf2ed98618d555422";
13
+ };
14
+ readonly 'linux-arm64': {
15
+ readonly version: "1.18.18";
16
+ readonly executableSha256: "a63ef0c7271383e48ffe36c156b4146087bbbc97929dec40e8be95ed4f4d76ae";
17
+ };
18
+ };
19
+ export type OpenCodePlatformKey = keyof typeof OPENCODE_RUNTIME_LOCK;
20
+ export declare function getOpenCodePlatformKey(platform: NodeJS.Platform, arch: string): OpenCodePlatformKey | undefined;
21
+ export declare function currentOpenCodePlatformKey(): OpenCodePlatformKey | undefined;
22
+ export declare function validateOpenCodeDeclaration(agent: AgentName, opts: AgentOptions): void;
23
+ export declare function sanitizeOpenCodeToolName(value: string): string;
24
+ export declare function collectOpenCodeMcpToolNames(declaration: MockMcpServerDescriptor | MockMcpServerDescriptor[] | undefined): string[];
@@ -0,0 +1,84 @@
1
+ import * as path from 'node:path';
2
+ export const OPENCODE_MODEL = 'anthropic/claude-sonnet-5';
3
+ export const OPENCODE_VERSION = '1.18.18';
4
+ export const OPENCODE_RUNTIME_LOCK = {
5
+ 'darwin-arm64': {
6
+ version: OPENCODE_VERSION,
7
+ executableSha256: '4f5979c2dadb06fbff1335335afaaea274e58f92e79aa43cf2ed98618d555422',
8
+ },
9
+ 'linux-arm64': {
10
+ version: OPENCODE_VERSION,
11
+ executableSha256: 'a63ef0c7271383e48ffe36c156b4146087bbbc97929dec40e8be95ed4f4d76ae',
12
+ },
13
+ };
14
+ export function getOpenCodePlatformKey(platform, arch) {
15
+ const key = `${platform}-${arch}`;
16
+ return key in OPENCODE_RUNTIME_LOCK ? key : undefined;
17
+ }
18
+ export function currentOpenCodePlatformKey() {
19
+ return getOpenCodePlatformKey(process.platform, process.arch);
20
+ }
21
+ function hasOwn(record, key) {
22
+ return Object.prototype.hasOwnProperty.call(record, key);
23
+ }
24
+ function assertOpenCodeBaseUrl(value) {
25
+ let url;
26
+ try {
27
+ url = new URL(value);
28
+ }
29
+ catch {
30
+ throw new Error('OpenCode ANTHROPIC_BASE_URL must be an absolute HTTPS API root ending in /v1');
31
+ }
32
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !url.pathname.endsWith('/v1')) {
33
+ throw new Error('OpenCode ANTHROPIC_BASE_URL must be an absolute HTTPS API root ending in /v1');
34
+ }
35
+ }
36
+ export function validateOpenCodeDeclaration(agent, opts) {
37
+ if (agent !== 'opencode') {
38
+ if (opts.opencodeExecutable !== undefined) {
39
+ throw new Error('opencodeExecutable is valid only for agent: "opencode"');
40
+ }
41
+ return;
42
+ }
43
+ if (!currentOpenCodePlatformKey()) {
44
+ throw new Error(`OpenCode is not supported on ${process.platform}-${process.arch}`);
45
+ }
46
+ if (!opts.opencodeExecutable || !path.isAbsolute(opts.opencodeExecutable)) {
47
+ throw new Error('OpenCode requires an absolute opencodeExecutable path');
48
+ }
49
+ if (opts.model !== undefined && opts.model !== OPENCODE_MODEL) {
50
+ throw new Error(`OpenCode v1 supports only model ${OPENCODE_MODEL}`);
51
+ }
52
+ const env = opts.env ?? {};
53
+ if (!hasOwn(env, 'ANTHROPIC_API_KEY') || !env.ANTHROPIC_API_KEY?.trim()) {
54
+ throw new Error('OpenCode requires a nonempty own env.ANTHROPIC_API_KEY');
55
+ }
56
+ if (hasOwn(env, 'ANTHROPIC_BASE_URL')) {
57
+ if (!env.ANTHROPIC_BASE_URL) {
58
+ throw new Error('OpenCode ANTHROPIC_BASE_URL must be a nonempty absolute HTTPS API root ending in /v1');
59
+ }
60
+ assertOpenCodeBaseUrl(env.ANTHROPIC_BASE_URL);
61
+ }
62
+ if (opts.copyFromHome !== undefined)
63
+ throw new Error('OpenCode does not support copyFromHome');
64
+ if (opts.mcpConfigFile !== undefined)
65
+ throw new Error('OpenCode v1 supports generated mcpMock only, not mcpConfigFile');
66
+ if (opts.mcpSafety !== undefined)
67
+ throw new Error('OpenCode v1 does not support mcpSafety');
68
+ if (opts.transport !== undefined)
69
+ throw new Error('OpenCode does not support transport overrides');
70
+ for (const key of Object.keys(env)) {
71
+ if (key === 'HOME' || key.startsWith('XDG_') || key.startsWith('OPENCODE_')) {
72
+ throw new Error(`OpenCode reserves env.${key}`);
73
+ }
74
+ }
75
+ }
76
+ export function sanitizeOpenCodeToolName(value) {
77
+ return value.replace(/[^A-Za-z0-9_-]/g, '_');
78
+ }
79
+ export function collectOpenCodeMcpToolNames(declaration) {
80
+ if (!declaration)
81
+ return [];
82
+ const descriptors = Array.isArray(declaration) ? declaration : [declaration];
83
+ return descriptors.flatMap((descriptor) => descriptor.config.tools.map((tool) => `${sanitizeOpenCodeToolName(descriptor.config.name)}_${sanitizeOpenCodeToolName(tool.name)}`));
84
+ }
@@ -0,0 +1,27 @@
1
+ import { BaseAgent, type AgentCommandRunner, type AgentSession, type AgentSessionOptions, type AgentTurnResult, type EnvironmentHandle } from '../types.js';
2
+ interface SpawnResult {
3
+ stdout: string;
4
+ exitCode: number | null;
5
+ signal: NodeJS.Signals | null;
6
+ overflow: boolean;
7
+ aborted: boolean;
8
+ }
9
+ interface SpawnOpenCodeOptions {
10
+ cwd: string;
11
+ env: Record<string, string>;
12
+ stdin: string;
13
+ signal?: AbortSignal;
14
+ outputCapBytes?: number;
15
+ }
16
+ export declare function spawnOpenCode(executable: string, args: string[], options: SpawnOpenCodeOptions): Promise<SpawnResult>;
17
+ interface ParsedOpenCodeTurn {
18
+ result: AgentTurnResult;
19
+ sessionId: string;
20
+ }
21
+ export declare function parseOpenCodeOutput(stdout: string, processResult: Pick<SpawnResult, 'exitCode' | 'overflow' | 'aborted'>, mcpToolNames: ReadonlySet<string>): ParsedOpenCodeTurn;
22
+ export declare function managedOpenCodeConfigPaths(platform?: NodeJS.Platform, username?: string): string[];
23
+ export declare function assertCleanManagedOpenCodeHost(candidates?: string[]): Promise<void>;
24
+ export declare class OpenCodeAgent extends BaseAgent {
25
+ createSession(runtime: EnvironmentHandle, _runCommand: AgentCommandRunner, options?: AgentSessionOptions): Promise<AgentSession>;
26
+ }
27
+ export {};
@@ -0,0 +1,508 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream } from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import fs from 'fs-extra';
7
+ import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../types.js';
8
+ import { buildSummary, enrichSkillEvents } from '../tool-events.js';
9
+ import { readStagedMcpServers } from '../providers/mcp-config.js';
10
+ import { currentOpenCodePlatformKey, OPENCODE_MODEL, OPENCODE_RUNTIME_LOCK, } from './opencode-contract.js';
11
+ const OUTPUT_CAP_BYTES = 16 * 1024 * 1024;
12
+ const FIXED_OPENCODE_ENV = {
13
+ OPENCODE_CLIENT: 'pathgrade',
14
+ OPENCODE_DISABLE_AUTOUPDATE: '1',
15
+ OPENCODE_DISABLE_PRUNE: '1',
16
+ OPENCODE_DISABLE_MODELS_FETCH: '1',
17
+ OPENCODE_DISABLE_DEFAULT_PLUGINS: '1',
18
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
19
+ OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
20
+ OPENCODE_DISABLE_SHARE: '1',
21
+ OPENCODE_PURE: '1',
22
+ };
23
+ const OPENCODE_PERMISSION = JSON.stringify({
24
+ read: 'allow',
25
+ edit: 'allow',
26
+ glob: 'allow',
27
+ grep: 'allow',
28
+ list: 'allow',
29
+ bash: 'allow',
30
+ todowrite: 'allow',
31
+ lsp: 'allow',
32
+ skill: 'allow',
33
+ task: 'deny',
34
+ question: 'deny',
35
+ plan_enter: 'deny',
36
+ plan_exit: 'deny',
37
+ external_directory: 'deny',
38
+ webfetch: 'deny',
39
+ websearch: 'deny',
40
+ });
41
+ const NATIVE_TOOL_ACTIONS = {
42
+ bash: 'run_shell',
43
+ read: 'read_file',
44
+ write: 'write_file',
45
+ edit: 'edit_file',
46
+ grep: 'search_code',
47
+ glob: 'list_files',
48
+ list: 'list_files',
49
+ skill: 'use_skill',
50
+ todowrite: 'update_todos',
51
+ lsp: 'search_code',
52
+ };
53
+ const ALLOWED_EVENT_TYPES = new Set([
54
+ 'tool_use',
55
+ 'step_start',
56
+ 'step_finish',
57
+ 'text',
58
+ 'reasoning',
59
+ 'error',
60
+ ]);
61
+ export function spawnOpenCode(executable, args, options) {
62
+ return new Promise((resolve, reject) => {
63
+ const stdout = [];
64
+ const cap = options.outputCapBytes ?? OUTPUT_CAP_BYTES;
65
+ let stdoutBytes = 0;
66
+ let stderrBytes = 0;
67
+ let overflow = false;
68
+ let aborted = false;
69
+ let killTimer;
70
+ let settled = false;
71
+ const child = spawn(executable, args, {
72
+ cwd: options.cwd,
73
+ env: options.env,
74
+ shell: false,
75
+ detached: true,
76
+ stdio: ['pipe', 'pipe', 'pipe'],
77
+ });
78
+ const killGroup = (signal) => {
79
+ if (!child.pid)
80
+ return;
81
+ try {
82
+ process.kill(-child.pid, signal);
83
+ }
84
+ catch {
85
+ // The process may have exited between the state check and kill.
86
+ }
87
+ };
88
+ const terminate = () => {
89
+ killGroup('SIGTERM');
90
+ killTimer ??= setTimeout(() => killGroup('SIGKILL'), 250);
91
+ killTimer.unref();
92
+ };
93
+ const onAbort = () => {
94
+ aborted = true;
95
+ terminate();
96
+ };
97
+ const onOverflow = () => {
98
+ overflow = true;
99
+ terminate();
100
+ };
101
+ options.signal?.addEventListener('abort', onAbort, { once: true });
102
+ if (options.signal?.aborted)
103
+ onAbort();
104
+ child.stdout.on('data', (chunk) => {
105
+ stdoutBytes += chunk.length;
106
+ if (stdoutBytes <= cap)
107
+ stdout.push(chunk);
108
+ else
109
+ onOverflow();
110
+ });
111
+ child.stderr.on('data', (chunk) => {
112
+ stderrBytes += chunk.length;
113
+ if (stderrBytes > cap)
114
+ onOverflow();
115
+ });
116
+ child.stdin.on('error', () => undefined);
117
+ if (!aborted)
118
+ child.stdin.end(options.stdin);
119
+ else
120
+ child.stdin.destroy();
121
+ child.once('error', (error) => finish(undefined, undefined, error));
122
+ child.once('close', (code, signal) => finish(code, signal));
123
+ function finish(code, signal, error) {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ options.signal?.removeEventListener('abort', onAbort);
128
+ if (killTimer)
129
+ clearTimeout(killTimer);
130
+ if (error) {
131
+ reject(error);
132
+ return;
133
+ }
134
+ resolve({
135
+ stdout: Buffer.concat(stdout).toString('utf8'),
136
+ exitCode: code ?? null,
137
+ signal: signal ?? null,
138
+ overflow,
139
+ aborted,
140
+ });
141
+ }
142
+ });
143
+ }
144
+ function record(value, label) {
145
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
146
+ throw new Error(`OpenCode protocol error: ${label} must be an object`);
147
+ }
148
+ return value;
149
+ }
150
+ function finiteNumber(value, label) {
151
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
152
+ throw new Error(`OpenCode protocol error: ${label} must be a nonnegative finite number`);
153
+ }
154
+ return value;
155
+ }
156
+ function requiredString(value, label) {
157
+ if (typeof value !== 'string' || !value) {
158
+ throw new Error(`OpenCode protocol error: ${label} must be a nonempty string`);
159
+ }
160
+ return value;
161
+ }
162
+ function sanitizedProviderError(event) {
163
+ const error = record(event.error, 'error');
164
+ const data = error.data && typeof error.data === 'object' && !Array.isArray(error.data)
165
+ ? error.data
166
+ : {};
167
+ const message = typeof data.message === 'string' && data.message.trim()
168
+ ? data.message.trim().slice(0, 1_000)
169
+ : 'OpenCode provider error';
170
+ const status = typeof data.statusCode === 'number' ? ` status=${data.statusCode}` : '';
171
+ const retryable = typeof data.isRetryable === 'boolean' ? ` retryable=${data.isRetryable}` : '';
172
+ return new Error(`${message}${status}${retryable}`);
173
+ }
174
+ export function parseOpenCodeOutput(stdout, processResult, mcpToolNames) {
175
+ if (processResult.overflow)
176
+ throw new Error('OpenCode output exceeded the 16 MiB limit');
177
+ if (processResult.aborted)
178
+ throw new Error('OpenCode turn aborted');
179
+ const textParts = [];
180
+ const toolEvents = [];
181
+ const sanitizedTrace = [];
182
+ const seenParts = new Map();
183
+ let sessionId;
184
+ let stepFinishCount = 0;
185
+ let inputTokens = 0;
186
+ let outputTokens = 0;
187
+ let cacheCreationInputTokens = 0;
188
+ let cacheReadInputTokens = 0;
189
+ let costUsd = 0;
190
+ let providerError;
191
+ for (const [index, line] of stdout.split('\n').entries()) {
192
+ if (!line.trim())
193
+ continue;
194
+ let parsed;
195
+ try {
196
+ parsed = JSON.parse(line);
197
+ }
198
+ catch {
199
+ throw new Error(`OpenCode protocol error: invalid JSON on line ${index + 1}`);
200
+ }
201
+ const event = record(parsed, `line ${index + 1}`);
202
+ const type = requiredString(event.type, `line ${index + 1} type`);
203
+ if (!ALLOWED_EVENT_TYPES.has(type)) {
204
+ throw new Error(`OpenCode protocol error: unsupported event type ${type}`);
205
+ }
206
+ const currentSessionId = requiredString(event.sessionID, `line ${index + 1} sessionID`);
207
+ if (sessionId && sessionId !== currentSessionId) {
208
+ throw new Error('OpenCode protocol error: mixed session IDs');
209
+ }
210
+ sessionId = currentSessionId;
211
+ if (type === 'error') {
212
+ providerError = sanitizedProviderError(event);
213
+ sanitizedTrace.push({ type: 'error', message: providerError.message });
214
+ continue;
215
+ }
216
+ const part = record(event.part, `${type} part`);
217
+ const partId = requiredString(part.id, `${type} part.id`);
218
+ const fingerprint = JSON.stringify({ type, part });
219
+ const prior = seenParts.get(partId);
220
+ if (prior !== undefined) {
221
+ if (prior !== fingerprint) {
222
+ throw new Error(`OpenCode protocol error: conflicting duplicate part ID ${partId}`);
223
+ }
224
+ continue;
225
+ }
226
+ seenParts.set(partId, fingerprint);
227
+ if (type === 'text') {
228
+ const text = typeof part.text === 'string' ? part.text : undefined;
229
+ const time = record(part.time, 'text part.time');
230
+ if (text === undefined || typeof time.end !== 'number') {
231
+ throw new Error('OpenCode protocol error: incomplete text part');
232
+ }
233
+ textParts.push(text);
234
+ sanitizedTrace.push({ type, text });
235
+ continue;
236
+ }
237
+ if (type === 'tool_use') {
238
+ const tool = requiredString(part.tool, 'tool_use part.tool');
239
+ const state = record(part.state, 'tool_use part.state');
240
+ if (state.status !== 'completed' && state.status !== 'error') {
241
+ throw new Error(`OpenCode protocol error: incomplete tool ${tool}`);
242
+ }
243
+ const input = state.input === undefined ? undefined : record(state.input, 'tool_use state.input');
244
+ const action = mcpToolNames.has(tool)
245
+ ? 'mcp_tool_call'
246
+ : NATIVE_TOOL_ACTIONS[tool] ?? 'unknown';
247
+ toolEvents.push({
248
+ action,
249
+ provider: 'opencode',
250
+ providerToolName: tool,
251
+ ...(input ? { arguments: input } : {}),
252
+ summary: buildSummary(action, tool, input),
253
+ confidence: action === 'unknown' ? 'low' : 'high',
254
+ rawSnippet: JSON.stringify({ tool, status: state.status, input }).slice(0, 2_000),
255
+ });
256
+ sanitizedTrace.push({ type, tool, status: state.status, input });
257
+ continue;
258
+ }
259
+ if (type === 'step_finish') {
260
+ const tokens = record(part.tokens, 'step_finish part.tokens');
261
+ const cache = record(tokens.cache, 'step_finish part.tokens.cache');
262
+ const input = finiteNumber(tokens.input, 'tokens.input');
263
+ const output = finiteNumber(tokens.output, 'tokens.output');
264
+ const reasoning = finiteNumber(tokens.reasoning, 'tokens.reasoning');
265
+ const cacheWrite = finiteNumber(cache.write, 'tokens.cache.write');
266
+ const cacheRead = finiteNumber(cache.read, 'tokens.cache.read');
267
+ const cost = finiteNumber(part.cost, 'step_finish part.cost');
268
+ inputTokens += input + cacheWrite + cacheRead;
269
+ outputTokens += output + reasoning;
270
+ cacheCreationInputTokens += cacheWrite;
271
+ cacheReadInputTokens += cacheRead;
272
+ costUsd += cost;
273
+ stepFinishCount++;
274
+ sanitizedTrace.push({
275
+ type,
276
+ tokens: { input, output, reasoning, cache: { write: cacheWrite, read: cacheRead } },
277
+ cost,
278
+ });
279
+ continue;
280
+ }
281
+ sanitizedTrace.push({ type });
282
+ }
283
+ if (providerError)
284
+ throw providerError;
285
+ if (processResult.exitCode !== 0) {
286
+ throw new Error(`OpenCode process exited with code ${processResult.exitCode ?? 'unknown'}`);
287
+ }
288
+ if (!sessionId)
289
+ throw new Error('OpenCode protocol error: no events');
290
+ if (stepFinishCount === 0)
291
+ throw new Error('OpenCode protocol error: missing step_finish');
292
+ const assistantMessage = textParts.join('');
293
+ const traceOutput = sanitizedTrace.map((event) => JSON.stringify(event)).join('\n');
294
+ return {
295
+ sessionId,
296
+ result: {
297
+ rawOutput: traceOutput,
298
+ traceOutput,
299
+ assistantMessage,
300
+ visibleAssistantMessage: assistantMessage,
301
+ visibleAssistantMessageSource: 'assistant_message',
302
+ exitCode: 0,
303
+ toolEvents: enrichSkillEvents(toolEvents),
304
+ inputTokens,
305
+ outputTokens,
306
+ cacheCreationInputTokens,
307
+ cacheReadInputTokens,
308
+ costUsd,
309
+ },
310
+ };
311
+ }
312
+ function sha256File(filename) {
313
+ return new Promise((resolve, reject) => {
314
+ const hash = createHash('sha256');
315
+ const stream = createReadStream(filename);
316
+ stream.on('error', reject);
317
+ stream.on('data', (chunk) => hash.update(chunk));
318
+ stream.on('end', () => resolve(hash.digest('hex')));
319
+ });
320
+ }
321
+ async function assertNoProjectConfig(workspacePath) {
322
+ for (const name of ['opencode.json', 'opencode.jsonc', '.opencode']) {
323
+ if (await fs.pathExists(path.join(workspacePath, name))) {
324
+ throw new Error(`OpenCode workspace configuration is not allowed: ${name}`);
325
+ }
326
+ }
327
+ }
328
+ export function managedOpenCodeConfigPaths(platform = process.platform, username = os.userInfo().username) {
329
+ return platform === 'linux'
330
+ ? ['/etc/opencode/opencode.json', '/etc/opencode/opencode.jsonc']
331
+ : [
332
+ '/Library/Application Support/opencode/opencode.json',
333
+ '/Library/Application Support/opencode/opencode.jsonc',
334
+ `/Library/Managed Preferences/${username}/ai.opencode.managed.plist`,
335
+ '/Library/Managed Preferences/ai.opencode.managed.plist',
336
+ ];
337
+ }
338
+ export async function assertCleanManagedOpenCodeHost(candidates = managedOpenCodeConfigPaths()) {
339
+ for (const candidate of candidates) {
340
+ if (await fs.pathExists(candidate)) {
341
+ throw new Error(`OpenCode managed host configuration is not supported: ${candidate}`);
342
+ }
343
+ }
344
+ }
345
+ async function projectMcpConfig(workspacePath, mcpConfigPath) {
346
+ if (!mcpConfigPath)
347
+ return {};
348
+ const servers = await readStagedMcpServers(workspacePath, mcpConfigPath);
349
+ const config = {};
350
+ for (const [name, entry] of Object.entries(servers ?? {})) {
351
+ if (!('command' in entry)) {
352
+ throw new Error(`OpenCode MCP server "${name}" must remain a stdio server`);
353
+ }
354
+ const stdio = entry;
355
+ config[name] = {
356
+ type: 'local',
357
+ command: [stdio.command, ...(stdio.args ?? [])],
358
+ ...(stdio.env ? { environment: stdio.env } : {}),
359
+ cwd: '.',
360
+ enabled: true,
361
+ };
362
+ }
363
+ return config;
364
+ }
365
+ class OpenCodeSession {
366
+ workspacePath;
367
+ runtimeEnv;
368
+ executable;
369
+ mcpConfigPath;
370
+ mcpToolNames;
371
+ getAbortSignal;
372
+ xdgDirs;
373
+ resolvedExecutable;
374
+ preflightDone = false;
375
+ running = false;
376
+ failed = false;
377
+ disposed = false;
378
+ sessionId;
379
+ inFlight;
380
+ constructor(runtime, options) {
381
+ this.workspacePath = getWorkspacePath(runtime);
382
+ this.runtimeEnv = getRuntimeEnv(runtime);
383
+ this.executable = options.opencodeExecutable;
384
+ this.mcpConfigPath = options.mcpConfigPath;
385
+ this.mcpToolNames = new Set(options.opencodeMcpToolNames ?? []);
386
+ this.getAbortSignal = options.getAbortSignal ?? (() => options.abortSignal);
387
+ const home = this.runtimeEnv.HOME;
388
+ if (!home)
389
+ throw new Error('OpenCode requires a managed HOME');
390
+ this.xdgDirs = [
391
+ path.join(home, '.local', 'share'),
392
+ path.join(home, '.config'),
393
+ path.join(home, '.local', 'state'),
394
+ path.join(home, '.cache'),
395
+ ];
396
+ }
397
+ start({ message }) {
398
+ if (this.sessionId)
399
+ throw new Error('OpenCode session has already started');
400
+ return this.execute(message);
401
+ }
402
+ reply({ message }) {
403
+ if (!this.sessionId)
404
+ throw new Error('OpenCode session has not started');
405
+ return this.execute(message);
406
+ }
407
+ execute(message) {
408
+ if (this.disposed)
409
+ return Promise.reject(new Error('OpenCode session is disposed'));
410
+ if (this.failed)
411
+ return Promise.reject(new Error('OpenCode session is unusable after a failed turn'));
412
+ if (this.running)
413
+ return Promise.reject(new Error('OpenCode does not allow concurrent turns'));
414
+ this.running = true;
415
+ const task = this.runTurn(message);
416
+ this.inFlight = task;
417
+ void task.finally(() => {
418
+ this.running = false;
419
+ if (this.inFlight === task)
420
+ this.inFlight = undefined;
421
+ }).catch(() => undefined);
422
+ return task;
423
+ }
424
+ async runTurn(message) {
425
+ try {
426
+ await this.ensurePreflight();
427
+ await assertNoProjectConfig(this.workspacePath);
428
+ const mcp = await projectMcpConfig(this.workspacePath, this.mcpConfigPath);
429
+ const env = this.buildEnvironment(mcp);
430
+ const args = [
431
+ 'run', '--format', 'json', '--thinking', '--dir', this.workspacePath,
432
+ '--model', OPENCODE_MODEL, '--agent', 'build',
433
+ ...(this.sessionId ? ['--session', this.sessionId] : []),
434
+ ];
435
+ const processResult = await spawnOpenCode(this.resolvedExecutable, args, {
436
+ cwd: this.workspacePath,
437
+ env,
438
+ stdin: message,
439
+ signal: this.getAbortSignal(),
440
+ });
441
+ const parsed = parseOpenCodeOutput(processResult.stdout, processResult, this.mcpToolNames);
442
+ if (this.sessionId && parsed.sessionId !== this.sessionId) {
443
+ throw new Error('OpenCode protocol error: resumed session ID changed');
444
+ }
445
+ this.sessionId = parsed.sessionId;
446
+ return parsed.result;
447
+ }
448
+ catch (error) {
449
+ this.failed = true;
450
+ await this.cleanupState();
451
+ throw error;
452
+ }
453
+ }
454
+ async ensurePreflight() {
455
+ if (this.preflightDone)
456
+ return;
457
+ const platformKey = currentOpenCodePlatformKey();
458
+ if (!platformKey)
459
+ throw new Error(`OpenCode is not supported on ${process.platform}-${process.arch}`);
460
+ const resolved = await fs.realpath(this.executable);
461
+ const actualHash = await sha256File(resolved);
462
+ const expectedHash = OPENCODE_RUNTIME_LOCK[platformKey].executableSha256;
463
+ if (actualHash !== expectedHash) {
464
+ throw new Error(`OpenCode executable hash mismatch for ${platformKey}`);
465
+ }
466
+ await assertCleanManagedOpenCodeHost();
467
+ await assertNoProjectConfig(this.workspacePath);
468
+ await Promise.all(this.xdgDirs.map((directory) => fs.ensureDir(directory)));
469
+ this.resolvedExecutable = resolved;
470
+ this.preflightDone = true;
471
+ }
472
+ buildEnvironment(mcp) {
473
+ const [data, config, state, cache] = this.xdgDirs;
474
+ return {
475
+ ...this.runtimeEnv,
476
+ XDG_DATA_HOME: data,
477
+ XDG_CONFIG_HOME: config,
478
+ XDG_STATE_HOME: state,
479
+ XDG_CACHE_HOME: cache,
480
+ ...FIXED_OPENCODE_ENV,
481
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
482
+ share: 'disabled',
483
+ model: OPENCODE_MODEL,
484
+ skills: { paths: ['.agents/skills'], urls: [] },
485
+ mcp,
486
+ }),
487
+ OPENCODE_PERMISSION,
488
+ };
489
+ }
490
+ async cleanupState() {
491
+ await Promise.all(this.xdgDirs.map((directory) => fs.remove(directory)));
492
+ }
493
+ async dispose() {
494
+ if (this.disposed)
495
+ return;
496
+ this.disposed = true;
497
+ await this.inFlight?.catch(() => undefined);
498
+ await this.cleanupState();
499
+ }
500
+ }
501
+ export class OpenCodeAgent extends BaseAgent {
502
+ async createSession(runtime, _runCommand, options = {}) {
503
+ if (!options.opencodeExecutable) {
504
+ throw new Error('OpenCode requires opencodeExecutable');
505
+ }
506
+ return new OpenCodeSession(runtime, options);
507
+ }
508
+ }
@@ -2,11 +2,13 @@ import { ClaudeAgent } from './claude.js';
2
2
  import { CodexAgent } from './codex.js';
3
3
  import { CodexAppServerAgent } from './codex-app-server/agent.js';
4
4
  import { CursorAgent } from './cursor.js';
5
+ import { OpenCodeAgent } from './opencode.js';
5
6
  /** Registry of available agent implementations. Codex routing is transport-aware. */
6
7
  const AGENT_REGISTRY = {
7
8
  claude: () => new ClaudeAgent(),
8
9
  codex: (transport) => transport === 'exec' ? new CodexAgent() : new CodexAppServerAgent(),
9
10
  cursor: () => new CursorAgent(),
11
+ opencode: () => new OpenCodeAgent(),
10
12
  };
11
13
  /** Get the list of supported agent names */
12
14
  export function getAgentNames() {
@@ -60,10 +60,22 @@ export async function resolveCredentials(agent, userEnv, ports) {
60
60
  return resolveCodex(userEnv, p);
61
61
  case 'cursor':
62
62
  return resolveCursor(userEnv, p);
63
+ case 'opencode':
64
+ return resolveOpenCode(userEnv);
63
65
  default:
64
66
  return EMPTY;
65
67
  }
66
68
  }
69
+ function resolveOpenCode(userEnv) {
70
+ const env = {};
71
+ if (userEnv.ANTHROPIC_API_KEY) {
72
+ env.ANTHROPIC_API_KEY = userEnv.ANTHROPIC_API_KEY;
73
+ }
74
+ if (userEnv.ANTHROPIC_BASE_URL) {
75
+ env.ANTHROPIC_BASE_URL = userEnv.ANTHROPIC_BASE_URL;
76
+ }
77
+ return { env, setupCommands: [], copyFromHome: [] };
78
+ }
67
79
  async function resolveClaude(userEnv, ports) {
68
80
  // User explicitly provided API key — trust it, nothing to add
69
81
  if (userEnv.ANTHROPIC_API_KEY) {
@@ -1,4 +1,4 @@
1
- import type { AgentName, AgentTransport, AgentOptions } from './types.js';
1
+ import type { AgentExecutionTransport, AgentName, AgentTransport, AgentOptions } from './types.js';
2
2
  export declare class InvalidTransportEnvError extends Error {
3
3
  constructor(value: string);
4
4
  }
@@ -10,3 +10,4 @@ export declare function resolveCodexTransport(opts: {
10
10
  }, env: {
11
11
  PATHGRADE_CODEX_TRANSPORT?: string;
12
12
  }): AgentTransport;
13
+ export declare function resolveExecutionTransport(agentName: AgentName, codexTransport?: AgentTransport): AgentExecutionTransport;
@@ -4,8 +4,13 @@ export class InvalidTransportEnvError extends Error {
4
4
  this.name = 'InvalidTransportEnvError';
5
5
  }
6
6
  }
7
+ const AGENT_NAMES = ['claude', 'codex', 'cursor', 'opencode'];
7
8
  export function resolveAgentName(opts, env) {
8
- return (opts.agent || env.PATHGRADE_AGENT || 'claude');
9
+ const value = opts.agent || env.PATHGRADE_AGENT || 'claude';
10
+ if (!AGENT_NAMES.includes(value)) {
11
+ throw new Error(`Unknown agent "${value}". Available agents: ${AGENT_NAMES.join(', ')}`);
12
+ }
13
+ return value;
9
14
  }
10
15
  export function resolveCodexTransport(opts, env) {
11
16
  if (opts.transport)
@@ -19,3 +24,10 @@ export function resolveCodexTransport(opts, env) {
19
24
  }
20
25
  return 'app-server';
21
26
  }
27
+ export function resolveExecutionTransport(agentName, codexTransport) {
28
+ if (agentName === 'claude')
29
+ return 'claude-agent-sdk';
30
+ if (agentName === 'cursor')
31
+ return 'cursor-agent';
32
+ return codexTransport ?? 'app-server';
33
+ }
package/dist/sdk/agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { prepareWorkspace } from '../providers/workspace.js';
2
- import { resolveAgentName, resolveCodexTransport } from './agent-resolution.js';
2
+ import { resolveAgentName, resolveCodexTransport, resolveExecutionTransport, } from './agent-resolution.js';
3
3
  import { lifecycleCore } from './lifecycle.js';
4
4
  import { ChatSessionImpl } from './chat.js';
5
5
  import { runConversation } from './converse.js';
@@ -14,6 +14,7 @@ import { getCurrentCaseContext } from './case-context.js';
14
14
  import { createVerboseEmitter } from '../reporters/verbose-emitter.js';
15
15
  import fs from 'fs-extra';
16
16
  import * as path from 'path';
17
+ import { collectOpenCodeMcpToolNames, validateOpenCodeDeclaration } from '../agents/opencode-contract.js';
17
18
  /**
18
19
  * Test-only injection point: override the sink used by the next emitter
19
20
  * built inside `createAgent`. Pass `null` to restore the default (stderr).
@@ -41,7 +42,10 @@ class AgentImpl {
41
42
  verbose;
42
43
  transport;
43
44
  mcpSafety;
44
- constructor(ws, agentName, llm, timeoutSetting, conversationWindow, modelOpt, debugOpt, debugName, debugBaseDir, verbose, transport, mcpSafety) {
45
+ opencodeExecutable;
46
+ opencodeMcpToolNames;
47
+ activeChatSession;
48
+ constructor(ws, agentName, llm, timeoutSetting, conversationWindow, modelOpt, debugOpt, debugName, debugBaseDir, verbose, transport, mcpSafety, opencodeExecutable, opencodeMcpToolNames) {
45
49
  this.ws = ws;
46
50
  this.agentName = agentName;
47
51
  this.llm = llm;
@@ -54,6 +58,8 @@ class AgentImpl {
54
58
  this.verbose = verbose;
55
59
  this.transport = transport;
56
60
  this.mcpSafety = mcpSafety;
61
+ this.opencodeExecutable = opencodeExecutable;
62
+ this.opencodeMcpToolNames = opencodeMcpToolNames;
57
63
  }
58
64
  get messages() {
59
65
  return this._messages;
@@ -73,7 +79,7 @@ class AgentImpl {
73
79
  return {
74
80
  name: this.agentName,
75
81
  ...(this.modelOpt ? { requestedModel: this.modelOpt } : {}),
76
- ...(this.transport ? { transport: this.transport } : {}),
82
+ transport: resolveExecutionTransport(this.agentName, this.transport),
77
83
  ...(interactionMode ? { interactionMode } : {}),
78
84
  };
79
85
  }
@@ -96,6 +102,8 @@ class AgentImpl {
96
102
  ...(askUserTimeoutMs !== undefined ? { askUserTimeoutMs } : {}),
97
103
  ...(this.transport !== undefined ? { transport: this.transport } : {}),
98
104
  ...(this.mcpSafety !== undefined ? { mcpSafety: this.mcpSafety } : {}),
105
+ ...(this.opencodeExecutable !== undefined ? { opencodeExecutable: this.opencodeExecutable } : {}),
106
+ ...(this.opencodeMcpToolNames !== undefined ? { opencodeMcpToolNames: this.opencodeMcpToolNames } : {}),
99
107
  });
100
108
  }
101
109
  resolveTimeoutSec(mode, maxTurns) {
@@ -195,6 +203,7 @@ class AgentImpl {
195
203
  }
196
204
  const timeoutSec = this.resolveTimeoutSec('startChat');
197
205
  const ms = this.createSession(timeoutSec);
206
+ this.activeChatSession = ms;
198
207
  const turnResult = await this.executeLoggedTurnResult(ms, firstMessage, 1, 'agent_start');
199
208
  return new ChatSessionImpl(turnResult, {
200
209
  messages: this._messages,
@@ -310,6 +319,8 @@ class AgentImpl {
310
319
  // Runner-owned agents stay tracked until flush consumes metadata.
311
320
  // Manual agents have no runner flush, so dispose releases them.
312
321
  lifecycleCore.releaseAgent(this);
322
+ await this.activeChatSession?.dispose?.();
323
+ this.activeChatSession = undefined;
313
324
  if (this.debugOpt) {
314
325
  const dest = typeof this.debugOpt === 'string'
315
326
  ? this.debugOpt
@@ -354,13 +365,14 @@ function resolveCaseDebugContext() {
354
365
  }
355
366
  export async function createAgent(opts) {
356
367
  const agentName = resolveAgentName(opts, process.env);
368
+ validateOpenCodeDeclaration(agentName, opts);
357
369
  const transport = agentName === 'codex'
358
370
  ? resolveCodexTransport(opts, process.env)
359
371
  : undefined;
360
372
  const timeoutSetting = opts.timeout ?? 300;
361
373
  // Capture runner context now; adapters own installation and restoration.
362
374
  const testCtx = opts.debug ? resolveCaseDebugContext() : { name: '', dir: '' };
363
- const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___, model: ____, transport: _____, mcpSafety: ______, ...rest } = opts;
375
+ const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___, model: ____, transport: _____, mcpSafety: ______, opencodeExecutable, ...rest } = opts;
364
376
  const workspace = await prepareWorkspace({
365
377
  ...rest,
366
378
  agent: agentName,
@@ -378,7 +390,7 @@ export async function createAgent(opts) {
378
390
  sink: verboseSinkOverride ?? undefined,
379
391
  testName: testCtx.name || undefined,
380
392
  });
381
- const agent = new AgentImpl(workspace, agentName, llm, timeoutSetting, opts.conversationWindow, opts.model, opts.debug, debugName, debugBaseDir, verbose, transport, opts.mcpSafety);
393
+ const agent = new AgentImpl(workspace, agentName, llm, timeoutSetting, opts.conversationWindow, opts.model, opts.debug, debugName, debugBaseDir, verbose, transport, opts.mcpSafety, opencodeExecutable, agentName === 'opencode' ? collectOpenCodeMcpToolNames(mcpMock) : undefined);
382
394
  lifecycleCore.registerAgent(agent);
383
395
  return agent;
384
396
  }
@@ -175,7 +175,9 @@ export async function runConversation(opts, deps) {
175
175
  const turnStart = Date.now();
176
176
  // Under app-server the child process holds thread state; a crashed
177
177
  // turn is not safely replayable, so retries are suppressed.
178
- const maxRetries = deps.transport === 'app-server' ? 0 : MAX_TURN_RETRIES;
178
+ const maxRetries = deps.transport === 'app-server' || deps.agentName === 'opencode'
179
+ ? 0
180
+ : MAX_TURN_RETRIES;
179
181
  let lastError;
180
182
  let turnResult;
181
183
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -21,7 +21,7 @@ export { emitEvalResult, resetAllResultObserversForTests, resetUserResultObserve
21
21
  export { getAgentCapabilities } from './types.js';
22
22
  export type { AgentTransport, AgentCapabilities, AgentName, McpRunMode, McpSafetyOptions, McpToolPolicy, McpToolPolicyRule, } from './types.js';
23
23
  export type { AskBus, AskBatch, AskQuestion, AskOption, AskAnswer, AskResolution, AskBatchSnapshot, AskAnswerSnapshot, AskResolutionSnapshot, AskHandle, AskHandler, AskSource, AskLifecycle, AskAnswerSource, Unsubscribe as AskBusUnsubscribe, } from './ask-bus/types.js';
24
- export type { Agent, AgentOptions, Message, Scorer, CheckScorer, ScoreScorer, JudgeScorer, ToolUsageScorer, ScorerContext, EvalResult, ScorerResultEntry, ScorerStatus, ChatSession, ConversationResult, ConverseOptions, UntilPredicate, UntilContext, Reaction, TextReaction, AskUserReaction, AskUserQuestion, AskUserOption, ReactionPreviewEntry, TextReactionPreviewEntry, AskUserReactionPreviewEntry, ReactionPreviewResult, ReactionPreviewTurn, StepScorer, Persona, PersonaConfig, ConversationWindowConfig, TurnDetail, ReactionFiredEntry, PathgradePluginOptions, PathgradeMeta, TurnTiming, TokenUsage, EvaluateOptions, ReactionPreviewStatus, ScoreResult, JudgeInput, CodeJudgeToolName, ToolExpectation, SessionArtifactMatchOptions, SessionArtifactContent, SessionArtifacts, RecordedEvalResult, PathgradeTestMeta, EvaluationResultKind, AgentExecutionMetadata, AgentInteractionMode, } from './types.js';
24
+ export type { Agent, AgentOptions, Message, Scorer, CheckScorer, ScoreScorer, JudgeScorer, ToolUsageScorer, ScorerContext, EvalResult, ScorerResultEntry, ScorerStatus, ChatSession, ConversationResult, ConverseOptions, UntilPredicate, UntilContext, Reaction, TextReaction, AskUserReaction, AskUserQuestion, AskUserOption, ReactionPreviewEntry, TextReactionPreviewEntry, AskUserReactionPreviewEntry, ReactionPreviewResult, ReactionPreviewTurn, StepScorer, Persona, PersonaConfig, ConversationWindowConfig, TurnDetail, ReactionFiredEntry, PathgradePluginOptions, PathgradeMeta, TurnTiming, TokenUsage, EvaluateOptions, ReactionPreviewStatus, ScoreResult, JudgeInput, CodeJudgeToolName, ToolExpectation, SessionArtifactMatchOptions, SessionArtifactContent, SessionArtifacts, RecordedEvalResult, PathgradeTestMeta, EvaluationResultKind, AgentExecutionMetadata, AgentExecutionTransport, AgentInteractionMode, } from './types.js';
25
25
  export type { ConversationWindow, ConversationWindowOptions } from './conversation-window.js';
26
26
  export type { JudgePipelineOptions } from './judge-pipeline.js';
27
27
  export type { RunScorerOptions } from './run-scorer.js';
@@ -31,6 +31,8 @@ export interface ManagedSessionDeps {
31
31
  transport?: AgentTransport;
32
32
  /** Live MCP Server Safety policy to enforce in supported harnesses. */
33
33
  mcpSafety?: McpSafetyOptions;
34
+ opencodeExecutable?: string;
35
+ opencodeMcpToolNames?: string[];
34
36
  }
35
37
  export interface ManagedSession {
36
38
  /** Full lifecycle: log start/result, push messages, check exit code. */
@@ -24,6 +24,8 @@ export function createManagedSession(deps) {
24
24
  askBus,
25
25
  ...(transport !== undefined ? { transport } : {}),
26
26
  ...(deps.mcpSafety !== undefined ? { mcpSafety: deps.mcpSafety } : {}),
27
+ ...(deps.opencodeExecutable !== undefined ? { opencodeExecutable: deps.opencodeExecutable } : {}),
28
+ ...(deps.opencodeMcpToolNames !== undefined ? { opencodeMcpToolNames: deps.opencodeMcpToolNames } : {}),
27
29
  getAbortSignal: () => currentSignal,
28
30
  };
29
31
  let session = null;
@@ -7,6 +7,7 @@ const INTERACTIVE_TOOL_NAMES = {
7
7
  claude: 'AskUserQuestion',
8
8
  codex: 'request_user_input',
9
9
  cursor: 'AskQuestion',
10
+ opencode: 'question',
10
11
  };
11
12
  export function planRuntimePolicies(agent, transport) {
12
13
  const capabilities = getAgentCapabilities(agent, transport);
@@ -1,9 +1,9 @@
1
1
  import type { LogEntry } from '../types.js';
2
2
  import type { ToolEvent } from '../tool-events.js';
3
3
  import type { AgentName, ConversationResult, Message, TurnTiming } from './types.js';
4
- export declare const RUN_SNAPSHOT_VERSION = 1;
4
+ export declare const RUN_SNAPSHOT_VERSION = 2;
5
5
  export interface RunSnapshot {
6
- version: 1;
6
+ version: 1 | 2;
7
7
  timestamp: string;
8
8
  agent: AgentName;
9
9
  messages: Message[];
@@ -1,12 +1,12 @@
1
1
  import fs from 'fs-extra';
2
- export const RUN_SNAPSHOT_VERSION = 1;
2
+ export const RUN_SNAPSHOT_VERSION = 2;
3
3
  export function buildRunSnapshot(params) {
4
4
  const { agent, messages, log, conversationResult, workspace, timestamp } = params;
5
5
  const toolEvents = log
6
6
  .filter((entry) => entry.type === 'tool_event' && entry.tool_event)
7
7
  .map((entry) => entry.tool_event);
8
8
  return {
9
- version: RUN_SNAPSHOT_VERSION,
9
+ version: agent === 'opencode' ? 2 : 1,
10
10
  timestamp: timestamp ?? new Date().toISOString(),
11
11
  agent,
12
12
  messages: [...messages],
@@ -102,6 +102,9 @@ function validateRunSnapshot(input) {
102
102
  if (snapshot.version > RUN_SNAPSHOT_VERSION) {
103
103
  throw new SnapshotVersionError(snapshot.version);
104
104
  }
105
+ if (snapshot.version !== 1 && snapshot.version !== 2) {
106
+ throw new SnapshotVersionError(snapshot.version);
107
+ }
105
108
  if (!Array.isArray(snapshot.messages)) {
106
109
  throw new SnapshotParseError('Snapshot is missing required field: messages');
107
110
  }
@@ -127,10 +130,16 @@ function validateRunSnapshot(input) {
127
130
  const turnTimings = Array.isArray(snapshot.turnTimings)
128
131
  ? snapshot.turnTimings
129
132
  : snapshot.conversationResult.turnTimings;
133
+ const legacyAgents = ['claude', 'codex', 'cursor'];
134
+ const currentAgents = [...legacyAgents, 'opencode'];
135
+ const allowedAgents = snapshot.version === 1 ? legacyAgents : currentAgents;
136
+ if (typeof snapshot.agent !== 'string' || !allowedAgents.includes(snapshot.agent)) {
137
+ throw new SnapshotParseError(`Snapshot version ${snapshot.version} has invalid agent: ${String(snapshot.agent)}`);
138
+ }
130
139
  return {
131
- version: RUN_SNAPSHOT_VERSION,
140
+ version: snapshot.version,
132
141
  timestamp: typeof snapshot.timestamp === 'string' ? snapshot.timestamp : new Date(0).toISOString(),
133
- agent: snapshot.agent === 'claude' || snapshot.agent === 'codex' || snapshot.agent === 'cursor' ? snapshot.agent : 'claude',
142
+ agent: snapshot.agent,
134
143
  messages: snapshot.messages,
135
144
  log: snapshot.log,
136
145
  toolEvents: snapshot.toolEvents,
@@ -5,18 +5,22 @@ import type { TrialResult } from '../types.js';
5
5
  import type { DiagnosticsReport } from './diagnostics.js';
6
6
  import type { LLMPort } from '../utils/llm-types.js';
7
7
  import type { McpSafetyOptions } from './mcp-safety.js';
8
- export type AgentName = 'claude' | 'codex' | 'cursor';
8
+ export type AgentName = 'claude' | 'codex' | 'cursor' | 'opencode';
9
9
  export type AgentInteractionMode = 'prompt' | 'start_chat' | 'conversation';
10
+ /** Runtime channel that actually executed the agent. */
11
+ export type AgentExecutionTransport = AgentTransport | 'claude-agent-sdk' | 'cursor-agent';
10
12
  /** Privacy-safe execution dimensions exposed to result observers. */
11
13
  export interface AgentExecutionMetadata {
12
14
  name: AgentName;
13
15
  /** Model override requested by the caller; not necessarily the provider-resolved model. */
14
16
  requestedModel?: string;
15
- transport?: AgentTransport;
17
+ transport?: AgentExecutionTransport;
16
18
  interactionMode?: AgentInteractionMode;
17
19
  }
18
20
  export interface AgentOptions {
19
21
  agent?: AgentName;
22
+ /** Absolute path to the pinned OpenCode executable. Valid only for `agent: 'opencode'`. */
23
+ opencodeExecutable?: string;
20
24
  model?: string;
21
25
  timeout?: number | 'auto';
22
26
  workspace?: string;
@@ -70,7 +74,7 @@ export interface Agent {
70
74
  readonly messages: Message[];
71
75
  readonly log: LogEntry[];
72
76
  readonly workspace: string;
73
- /** Provider/configuration metadata only; never contains prompts or paths. */
77
+ /** Provider/runtime metadata only; never contains prompts or paths. */
74
78
  readonly executionMetadata?: AgentExecutionMetadata;
75
79
  dispose(): Promise<void>;
76
80
  }
package/dist/sdk/types.js CHANGED
@@ -2,6 +2,7 @@ const BASE_CAPABILITIES = {
2
2
  claude: { mcp: true, nativeSession: true, interactiveQuestionTransport: 'reliable' },
3
3
  codex: { mcp: false, nativeSession: true, interactiveQuestionTransport: 'noninteractive' },
4
4
  cursor: { mcp: true, nativeSession: true, interactiveQuestionTransport: 'noninteractive' },
5
+ opencode: { mcp: true, nativeSession: true, interactiveQuestionTransport: 'noninteractive' },
5
6
  };
6
7
  export function getAgentCapabilities(agent, transport) {
7
8
  const base = BASE_CAPABILITIES[agent];
@@ -1,7 +1,7 @@
1
1
  export type ToolAction = 'run_shell' | 'read_file' | 'write_file' | 'edit_file' | 'search_code' | 'list_files' | 'ask_user' | 'web_fetch' | 'use_skill' | 'update_todos' | 'mcp_tool_call' | 'unknown';
2
2
  export interface ToolEvent {
3
3
  action: ToolAction;
4
- provider: 'claude' | 'codex' | 'cursor';
4
+ provider: 'claude' | 'codex' | 'cursor' | 'opencode';
5
5
  providerToolName: string;
6
6
  turnNumber?: number;
7
7
  arguments?: Record<string, unknown>;
package/dist/types.d.ts CHANGED
@@ -379,6 +379,10 @@ export interface AgentSessionOptions {
379
379
  abortSignal?: AbortSignal;
380
380
  /** Supplies the current timeout/cancellation signal for reused sessions. */
381
381
  getAbortSignal?: () => AbortSignal | undefined;
382
+ /** Absolute pinned runtime path for the OpenCode adapter. */
383
+ opencodeExecutable?: string;
384
+ /** Exact generated MCP tool names accepted by the OpenCode event normalizer. */
385
+ opencodeMcpToolNames?: string[];
382
386
  }
383
387
  export declare abstract class BaseAgent {
384
388
  createSession(runtime: EnvironmentHandle, runCommand: AgentCommandRunner, options?: AgentSessionOptions): Promise<AgentSession>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/pathgrade",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "packageManager": "yarn@4.12.0",
5
5
  "description": "Evaluate whether AI agents discover and use your skills correctly",
6
6
  "exports": {
@@ -76,6 +76,7 @@
76
76
  "gemini",
77
77
  "claude",
78
78
  "codex",
79
+ "opencode",
79
80
  "openai",
80
81
  "llm",
81
82
  "testing"
@@ -127,5 +128,5 @@
127
128
  "typescript": "^5.9.3",
128
129
  "zod": "4.3.6"
129
130
  },
130
- "falconPackageHash": "0d0bd8f233a4d538524961fc02c50f608b46cb42353be3b9ad1c4cde"
131
+ "falconPackageHash": "6dec3f88035544895dd8e99741e3c9a69e73651415b4999e32ddefd6"
131
132
  }