ocpipe 0.6.10 → 0.6.12

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/src/agent.ts CHANGED
@@ -1,41 +1,40 @@
1
1
  /**
2
2
  * ocpipe agent integration.
3
3
  *
4
- * Dispatches to OpenCode CLI, Claude Code SDK, Codex SDK, or Pi based on backend
5
- * configuration.
4
+ * Dispatches prompts to configured coding-agent backends.
6
5
  */
7
6
 
8
- import { spawn } from 'child_process'
9
- import { mkdir, writeFile, unlink } from 'fs/promises'
7
+ import { spawn, type ChildProcess } from 'child_process'
8
+ import { createWriteStream, type WriteStream } from 'fs'
9
+ import { randomUUID } from 'crypto'
10
+ import { mkdir, readFile, unlink, writeFile } from 'fs/promises'
10
11
  import { join } from 'path'
12
+ import { OutputLimitError } from './errors.js'
11
13
  import { PROJECT_ROOT, TMP_DIR } from './paths.js'
14
+ import type { RunAgentOptions, RunAgentResult } from './types.js'
12
15
  import { runOmpAgent } from './omp.js'
13
16
  import { runPiAgent } from './pi.js'
14
- import type { RunAgentOptions, RunAgentResult } from './types.js'
15
17
 
16
- /** Get command and args to invoke opencode from PATH */
17
- function getOpencodeCommand(args: string[]): { cmd: string; args: string[] } {
18
- return { cmd: 'opencode', args }
19
- }
20
-
21
- /** runAgent dispatches to the appropriate backend based on model configuration. */
18
+ /** runAgent dispatches to the selected backend. */
22
19
  export async function runAgent(
23
20
  options: RunAgentOptions,
24
21
  ): Promise<RunAgentResult> {
25
- const backend = options.model.backend ?? 'opencode'
22
+ const backend = options.model.backend ?? 'omp'
26
23
 
27
24
  if (backend === 'claude-code') {
28
- // Dynamic import is required: @anthropic-ai/claude-agent-sdk is an optional peer absent for other backends.
29
25
  const { runClaudeCodeAgent } = await import('./claude-code.js')
30
26
  return runClaudeCodeAgent(options)
31
27
  }
32
28
 
33
29
  if (backend === 'codex') {
34
- // Dynamic import is required: @openai/codex-sdk is an optional peer absent for other backends.
35
30
  const { runCodexAgent } = await import('./codex.js')
36
31
  return runCodexAgent(options)
37
32
  }
38
33
 
34
+ if (backend === 'opencode') {
35
+ return runOpencodeAgent(options)
36
+ }
37
+
39
38
  if (backend === 'pi') {
40
39
  return runPiAgent(options)
41
40
  }
@@ -44,52 +43,257 @@ export async function runAgent(
44
43
  return runOmpAgent(options)
45
44
  }
46
45
 
47
- return runOpencodeAgent(options)
46
+ const unreachable: never = backend
47
+ throw new Error(`Unsupported backend: ${unreachable}`)
48
+ }
49
+ const MAX_CHILD_OUTPUT_BYTES = 512 * 1024 * 1024
50
+ const MAX_STDERR_LINE_BYTES = 8 * 1024 * 1024
51
+
52
+ class OutputBudget {
53
+ private usedBytes = 0
54
+
55
+ reserve(bytes: number, source: string): void {
56
+ if (this.usedBytes + bytes > MAX_CHILD_OUTPUT_BYTES) {
57
+ throw new OutputLimitError(
58
+ `OpenCode ${source} output`,
59
+ MAX_CHILD_OUTPUT_BYTES,
60
+ )
61
+ }
62
+ this.usedBytes += bytes
63
+ }
64
+ }
65
+
66
+ class OutputFile {
67
+ private readonly stream: WriteStream
68
+ private readonly onError: (error: Error) => void
69
+ private error: Error | undefined
70
+ private closePromise: Promise<void> | undefined
71
+
72
+ constructor(
73
+ readonly path: string,
74
+ budget: OutputBudget,
75
+ source: string,
76
+ onError: (error: Error) => void,
77
+ ) {
78
+ this.onError = onError
79
+ this.stream = createWriteStream(path, { mode: 0o600 })
80
+ this.stream.on('error', (error) => {
81
+ this.fail(error)
82
+ })
83
+ this.budget = budget
84
+ this.source = source
85
+ }
86
+
87
+ private readonly budget: OutputBudget
88
+ private readonly source: string
89
+
90
+ get failed(): boolean {
91
+ return this.error !== undefined
92
+ }
93
+
94
+ write(chunk: Buffer): boolean {
95
+ if (this.error) return false
96
+ try {
97
+ this.budget.reserve(chunk.byteLength, this.source)
98
+ } catch (error) {
99
+ this.fail(error)
100
+ return false
101
+ }
102
+ return this.stream.write(chunk)
103
+ }
104
+
105
+ async waitForDrain(): Promise<void> {
106
+ if (!this.stream.writableNeedDrain || this.error) return
107
+ await new Promise<void>((resolve) => {
108
+ const done = () => {
109
+ this.stream.removeListener('drain', done)
110
+ this.stream.removeListener('error', done)
111
+ resolve()
112
+ }
113
+ this.stream.once('drain', done)
114
+ this.stream.once('error', done)
115
+ })
116
+ }
117
+
118
+ close(): Promise<void> {
119
+ if (this.closePromise) return this.closePromise
120
+ this.closePromise = new Promise<void>((resolve) => {
121
+ const done = () => {
122
+ this.stream.removeListener('finish', done)
123
+ this.stream.removeListener('close', done)
124
+ this.stream.removeListener('error', done)
125
+ resolve()
126
+ }
127
+ if (this.stream.destroyed || this.stream.writableFinished) {
128
+ resolve()
129
+ return
130
+ }
131
+ this.stream.once('finish', done)
132
+ this.stream.once('close', done)
133
+ this.stream.once('error', done)
134
+ this.stream.end()
135
+ })
136
+ return this.closePromise
137
+ }
138
+
139
+ private fail(error: unknown): void {
140
+ if (this.error) return
141
+ this.error = error instanceof Error ? error : new Error(String(error))
142
+ this.onError(this.error)
143
+ }
144
+ }
145
+
146
+ async function writeChildChunk(
147
+ stream: { pause?: () => unknown; resume?: () => unknown },
148
+ file: OutputFile,
149
+ chunk: Buffer,
150
+ ): Promise<void> {
151
+ if (file.write(chunk) && !file.failed) return
152
+ if (file.failed) return
153
+ stream.pause?.()
154
+ await file.waitForDrain()
155
+ if (!file.failed) stream.resume?.()
156
+ }
157
+ async function writeConsoleChunk(chunk: Buffer): Promise<void> {
158
+ if (process.stderr.write(chunk)) return
159
+ await new Promise<void>((resolve) => {
160
+ process.stderr.once('drain', resolve)
161
+ })
162
+ }
163
+
164
+ class StderrTracker {
165
+ private pending = ''
166
+ private readonly diagnosticLines: string[] = []
167
+ private readonly lastLines: string[] = []
168
+ private matchedErrorPattern: RegExp | undefined
169
+ private knownError = false
170
+ private sawError = false
171
+ private sessionId = ''
172
+
173
+ consume(text: string): void {
174
+ let cursor = 0
175
+ while (cursor < text.length) {
176
+ const newline = text.indexOf('\n', cursor)
177
+ if (newline < 0) {
178
+ this.append(text.slice(cursor))
179
+ return
180
+ }
181
+ this.append(text.slice(cursor, newline))
182
+ cursor = newline + 1
183
+ this.recordLine(this.pending)
184
+ this.pending = ''
185
+ }
186
+ }
187
+
188
+ finish(): void {
189
+ if (this.pending) {
190
+ this.recordLine(this.pending)
191
+ this.pending = ''
192
+ }
193
+ }
194
+
195
+ get hasKnownError(): boolean {
196
+ return this.knownError
197
+ }
198
+ get knownErrorPattern(): RegExp | undefined {
199
+ return this.matchedErrorPattern
200
+ }
201
+
202
+ get hasError(): boolean {
203
+ return this.sawError
204
+ }
205
+
206
+ get newSessionId(): string {
207
+ return this.sessionId
208
+ }
209
+
210
+ getLastLines(count: number): string {
211
+ return this.lastLines.slice(-count).join('\n')
212
+ }
213
+
214
+ getDiagnosticLines(): string {
215
+ return this.diagnosticLines.join('\n')
216
+ }
217
+
218
+ private append(text: string): void {
219
+ if (
220
+ Buffer.byteLength(this.pending, 'utf8') +
221
+ Buffer.byteLength(text, 'utf8') >
222
+ MAX_STDERR_LINE_BYTES
223
+ ) {
224
+ throw new OutputLimitError('OpenCode stderr line', MAX_STDERR_LINE_BYTES)
225
+ }
226
+ this.pending += text
227
+ }
228
+
229
+ private recordLine(line: string): void {
230
+ this.lastLines.push(line)
231
+ if (this.lastLines.length > 10) this.lastLines.shift()
232
+ if (line.includes('Error')) this.sawError = true
233
+
234
+ if (line.startsWith('[session:')) {
235
+ this.sessionId = line.trim().slice(9, -1)
236
+ return
237
+ }
238
+ for (const { pattern } of knownOpenCodeErrors) {
239
+ if (pattern.test(line)) {
240
+ this.knownError = true
241
+ this.matchedErrorPattern ??= pattern
242
+ }
243
+ }
244
+
245
+ if (
246
+ line.includes('Error') ||
247
+ line.includes('error:') ||
248
+ knownOpenCodeErrors.some(({ pattern }) => pattern.test(line))
249
+ ) {
250
+ if (this.diagnosticLines.length < 5) this.diagnosticLines.push(line)
251
+ }
252
+ }
48
253
  }
