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/README.md +19 -22
- package/example/correction.ts +1 -2
- package/example/index.ts +6 -7
- package/package.json +4 -3
- package/src/agent.ts +436 -188
- package/src/claude-code.ts +19 -24
- package/src/codex.ts +28 -8
- package/src/errors.ts +19 -0
- package/src/index.ts +3 -3
- package/src/pipeline.ts +7 -7
- package/src/predict.ts +6 -8
- package/src/testing.ts +7 -5
- package/src/types.ts +36 -41
package/src/agent.ts
CHANGED
|
@@ -1,41 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ocpipe agent integration.
|
|
3
3
|
*
|
|
4
|
-
* Dispatches to
|
|
5
|
-
* configuration.
|
|
4
|
+
* Dispatches prompts to configured coding-agent backends.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
|
-
import { spawn } from 'child_process'
|
|
9
|
-
import {
|
|
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
|
-
/**
|
|
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 ?? '
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
const
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 =
|
|
447
|
+
const lastLines = stderrTracker.getLastLines(5)
|
|
195
448
|
const detail = lastLines ? `\n${lastLines}` : ''
|
|
196
|
-
|
|
449
|
+
fail(new Error(`OpenCode exited with code ${code}${detail}`))
|
|
197
450
|
return
|
|
198
451
|
}
|
|
199
452
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
response
|
|
240
|
-
}
|
|
477
|
+
response =
|
|
478
|
+
(await exportSession(newSessionId, cwd, command, opencode?.env)) ??
|
|
479
|
+
response
|
|
241
480
|
}
|
|
242
481
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
511
|
+
workdir: string,
|
|
512
|
+
command: string,
|
|
513
|
+
env: Record<string, string> | undefined,
|
|
277
514
|
): Promise<string | null> {
|
|
278
|
-
const
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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})` : ''
|