@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.
@@ -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() {
@@ -0,0 +1,8 @@
1
+ import { type CleanDebugRunsResult } from '../providers/debug-runs.js';
2
+ export interface CleanCommandOptions {
3
+ debug: boolean;
4
+ keep?: number;
5
+ dryRun?: boolean;
6
+ }
7
+ export declare function parseCleanArgs(args: string[]): CleanCommandOptions;
8
+ export declare function runClean(cwd: string, options: CleanCommandOptions): Promise<CleanDebugRunsResult>;
@@ -0,0 +1,94 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { cleanDebugRuns, DEBUG_ROOT_MARKER, } from '../providers/debug-runs.js';
4
+ function parseKeep(value) {
5
+ if (value === undefined || !/^\d+$/.test(value)) {
6
+ throw new Error('pathgrade clean: --keep must be a non-negative integer');
7
+ }
8
+ const parsed = Number(value);
9
+ if (!Number.isSafeInteger(parsed)) {
10
+ throw new Error('pathgrade clean: --keep must be a non-negative integer');
11
+ }
12
+ return parsed;
13
+ }
14
+ export function parseCleanArgs(args) {
15
+ let debug = false;
16
+ let dryRun = false;
17
+ let keep;
18
+ for (let index = 0; index < args.length; index++) {
19
+ const arg = args[index];
20
+ if (arg === '--debug') {
21
+ debug = true;
22
+ continue;
23
+ }
24
+ if (arg === '--dry-run') {
25
+ dryRun = true;
26
+ continue;
27
+ }
28
+ if (arg === '--keep') {
29
+ keep = parseKeep(args[++index]);
30
+ continue;
31
+ }
32
+ if (arg.startsWith('--keep=')) {
33
+ keep = parseKeep(arg.slice('--keep='.length));
34
+ continue;
35
+ }
36
+ throw new Error(`pathgrade clean: unknown option ${arg}`);
37
+ }
38
+ return { debug, ...(keep === undefined ? {} : { keep }), dryRun };
39
+ }
40
+ const SKIPPED_DIRECTORY_NAMES = new Set(['.git', '.worktrees', 'node_modules']);
41
+ async function findDebugRoots(cwd) {
42
+ const roots = [];
43
+ async function visit(dir) {
44
+ let stat;
45
+ try {
46
+ stat = await fs.lstat(dir);
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ if (!stat.isDirectory() || stat.isSymbolicLink())
52
+ return;
53
+ let entries;
54
+ try {
55
+ entries = await fs.readdir(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ if (entries.some(entry => entry.isFile() && !entry.isSymbolicLink() && entry.name === DEBUG_ROOT_MARKER)) {
61
+ roots.push(dir);
62
+ return;
63
+ }
64
+ await Promise.all(entries.map(async (entry) => {
65
+ if (!entry.isDirectory() || entry.isSymbolicLink() || SKIPPED_DIRECTORY_NAMES.has(entry.name))
66
+ return;
67
+ await visit(path.join(dir, entry.name));
68
+ }));
69
+ }
70
+ await visit(path.resolve(cwd));
71
+ return roots;
72
+ }
73
+ export async function runClean(cwd, options) {
74
+ if (!options.debug) {
75
+ throw new Error('pathgrade clean requires --debug');
76
+ }
77
+ const roots = await findDebugRoots(cwd);
78
+ const results = await Promise.all(roots.map(rootDir => cleanDebugRuns({
79
+ rootDir,
80
+ keep: options.keep,
81
+ dryRun: options.dryRun,
82
+ })));
83
+ return results.reduce((total, result) => ({
84
+ removed: total.removed + result.removed,
85
+ retained: total.retained + result.retained,
86
+ active: total.active + result.active,
87
+ dryRun: total.dryRun,
88
+ }), {
89
+ removed: 0,
90
+ retained: 0,
91
+ active: 0,
92
+ dryRun: options.dryRun ?? false,
93
+ });
94
+ }
@@ -20,15 +20,13 @@ import { writeSidecar } from '../affected/sidecar.js';
20
20
  import { discoverPathgradeEvalFiles } from '../evals/discovery.js';
21
21
  import { resolvePathgradeConfig } from '../config/pathgrade.js';
22
22
  import { loadRunnerInvocationAdapter } from '../runners/adapter-loader.js';
23
+ import { buildRunnerEnv } from './runner-env.js';
23
24
  export async function runChanged(opts) {
24
25
  const { cwd, parsed } = opts;
25
26
  const selectionInvocationId = randomUUID();
26
- const runnerEnv = {
27
- ...process.env,
28
- ...(parsed.forceDiagnostics ? { PATHGRADE_DIAGNOSTICS: '1' } : {}),
29
- ...(parsed.forceVerbose ? { PATHGRADE_VERBOSE: '1' } : {}),
27
+ const runnerEnv = buildRunnerEnv(parsed, {
30
28
  PATHGRADE_SELECTION_INVOCATION_ID: selectionInvocationId,
31
- };
29
+ });
32
30
  const configPath = findVitestConfigArg(parsed.runnerArgs);
33
31
  let config;
34
32
  let runnerInvocation;
@@ -0,0 +1,2 @@
1
+ import type { PathgradeRunArgs } from './run-args.js';
2
+ export declare function buildRunnerEnv(parsed: Pick<PathgradeRunArgs, 'forceDiagnostics' | 'forceVerbose'>, additions?: Record<string, string>): NodeJS.ProcessEnv;
@@ -0,0 +1,10 @@
1
+ import { resolveDebugRunId } from '../providers/debug-runs.js';
2
+ export function buildRunnerEnv(parsed, additions = {}) {
3
+ return {
4
+ ...process.env,
5
+ PATHGRADE_DEBUG_RUN_ID: resolveDebugRunId(),
6
+ ...(parsed.forceDiagnostics ? { PATHGRADE_DIAGNOSTICS: '1' } : {}),
7
+ ...(parsed.forceVerbose ? { PATHGRADE_VERBOSE: '1' } : {}),
8
+ ...additions,
9
+ };
10
+ }