49
254
 
50
- /** runOpencodeAgent executes an OpenCode agent with a prompt, streaming output in real-time. */
51
255
  async function runOpencodeAgent(
52
256
  options: RunAgentOptions,
53
257
  ): Promise<RunAgentResult> {
54
258
  const {
55
259
  prompt,
56
- agent,
57
260
  model,
58
261
  sessionId,
59
262
  timeoutSec = 3600,
60
263
  workdir,
264
+ opencode,
61
265
  signal,
62
266
  } = options
63
267
 
64
268
  if (!model.providerID) {
65
269
  throw new Error('providerID is required for OpenCode backend')
66
270
  }
67
- const modelStr = `${model.providerID}/${model.modelID}`
68
- const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
69
- const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
70
271
 
71
- console.error(
72
- `\n>>> OpenCode [${agent}] [${modelStr}] ${sessionInfo}: ${promptPreview}...`,
73
- )
74
-
75
- // Check if already aborted
76
272
  if (signal?.aborted) {
77
273
  throw new Error('Request aborted')
78
274
  }
79
275
 
80
- // Write prompt to .opencode/prompts/ within the working directory
81
276
  const cwd = workdir ?? PROJECT_ROOT
82
- const promptsDir = join(cwd, '.opencode', 'prompts')
83
- await mkdir(promptsDir, { recursive: true })
84
- const promptFile = join(promptsDir, `prompt_${Date.now()}.txt`)
277
+ const modelStr = `${model.providerID}/${model.modelID}`
278
+ const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
279
+ const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
280
+ console.error(
281
+ `\n>>> OpenCode [${modelStr}] ${sessionInfo}: ${promptPreview}...`,
282
+ )
283
+
284
+ const promptDir = opencode?.promptDir ?? TMP_DIR
285
+ await mkdir(promptDir, { recursive: true })
286
+ await mkdir(TMP_DIR, { recursive: true })
287
+ const promptFile = join(promptDir, `ocpipe_prompt_${randomUUID()}.txt`)
288
+ const outputId = randomUUID()
289
+ const stdoutPath = join(TMP_DIR, `ocpipe_output_${outputId}.stdout`)
290
+ const stderrPath = join(TMP_DIR, `ocpipe_output_${outputId}.stderr`)
85
291
  await writeFile(promptFile, prompt)
86
292
 
87
293
  const args = [
88
294
  'run',
89
295
  '--format',
90
296
  'default',
91
- '--agent',
92
- agent,
93
297
  '--model',
94
298
  modelStr,
95
299
  '--prompt-file',
@@ -99,11 +303,9 @@ async function runOpencodeAgent(
99
303
  if (sessionId) {
100
304
  args.push('--session', sessionId)
101
305
  }
102
-
103
306
  if (model.variant) {
104
307
  args.push('--model-variant', model.variant)
105
308
  }
106
-
107
309
  if (model.variantThinkingBudget !== undefined) {
108
310
  args.push(
109
311
  '--model-variant-thinking-budget',
@@ -111,139 +313,175 @@ async function runOpencodeAgent(
111
313
  )
112
314
  }
113
315
 
114
- return new Promise((resolve, reject) => {
115
- const opencodeCmd = getOpencodeCommand(args)
116
- console.error(
117
- `[DEBUG] Running: ${opencodeCmd.cmd} ${opencodeCmd.args.join(' ')}`,
118
- )
119
- const proc = spawn(opencodeCmd.cmd, opencodeCmd.args, {
120
- cwd,
121
- stdio: ['ignore', 'pipe', 'pipe'],
122
- })
316
+ const command = opencode?.command ?? 'opencode'
317
+ console.error(`[DEBUG] Running: ${command} ${args.join(' ')}`)
318
+
319
+ const { promise, resolve, reject } = Promise.withResolvers<RunAgentResult>()
320
+ const budget = new OutputBudget()
321
+ const stderrTracker = new StderrTracker()
322
+ let proc: ChildProcess | undefined
323
+ const timers: { timeout?: NodeJS.Timeout } = {}
324
+ let settled = false
325
+ let closeFilesPromise: Promise<void> | undefined
326
+ let killTimer: NodeJS.Timeout | undefined
327
+ let cleanupPromise: Promise<void> | undefined
328
+
329
+ const closeFiles = (): Promise<void> => {
330
+ if (!closeFilesPromise) {
331
+ closeFilesPromise = Promise.all([
332
+ stdoutFile.close(),
333
+ stderrFile.close(),
334
+ ]).then(() => undefined)
335
+ }
336
+ return closeFilesPromise
337
+ }
123
338
 
124
- let newSessionId = sessionId || ''
125
- const stdoutChunks: string[] = []
126
- const stderrChunks: string[] = []
127
- let aborted = false
128
-
129
- // Handle abort signal - kill subprocess when aborted
130
- const abortHandler = async () => {
131
- if (aborted) return
132
- aborted = true
133
- console.error(`\n[abort] Killing OpenCode subprocess...`)
134
- proc.kill('SIGTERM')
135
- // Give it a moment to clean up, then force kill
136
- setTimeout(() => {
137
- if (!proc.killed) proc.kill('SIGKILL')
138
- }, 1000)
139
- await unlink(promptFile).catch(() => {})
140
- reject(new Error('Request aborted'))
339
+ const cleanup = (): Promise<void> => {
340
+ if (!cleanupPromise) {
341
+ cleanupPromise = (async () => {
342
+ await closeFiles()
343
+ await Promise.all(
344
+ [promptFile, stdoutPath, stderrPath].map((path) =>
345
+ unlink(path).catch(() => {}),
346
+ ),
347
+ )
348
+ })()
141
349
  }
142
- signal?.addEventListener('abort', abortHandler, { once: true })
143
-
144
- // Stream stderr in real-time (OpenCode progress output)
145
- proc.stderr.on('data', (data: Buffer) => {
146
- const text = data.toString()
147
- stderrChunks.push(text)
148
-
149
- // Parse session ID from output
150
- for (const line of text.split('\n')) {
151
- if (line.startsWith('[session:')) {
152
- newSessionId = line.trim().slice(9, -1)
153
- continue
154
- }
155
- // Filter noise
156
- if (line.includes('baseline-browser-mapping')) continue
157
- if (line.startsWith('$ bun run')) continue
158
- if (line.trim()) {
159
- process.stderr.write(line + '\n')
160
- }
161
- }
162
- })
350
+ return cleanupPromise
351
+ }
352
+
353
+ const stopProcess = () => {
354
+ if (!proc || proc.killed) return
355
+ proc.kill('SIGTERM')
356
+ killTimer = setTimeout(() => {
357
+ if (proc && !proc.killed) proc.kill('SIGKILL')
358
+ }, 1000)
359
+ }
360
+
361
+ const fail = (error: unknown): void => {
362
+ if (settled) return
363
+ settled = true
364
+ clearTimeout(timers.timeout)
365
+ clearTimeout(killTimer)
366
+ signal?.removeEventListener('abort', abortHandler)
367
+ stopProcess()
368
+ const cause = error instanceof Error ? error : new Error(String(error))
369
+ reject(cause)
370
+ void cleanup()
371
+ }
163
372
 
164
- // Collect stdout
165
- proc.stdout.on('data', (data: Buffer) => {
166
- const text = data.toString()
167
- stdoutChunks.push(text)
168
- process.stderr.write(text)
373
+ const stdoutFile = new OutputFile(stdoutPath, budget, 'stdout', fail)
374
+ const stderrFile = new OutputFile(stderrPath, budget, 'stderr', fail)
375
+
376
+ const abortHandler = () => {
377
+ if (settled) return
378
+ console.error(`\n[abort] Killing OpenCode subprocess...`)
379
+ fail(new Error('Request aborted'))
380
+ }
381
+
382
+ try {
383
+ proc = spawn(command, args, {
384
+ cwd,
385
+ stdio: ['ignore', 'pipe', 'pipe'],
386
+ env: { ...process.env, ...opencode?.env },
169
387
  })
388
+ } catch (error) {
389
+ fail(error)
390
+ return promise
391
+ }
170
392
 
171
- // Timeout handling (0 = no timeout)
172
- const timeout =
173
- timeoutSec > 0 ?
174
- setTimeout(async () => {
175
- proc.kill()
176
- await unlink(promptFile).catch(() => {})
177
- reject(new Error(`Timeout after ${timeoutSec}s`))
178
- }, timeoutSec * 1000)
179
- : null
180
-
181
- proc.on('close', async (code) => {
182
- if (timeout) clearTimeout(timeout)
183
- signal?.removeEventListener('abort', abortHandler)
393
+ if (!proc.stdout || !proc.stderr) {
394
+ fail(new Error('OpenCode subprocess did not provide output streams'))
395
+ return promise
396
+ }
184
397
 
185
- // If aborted, we already rejected
186
- if (aborted) return
398
+ const stdoutStream = proc.stdout
399
+ const stderrStream = proc.stderr
400
+
401
+ stderrStream.on('data', (data: Buffer) => {
402
+ if (settled) return
403
+ void (async () => {
404
+ try {
405
+ const text = data.toString()
406
+ stderrTracker.consume(text)
407
+ await writeChildChunk(stderrStream, stderrFile, data)
408
+ } catch (error) {
409
+ fail(error)
410
+ }
411
+ })()
412
+ })
187
413
 
188
- // Clean up prompt file
189
- await unlink(promptFile).catch(() => {})
414
+ stdoutStream.on('data', (data: Buffer) => {
415
+ if (settled) return
416
+ void (async () => {
417
+ try {
418
+ await writeChildChunk(stdoutStream, stdoutFile, data)
419
+ await writeConsoleChunk(data)
420
+ } catch (error) {
421
+ fail(error)
422
+ }
423
+ })()
424
+ })
190
425
 
191
- const stderr = stderrChunks.join('').trim()
426
+ timers.timeout =
427
+ timeoutSec > 0 ?
428
+ setTimeout(() => {
429
+ fail(new Error(`Timeout after ${timeoutSec}s`))
430
+ }, timeoutSec * 1000)
431
+ : undefined
432
+ signal?.addEventListener('abort', abortHandler, { once: true })
433
+
434
+ proc.on('close', (code) => {
435
+ void (async () => {
436
+ clearTimeout(timers.timeout)
437
+ clearTimeout(killTimer)
438
+ signal?.removeEventListener('abort', abortHandler)
439
+ await closeFiles()
440
+ if (settled) {
441
+ await cleanup()
442
+ return
443
+ }
192
444
 
445
+ stderrTracker.finish()
193
446
  if (code !== 0) {
194
- const lastLines = stderr.split('\n').slice(-5).join('\n')
447
+ const lastLines = stderrTracker.getLastLines(5)
195
448
  const detail = lastLines ? `\n${lastLines}` : ''
196
- reject(new Error(`OpenCode exited with code ${code}${detail}`))
449
+ fail(new Error(`OpenCode exited with code ${code}${detail}`))
197
450
  return
198
451
  }
199
452
 
200
- // Check for OpenCode errors that exit with code 0 but produce no output
201
- const knownErrors = [
202
- {
203
- pattern: /ProviderModelNotFoundError/,
204
- message: 'Provider/model not found',
205
- },
206
- { pattern: /ModelNotFoundError/, message: 'Model not found' },
207
- { pattern: /ProviderNotFoundError/, message: 'Provider not found' },
208
- { pattern: /API key.*not.*found/i, message: 'API key not configured' },
209
- {
210
- pattern: /authentication.*failed/i,
211
- message: 'Authentication failed',
212
- },
213
- ]
214
-
215
- for (const { pattern, message } of knownErrors) {
216
- if (pattern.test(stderr)) {
217
- // Extract the relevant error lines
218
- const errorLines = stderr
219
- .split('\n')
220
- .filter(
221
- (line) =>
222
- pattern.test(line) ||
223
- line.includes('Error') ||
224
- line.includes('error:'),
225
- )
226
- .slice(0, 5)
227
- .join('\n')
228
- reject(new Error(`OpenCode ${message}:\n${errorLines}`))
453
+ if (stderrTracker.hasKnownError) {
454
+ const knownError = knownOpenCodeErrors.find(
455
+ ({ pattern }) => pattern === stderrTracker.knownErrorPattern,
456
+ )
457
+ if (knownError) {
458
+ fail(
459
+ new Error(
460
+ `OpenCode ${knownError.message}:\n${stderrTracker.getDiagnosticLines()}`,
461
+ ),
462
+ )
229
463
  return
230
464
  }
231
465
  }
232
466
 
233
- // Export session to get structured response
234
- let response = stdoutChunks.join('').trim()
235
-
467
+ let response: string
468
+ try {
469
+ response = (await readFile(stdoutPath, 'utf8')).trim()
470
+ } catch (error) {
471
+ fail(error)
472
+ return
473
+ }
474
+ const newSessionId =
475
+ stderrTracker.newSessionId || (sessionId ? sessionId : '')
236
476
  if (newSessionId) {
237
- const exported = await exportSession(newSessionId, workdir)
238
- if (exported) {
239
- response = exported
240
- }
477
+ response =
478
+ (await exportSession(newSessionId, cwd, command, opencode?.env)) ??
479
+ response
241
480
  }
242
481
 
243
- // Check for empty response with errors in stderr (likely a silent failure)
244
- if (response.length === 0 && stderr.includes('Error')) {
245
- const lastLines = stderr.split('\n').slice(-10).join('\n')
246
- reject(
482
+ if (response.length === 0 && stderrTracker.hasError) {
483
+ const lastLines = stderrTracker.getLastLines(10)
484
+ fail(
247
485
  new Error(
248
486
  `OpenCode returned empty response with errors:\n${lastLines}`,
249
487
  ),
@@ -255,50 +493,52 @@ async function runOpencodeAgent(
255
493
  console.error(
256
494
  `<<< OpenCode done (${response.length} chars) [session:${sessionStr}]`,
257
495
  )
496
+ settled = true
497
+ await cleanup()
498
+ resolve({ text: response, sessionId: newSessionId })
499
+ })().catch(fail)
500
+ })
258
501
 
259
- resolve({
260
- text: response,
261
- sessionId: newSessionId,
262
- })
263
- })
264
-
265
- proc.on('error', async (err) => {
266
- if (timeout) clearTimeout(timeout)
267
- await unlink(promptFile).catch(() => {})
268
- reject(err)
269
- })
502
+ proc.on('error', (error) => {
503
+ fail(error)
270
504
  })
505
+
506
+ return promise
271
507
  }
272
508
 
273
- /** exportSession exports a session and extracts assistant text responses. */
274
509
  async function exportSession(
275
510
  sessionId: string,
276
- workdir?: string,
511
+ workdir: string,
512
+ command: string,
513
+ env: Record<string, string> | undefined,
277
514
  ): Promise<string | null> {
278
- const tmpPath = `${TMP_DIR}/opencode_export_${Date.now()}.json`
515
+ const exportFile = join(TMP_DIR, `opencode_export_${randomUUID()}.json`)
279
516
 
280
517
  try {
281
518
  await mkdir(TMP_DIR, { recursive: true })
282
- const opencodeCmd = getOpencodeCommand([
283
- 'session',
284
- 'export',
285
- sessionId,
286
- '--format',
287
- 'json',
288
- '--turn',
289
- '-1',
290
- '-o',
291
- tmpPath,
292
- ])
293
- const proc = Bun.spawn([opencodeCmd.cmd, ...opencodeCmd.args], {
294
- cwd: workdir ?? PROJECT_ROOT,
295
- stdout: 'pipe',
296
- stderr: 'pipe',
297
- })
519
+ const proc = Bun.spawn(
520
+ [
521
+ command,
522
+ 'session',
523
+ 'export',
524
+ sessionId,
525
+ '--format',
526
+ 'json',
527
+ '--turn',
528
+ '-1',
529
+ '-o',
530
+ exportFile,
531
+ ],
532
+ {
533
+ cwd: workdir,
534
+ stdout: 'pipe',
535
+ stderr: 'pipe',
536
+ env: { ...process.env, ...env },
537
+ },
538
+ )
298
539
 
299
540
  await proc.exited
300
-
301
- const file = Bun.file(tmpPath)
541
+ const file = Bun.file(exportFile)
302
542
  if (!(await file.exists())) return null
303
543
 
304
544
  const data = (await file.json()) as {
@@ -307,29 +547,37 @@ async function exportSession(
307
547
  parts?: Array<{ type?: string; text?: string }>
308
548
  }>
309
549
  }
310
- await Bun.write(tmpPath, '') // Clean up
550
+ void unlink(exportFile).catch(() => {})
311
551
 
312
- // Extract all assistant text parts from the exported turn.
313
- // The --turn -1 flag ensures we only get the last turn's messages.
314
- const messages = data.messages || []
315
552
  const textParts: string[] = []
316
-
317
- for (const msg of messages) {
318
- if (msg.info?.role === 'assistant') {
319
- for (const part of msg.parts || []) {
320
- if (part.type === 'text' && part.text) {
321
- textParts.push(part.text)
322
- }
553
+ for (const msg of data.messages ?? []) {
554
+ if (msg.info?.role !== 'assistant') continue
555
+ for (const part of msg.parts ?? []) {
556
+ if (part.type === 'text' && part.text) {
557
+ textParts.push(part.text)
323
558
  }
324
559
  }
325
560
  }
326
561
 
327
562
  return textParts.length > 0 ? textParts.join('\n') : null
328
563
  } catch {
564
+ void unlink(exportFile).catch(() => {})
329
565
  return null
330
566
  }
331
567
  }
332
568
 
569
+ const knownOpenCodeErrors: ReadonlyArray<{ pattern: RegExp; message: string }> =
570
+ [
571
+ {
572
+ pattern: /ProviderModelNotFoundError/,
573
+ message: 'Provider/model not found',
574
+ },
575
+ { pattern: /ModelNotFoundError/, message: 'Model not found' },
576
+ { pattern: /ProviderNotFoundError/, message: 'Provider not found' },
577
+ { pattern: /API key.*not.*found/i, message: 'API key not configured' },
578
+ { pattern: /authentication.*failed/i, message: 'Authentication failed' },
579
+ ]
580
+
333
581
  /** logStep logs a step header for workflow progress. */
334
582
  export function logStep(step: number, title: string, detail = ''): void {
335
583
  const detailStr = detail ? ` (${detail})` : ''