@wix/pathgrade 1.0.6 → 1.0.8

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/dist/pathgrade.js CHANGED
@@ -18,11 +18,13 @@ import { runPreviewReactions } from './commands/preview-reactions.js';
18
18
  import { runReport } from './commands/report.js';
19
19
  import { runAffected } from './commands/affected.js';
20
20
  import { runChanged } from './commands/run-changed.js';
21
+ import { parseCleanArgs, runClean } from './commands/clean.js';
21
22
  import { clearSidecar } from './affected/sidecar.js';
22
23
  import { resolvePathgradeConfig } from './config/pathgrade.js';
23
24
  import { loadRunnerInvocationAdapter } from './runners/adapter-loader.js';
24
25
  import { fmt } from './utils/cli.js';
25
26
  import { shutdown } from './utils/shutdown.js';
27
+ import { buildRunnerEnv } from './commands/runner-env.js';
26
28
  function loadDotenv() {
27
29
  const envPath = path.resolve(process.cwd(), '.env');
28
30
  if (!fs.existsSync(envPath))
@@ -100,6 +102,13 @@ async function main() {
100
102
  await runInit(process.cwd(), { force: hasForce });
101
103
  return;
102
104
  }
105
+ if (command === 'clean') {
106
+ const result = await runClean(process.cwd(), parseCleanArgs(args.slice(1)));
107
+ const action = result.dryRun ? 'would remove' : 'removed';
108
+ console.log(`pathgrade: ${action} ${result.removed} debug run(s); ` +
109
+ `retained ${result.retained}; active ${result.active}`);
110
+ return;
111
+ }
103
112
  if (command === 'preview') {
104
113
  const previewArgs = args.slice(1);
105
114
  const mode = previewArgs.includes('browser') ? 'browser' : 'cli';
@@ -164,11 +173,7 @@ async function main() {
164
173
  // previous `--changed` run so it doesn't leak into this full-suite
165
174
  // run (the reporter would otherwise merge old metadata).
166
175
  await clearSidecar(process.cwd());
167
- const env = {
168
- ...process.env,
169
- ...(parsed.forceDiagnostics ? { PATHGRADE_DIAGNOSTICS: '1' } : {}),
170
- ...(parsed.forceVerbose ? { PATHGRADE_VERBOSE: '1' } : {}),
171
- };
176
+ const env = buildRunnerEnv(parsed);
172
177
  try {
173
178
  const config = await resolvePathgradeConfig({ cwd: process.cwd() });
174
179
  const runner = await loadRunnerInvocationAdapter({
@@ -209,6 +214,9 @@ function printHelp() {
209
214
  pathgrade analyze [--skill=X] Analyze skills and output JSON
210
215
  pathgrade validate <file> Validate an .eval.ts file
211
216
  pathgrade validate --affected Strict: every eval must be anchored or have valid __pathgradeMeta
217
+ pathgrade clean --debug Remove completed debug runs
218
+ [--keep=N] Keep the N newest completed debug runs
219
+ [--dry-run] Report removals without changing files
212
220
  pathgrade preview [browser] View results (CLI default, or browser)
213
221
  [--last=N] Show only the N most recent reports
214
222
  [--filter=X] Filter reports by test name (substring)
@@ -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) {
@@ -0,0 +1,23 @@
1
+ export declare const DEBUG_ROOT_MARKER = ".pathgrade-debug-root.json";
2
+ export declare const DEBUG_RUN_MARKER = ".pathgrade-debug-run.json";
3
+ export declare const DEFAULT_DEBUG_RETAIN_RUNS = 3;
4
+ export declare function resolveDebugRunId(): string;
5
+ export interface CleanDebugRunsResult {
6
+ removed: number;
7
+ retained: number;
8
+ active: number;
9
+ dryRun: boolean;
10
+ }
11
+ export declare function prepareManagedDebugRun(input: {
12
+ rootDir: string;
13
+ debugName: string;
14
+ }): Promise<{
15
+ destination: string;
16
+ rootDir: string;
17
+ runId: string;
18
+ }>;
19
+ export declare function cleanDebugRuns(input: {
20
+ rootDir: string;
21
+ keep?: number;
22
+ dryRun?: boolean;
23
+ }): Promise<CleanDebugRunsResult>;
@@ -0,0 +1,208 @@
1
+ import fs from 'fs-extra';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ export const DEBUG_ROOT_MARKER = '.pathgrade-debug-root.json';
5
+ export const DEBUG_RUN_MARKER = '.pathgrade-debug-run.json';
6
+ export const DEFAULT_DEBUG_RETAIN_RUNS = 3;
7
+ let generatedRunId;
8
+ function createRunId() {
9
+ generatedRunId ??= `${new Date().toISOString().replace(/[:.]/g, '-')}-${Math.random().toString(36).slice(2, 8)}`;
10
+ return generatedRunId;
11
+ }
12
+ export function resolveDebugRunId() {
13
+ const configured = process.env.PATHGRADE_DEBUG_RUN_ID;
14
+ if (configured && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(configured))
15
+ return configured;
16
+ return createRunId();
17
+ }
18
+ async function readJsonFile(filePath) {
19
+ try {
20
+ const stat = await fs.lstat(filePath);
21
+ if (!stat.isFile() || stat.isSymbolicLink())
22
+ return undefined;
23
+ return await fs.readJson(filePath);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ async function isOwnedDebugRoot(rootDir) {
30
+ try {
31
+ const stat = await fs.lstat(rootDir);
32
+ if (!stat.isDirectory() || stat.isSymbolicLink())
33
+ return false;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ const marker = await readJsonFile(path.join(rootDir, DEBUG_ROOT_MARKER));
39
+ return marker?.version === 1;
40
+ }
41
+ async function ensureOwnedDebugRoot(rootDir) {
42
+ await fs.ensureDir(rootDir);
43
+ const rootStat = await fs.lstat(rootDir);
44
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
45
+ throw new Error(`Pathgrade debug root must be a real directory: ${rootDir}`);
46
+ }
47
+ const markerPath = path.join(rootDir, DEBUG_ROOT_MARKER);
48
+ let markerExists = false;
49
+ try {
50
+ const markerStat = await fs.lstat(markerPath);
51
+ markerExists = true;
52
+ if (!markerStat.isFile() || markerStat.isSymbolicLink()) {
53
+ throw new Error(`Pathgrade debug root has an unsafe ownership marker: ${rootDir}`);
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (error.code !== 'ENOENT')
58
+ throw error;
59
+ }
60
+ if (!markerExists) {
61
+ await fs.writeJson(markerPath, { version: 1 }, { spaces: 2 });
62
+ return;
63
+ }
64
+ const existing = await readJsonFile(markerPath);
65
+ if (existing?.version !== 1) {
66
+ throw new Error(`Pathgrade debug root has an unsupported ownership marker: ${rootDir}`);
67
+ }
68
+ }
69
+ async function isRealDirectory(directory) {
70
+ try {
71
+ const stat = await fs.lstat(directory);
72
+ return stat.isDirectory() && !stat.isSymbolicLink();
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ async function ensureRealChildDirectory(directory) {
79
+ try {
80
+ await fs.mkdir(directory);
81
+ }
82
+ catch (error) {
83
+ if (error.code !== 'EEXIST')
84
+ throw error;
85
+ }
86
+ if (!await isRealDirectory(directory)) {
87
+ throw new Error(`Pathgrade managed debug path must be a real directory: ${directory}`);
88
+ }
89
+ }
90
+ async function ensureRunMarker(runDir, runId) {
91
+ const markerPath = path.join(runDir, DEBUG_RUN_MARKER);
92
+ const temporaryMarker = `${markerPath}.${process.pid}.${randomUUID()}.tmp`;
93
+ await fs.writeJson(temporaryMarker, {
94
+ version: 1,
95
+ runId,
96
+ createdAt: new Date().toISOString(),
97
+ }, { spaces: 2 });
98
+ try {
99
+ await fs.link(temporaryMarker, markerPath);
100
+ }
101
+ catch (error) {
102
+ if (error.code !== 'EEXIST')
103
+ throw error;
104
+ }
105
+ finally {
106
+ await fs.remove(temporaryMarker);
107
+ }
108
+ const marker = await readJsonFile(markerPath);
109
+ if (marker?.version !== 1 ||
110
+ marker.runId !== runId ||
111
+ typeof marker.createdAt !== 'string' ||
112
+ !Number.isFinite(Date.parse(marker.createdAt))) {
113
+ throw new Error(`Pathgrade debug run has an unsafe ownership marker: ${runDir}`);
114
+ }
115
+ }
116
+ export async function prepareManagedDebugRun(input) {
117
+ const rootDir = path.resolve(input.rootDir);
118
+ await ensureOwnedDebugRoot(rootDir);
119
+ const runId = resolveDebugRunId();
120
+ const runsDir = path.join(rootDir, 'runs');
121
+ const runDir = path.join(runsDir, runId);
122
+ const activeDir = path.join(runDir, '.pathgrade-active');
123
+ await ensureRealChildDirectory(runsDir);
124
+ await ensureRealChildDirectory(runDir);
125
+ await ensureRealChildDirectory(activeDir);
126
+ await fs.writeFile(path.join(activeDir, String(process.pid)), '');
127
+ await ensureRunMarker(runDir, runId);
128
+ return {
129
+ destination: input.debugName ? path.join(runDir, input.debugName) : runDir,
130
+ rootDir,
131
+ runId,
132
+ };
133
+ }
134
+ async function readOwnedRun(runDir) {
135
+ const marker = await readJsonFile(path.join(runDir, DEBUG_RUN_MARKER));
136
+ if (marker?.version !== 1 ||
137
+ typeof marker.runId !== 'string' ||
138
+ typeof marker.createdAt !== 'string' ||
139
+ !Number.isFinite(Date.parse(marker.createdAt))) {
140
+ return undefined;
141
+ }
142
+ if (path.basename(runDir) !== marker.runId)
143
+ return undefined;
144
+ return marker;
145
+ }
146
+ function isProcessAlive(pid) {
147
+ try {
148
+ process.kill(pid, 0);
149
+ return true;
150
+ }
151
+ catch (error) {
152
+ return error.code !== 'ESRCH';
153
+ }
154
+ }
155
+ async function isRunActive(runDir) {
156
+ const activeDir = path.join(runDir, '.pathgrade-active');
157
+ const entries = await fs.readdir(activeDir, { withFileTypes: true }).catch(() => []);
158
+ return entries.some(entry => {
159
+ if (!entry.isFile() || entry.isSymbolicLink() || !/^\d+$/.test(entry.name))
160
+ return false;
161
+ const pid = Number(entry.name);
162
+ return Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid);
163
+ });
164
+ }
165
+ export async function cleanDebugRuns(input) {
166
+ const dryRun = input.dryRun ?? false;
167
+ if (!await isOwnedDebugRoot(input.rootDir)) {
168
+ return { removed: 0, retained: 0, active: 0, dryRun };
169
+ }
170
+ const runsDir = path.join(input.rootDir, 'runs');
171
+ if (!await isRealDirectory(runsDir)) {
172
+ return { removed: 0, retained: 0, active: 0, dryRun };
173
+ }
174
+ const entries = await fs.readdir(runsDir, { withFileTypes: true }).catch(() => []);
175
+ const ownedRuns = [];
176
+ for (const entry of entries) {
177
+ if (!entry.isDirectory() || entry.isSymbolicLink())
178
+ continue;
179
+ const dir = path.join(runsDir, entry.name);
180
+ const marker = await readOwnedRun(dir);
181
+ if (marker)
182
+ ownedRuns.push({ dir, marker, active: await isRunActive(dir) });
183
+ }
184
+ const activeRuns = ownedRuns.filter(run => run.active);
185
+ const completedRuns = ownedRuns.filter(run => !run.active);
186
+ completedRuns.sort((a, b) => b.marker.createdAt.localeCompare(a.marker.createdAt));
187
+ const keep = input.keep ?? 0;
188
+ const retained = completedRuns.slice(0, keep);
189
+ const removable = completedRuns.slice(keep);
190
+ let removed = removable.length;
191
+ let newlyActive = 0;
192
+ if (!dryRun) {
193
+ const removalResults = await Promise.all(removable.map(async (run) => {
194
+ if (await isRunActive(run.dir))
195
+ return false;
196
+ await fs.remove(run.dir);
197
+ return true;
198
+ }));
199
+ removed = removalResults.filter(Boolean).length;
200
+ newlyActive = removalResults.length - removed;
201
+ }
202
+ return {
203
+ removed,
204
+ retained: retained.length,
205
+ active: activeRuns.length + newlyActive,
206
+ dryRun,
207
+ };
208
+ }
@@ -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)
package/dist/sdk/agent.js CHANGED
@@ -14,6 +14,8 @@ 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 { cleanDebugRuns, DEFAULT_DEBUG_RETAIN_RUNS, prepareManagedDebugRun, } from '../providers/debug-runs.js';
18
+ import { collectOpenCodeMcpToolNames, validateOpenCodeDeclaration } from '../agents/opencode-contract.js';
17
19
  /**
18
20
  * Test-only injection point: override the sink used by the next emitter
19
21
  * built inside `createAgent`. Pass `null` to restore the default (stderr).
@@ -41,7 +43,10 @@ class AgentImpl {
41
43
  verbose;
42
44
  transport;
43
45
  mcpSafety;
44
- constructor(ws, agentName, llm, timeoutSetting, conversationWindow, modelOpt, debugOpt, debugName, debugBaseDir, verbose, transport, mcpSafety) {
46
+ opencodeExecutable;
47
+ opencodeMcpToolNames;
48
+ activeChatSession;
49
+ constructor(ws, agentName, llm, timeoutSetting, conversationWindow, modelOpt, debugOpt, debugName, debugBaseDir, verbose, transport, mcpSafety, opencodeExecutable, opencodeMcpToolNames) {
45
50
  this.ws = ws;
46
51
  this.agentName = agentName;
47
52
  this.llm = llm;
@@ -54,6 +59,8 @@ class AgentImpl {
54
59
  this.verbose = verbose;
55
60
  this.transport = transport;
56
61
  this.mcpSafety = mcpSafety;
62
+ this.opencodeExecutable = opencodeExecutable;
63
+ this.opencodeMcpToolNames = opencodeMcpToolNames;
57
64
  }
58
65
  get messages() {
59
66
  return this._messages;
@@ -96,6 +103,8 @@ class AgentImpl {
96
103
  ...(askUserTimeoutMs !== undefined ? { askUserTimeoutMs } : {}),
97
104
  ...(this.transport !== undefined ? { transport: this.transport } : {}),
98
105
  ...(this.mcpSafety !== undefined ? { mcpSafety: this.mcpSafety } : {}),
106
+ ...(this.opencodeExecutable !== undefined ? { opencodeExecutable: this.opencodeExecutable } : {}),
107
+ ...(this.opencodeMcpToolNames !== undefined ? { opencodeMcpToolNames: this.opencodeMcpToolNames } : {}),
99
108
  });
100
109
  }
101
110
  resolveTimeoutSec(mode, maxTurns) {
@@ -195,6 +204,7 @@ class AgentImpl {
195
204
  }
196
205
  const timeoutSec = this.resolveTimeoutSec('startChat');
197
206
  const ms = this.createSession(timeoutSec);
207
+ this.activeChatSession = ms;
198
208
  const turnResult = await this.executeLoggedTurnResult(ms, firstMessage, 1, 'agent_start');
199
209
  return new ChatSessionImpl(turnResult, {
200
210
  messages: this._messages,
@@ -310,24 +320,46 @@ class AgentImpl {
310
320
  // Runner-owned agents stay tracked until flush consumes metadata.
311
321
  // Manual agents have no runner flush, so dispose releases them.
312
322
  lifecycleCore.releaseAgent(this);
313
- if (this.debugOpt) {
314
- const dest = typeof this.debugOpt === 'string'
315
- ? this.debugOpt
316
- : path.join(this.debugBaseDir, 'pathgrade-debug', this.debugName);
317
- await fs.remove(dest);
318
- await fs.copy(this.ws.path, dest);
319
- if (this.interactionMode === 'runConversation' && this.lastConversationResult) {
320
- const snapshot = buildRunSnapshot({
321
- agent: this.agentName,
322
- messages: this._messages,
323
- log: this._log,
324
- conversationResult: this.lastConversationResult,
325
- workspace: dest,
326
- });
327
- await fs.writeJSON(path.join(dest, 'run-snapshot.json'), snapshot, { spaces: 2 });
323
+ try {
324
+ await this.activeChatSession?.dispose?.();
325
+ this.activeChatSession = undefined;
326
+ if (this.debugOpt) {
327
+ const managedOptions = typeof this.debugOpt === 'object' ? this.debugOpt : undefined;
328
+ const managed = managedOptions
329
+ ? await prepareManagedDebugRun({
330
+ rootDir: managedOptions.directory
331
+ ? path.resolve(this.debugBaseDir, managedOptions.directory)
332
+ : path.join(this.debugBaseDir, 'pathgrade-debug'),
333
+ debugName: this.debugName,
334
+ })
335
+ : undefined;
336
+ const dest = typeof this.debugOpt === 'string'
337
+ ? this.debugOpt
338
+ : managed?.destination ?? path.join(this.debugBaseDir, 'pathgrade-debug', this.debugName);
339
+ await fs.remove(dest);
340
+ await fs.copy(this.ws.path, dest);
341
+ if (this.interactionMode === 'runConversation' && this.lastConversationResult) {
342
+ const snapshot = buildRunSnapshot({
343
+ agent: this.agentName,
344
+ messages: this._messages,
345
+ log: this._log,
346
+ conversationResult: this.lastConversationResult,
347
+ workspace: dest,
348
+ });
349
+ await fs.writeJSON(path.join(dest, 'run-snapshot.json'), snapshot, { spaces: 2 });
350
+ }
351
+ if (managed) {
352
+ const retainRuns = managedOptions.retainRuns ?? DEFAULT_DEBUG_RETAIN_RUNS;
353
+ await cleanDebugRuns({
354
+ rootDir: managed.rootDir,
355
+ keep: Math.max(0, retainRuns - 1),
356
+ });
357
+ }
328
358
  }
329
359
  }
330
- await this.ws.dispose();
360
+ finally {
361
+ await this.ws.dispose();
362
+ }
331
363
  }
332
364
  }
333
365
  /**
@@ -353,14 +385,21 @@ function resolveCaseDebugContext() {
353
385
  };
354
386
  }
355
387
  export async function createAgent(opts) {
388
+ if (typeof opts.debug === 'object') {
389
+ const retainRuns = opts.debug.retainRuns ?? DEFAULT_DEBUG_RETAIN_RUNS;
390
+ if (!Number.isSafeInteger(retainRuns) || retainRuns < 1) {
391
+ throw new Error('Pathgrade debug retainRuns must be a positive integer');
392
+ }
393
+ }
356
394
  const agentName = resolveAgentName(opts, process.env);
395
+ validateOpenCodeDeclaration(agentName, opts);
357
396
  const transport = agentName === 'codex'
358
397
  ? resolveCodexTransport(opts, process.env)
359
398
  : undefined;
360
399
  const timeoutSetting = opts.timeout ?? 300;
361
400
  // Capture runner context now; adapters own installation and restoration.
362
401
  const testCtx = opts.debug ? resolveCaseDebugContext() : { name: '', dir: '' };
363
- const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___, model: ____, transport: _____, mcpSafety: ______, ...rest } = opts;
402
+ const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___, model: ____, transport: _____, mcpSafety: ______, opencodeExecutable, ...rest } = opts;
364
403
  const workspace = await prepareWorkspace({
365
404
  ...rest,
366
405
  agent: agentName,
@@ -378,7 +417,7 @@ export async function createAgent(opts) {
378
417
  sink: verboseSinkOverride ?? undefined,
379
418
  testName: testCtx.name || undefined,
380
419
  });
381
- const agent = new AgentImpl(workspace, agentName, llm, timeoutSetting, opts.conversationWindow, opts.model, opts.debug, debugName, debugBaseDir, verbose, transport, opts.mcpSafety);
420
+ 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
421
  lifecycleCore.registerAgent(agent);
383
422
  return agent;
384
423
  }
@@ -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, AgentExecutionTransport, AgentInteractionMode, } from './types.js';
24
+ export type { Agent, AgentOptions, DebugOptions, 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,7 +5,7 @@ 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
10
  /** Runtime channel that actually executed the agent. */
11
11
  export type AgentExecutionTransport = AgentTransport | 'claude-agent-sdk' | 'cursor-agent';
@@ -19,6 +19,8 @@ export interface AgentExecutionMetadata {
19
19
  }
20
20
  export interface AgentOptions {
21
21
  agent?: AgentName;
22
+ /** Absolute path to the pinned OpenCode executable. Valid only for `agent: 'opencode'`. */
23
+ opencodeExecutable?: string;
22
24
  model?: string;
23
25
  timeout?: number | 'auto';
24
26
  workspace?: string;
@@ -29,8 +31,8 @@ export interface AgentOptions {
29
31
  mcpConfigFile?: string;
30
32
  /** Configure the conversation window for transcript-based agents. Set false to disable. */
31
33
  conversationWindow?: ConversationWindowConfig | false;
32
- /** Copy workspace to a persistent location before cleanup. true = ./pathgrade-debug/{test-name}/, string = custom path. */
33
- debug?: boolean | string;
34
+ /** Preserve the workspace. true uses the legacy path; strings are exact paths; objects enable managed retention. */
35
+ debug?: boolean | string | DebugOptions;
34
36
  /**
35
37
  * Glob patterns to ignore when copying workspace and skill directories.
36
38
  * Replaces the default ignore list entirely. Pass `[]` to disable filtering.
@@ -51,6 +53,12 @@ export interface AgentOptions {
51
53
  */
52
54
  mcpSafety?: McpSafetyOptions;
53
55
  }
56
+ export interface DebugOptions {
57
+ /** Managed debug root. Relative paths resolve next to the eval file. */
58
+ directory?: string;
59
+ /** Maximum managed runs to retain, including the current run. Default: 3. */
60
+ retainRuns?: number;
61
+ }
54
62
  export type { McpRunMode, McpSafetyOptions, McpToolPolicy, McpToolPolicyRule, } from './mcp-safety.js';
55
63
  export interface ConversationWindowConfig {
56
64
  /** Number of recent messages to keep verbatim. Default: 4 */
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>;