ocpipe 0.6.11 → 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/package.json +1 -1
- package/src/agent.ts +371 -92
- package/src/codex.ts +28 -8
- package/src/errors.ts +19 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* Dispatches prompts to configured coding-agent backends.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { spawn } from 'child_process'
|
|
7
|
+
import { spawn, type ChildProcess } from 'child_process'
|
|
8
|
+
import { createWriteStream, type WriteStream } from 'fs'
|
|
8
9
|
import { randomUUID } from 'crypto'
|
|
9
|
-
import { mkdir, unlink, writeFile } from 'fs/promises'
|
|
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'
|
|
12
14
|
import type { RunAgentOptions, RunAgentResult } from './types.js'
|
|
13
15
|
import { runOmpAgent } from './omp.js'
|
|
@@ -44,6 +46,211 @@ export async function runAgent(
|
|
|
44
46
|
const unreachable: never = backend
|
|
45
47
|
throw new Error(`Unsupported backend: ${unreachable}`)
|
|
46
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
|
+
}
|
|
253
|
+
}
|
|
47
254
|
|
|
48
255
|
async function runOpencodeAgent(
|
|
49
256
|
options: RunAgentOptions,
|
|
@@ -76,7 +283,11 @@ async function runOpencodeAgent(
|
|
|
76
283
|
|
|
77
284
|
const promptDir = opencode?.promptDir ?? TMP_DIR
|
|
78
285
|
await mkdir(promptDir, { recursive: true })
|
|
286
|
+
await mkdir(TMP_DIR, { recursive: true })
|
|
79
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`)
|
|
80
291
|
await writeFile(promptFile, prompt)
|
|
81
292
|
|
|
82
293
|
const args = [
|
|
@@ -104,124 +315,192 @@ async function runOpencodeAgent(
|
|
|
104
315
|
|
|
105
316
|
const command = opencode?.command ?? 'opencode'
|
|
106
317
|
console.error(`[DEBUG] Running: ${command} ${args.join(' ')}`)
|
|
107
|
-
const proc = spawn(command, args, {
|
|
108
|
-
cwd,
|
|
109
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
-
env: { ...process.env, ...opencode?.env },
|
|
111
|
-
})
|
|
112
318
|
|
|
113
319
|
const { promise, resolve, reject } = Promise.withResolvers<RunAgentResult>()
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
118
338
|
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
})()
|
|
349
|
+
}
|
|
350
|
+
return cleanupPromise
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const stopProcess = () => {
|
|
354
|
+
if (!proc || proc.killed) return
|
|
123
355
|
proc.kill('SIGTERM')
|
|
124
|
-
setTimeout(() => {
|
|
125
|
-
if (!proc.killed) proc.kill('SIGKILL')
|
|
356
|
+
killTimer = setTimeout(() => {
|
|
357
|
+
if (proc && !proc.killed) proc.kill('SIGKILL')
|
|
126
358
|
}, 1000)
|
|
127
|
-
void unlink(promptFile).catch(() => {})
|
|
128
|
-
reject(new Error('Request aborted'))
|
|
129
359
|
}
|
|
130
|
-
signal?.addEventListener('abort', abortHandler, { once: true })
|
|
131
360
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
|
135
372
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 },
|
|
387
|
+
})
|
|
388
|
+
} catch (error) {
|
|
389
|
+
fail(error)
|
|
390
|
+
return promise
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!proc.stdout || !proc.stderr) {
|
|
394
|
+
fail(new Error('OpenCode subprocess did not provide output streams'))
|
|
395
|
+
return promise
|
|
396
|
+
}
|
|
397
|
+
|
|
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)
|
|
145
410
|
}
|
|
146
|
-
}
|
|
411
|
+
})()
|
|
147
412
|
})
|
|
148
413
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
})()
|
|
153
424
|
})
|
|
154
425
|
|
|
155
|
-
|
|
426
|
+
timers.timeout =
|
|
156
427
|
timeoutSec > 0 ?
|
|
157
428
|
setTimeout(() => {
|
|
158
|
-
|
|
159
|
-
void unlink(promptFile).catch(() => {})
|
|
160
|
-
reject(new Error(`Timeout after ${timeoutSec}s`))
|
|
429
|
+
fail(new Error(`Timeout after ${timeoutSec}s`))
|
|
161
430
|
}, timeoutSec * 1000)
|
|
162
431
|
: undefined
|
|
432
|
+
signal?.addEventListener('abort', abortHandler, { once: true })
|
|
163
433
|
|
|
164
|
-
proc.on('close',
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
}
|
|
171
444
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
445
|
+
stderrTracker.finish()
|
|
446
|
+
if (code !== 0) {
|
|
447
|
+
const lastLines = stderrTracker.getLastLines(5)
|
|
448
|
+
const detail = lastLines ? `\n${lastLines}` : ''
|
|
449
|
+
fail(new Error(`OpenCode exited with code ${code}${detail}`))
|
|
450
|
+
return
|
|
451
|
+
}
|
|
178
452
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
),
|
|
188
462
|
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let response: string
|
|
468
|
+
try {
|
|
469
|
+
response = (await readFile(stdoutPath, 'utf8')).trim()
|
|
470
|
+
} catch (error) {
|
|
471
|
+
fail(error)
|
|
192
472
|
return
|
|
193
473
|
}
|
|
194
|
-
|
|
474
|
+
const newSessionId =
|
|
475
|
+
stderrTracker.newSessionId || (sessionId ? sessionId : '')
|
|
476
|
+
if (newSessionId) {
|
|
477
|
+
response =
|
|
478
|
+
(await exportSession(newSessionId, cwd, command, opencode?.env)) ??
|
|
479
|
+
response
|
|
480
|
+
}
|
|
195
481
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
482
|
+
if (response.length === 0 && stderrTracker.hasError) {
|
|
483
|
+
const lastLines = stderrTracker.getLastLines(10)
|
|
484
|
+
fail(
|
|
485
|
+
new Error(
|
|
486
|
+
`OpenCode returned empty response with errors:\n${lastLines}`,
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
return
|
|
490
|
+
}
|
|
202
491
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
new Error(
|
|
207
|
-
`OpenCode returned empty response with errors:\n${lastLines}`,
|
|
208
|
-
),
|
|
492
|
+
const sessionStr = newSessionId || 'none'
|
|
493
|
+
console.error(
|
|
494
|
+
`<<< OpenCode done (${response.length} chars) [session:${sessionStr}]`,
|
|
209
495
|
)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
console.error(
|
|
215
|
-
`<<< OpenCode done (${response.length} chars) [session:${sessionStr}]`,
|
|
216
|
-
)
|
|
217
|
-
resolve({ text: response, sessionId: newSessionId })
|
|
496
|
+
settled = true
|
|
497
|
+
await cleanup()
|
|
498
|
+
resolve({ text: response, sessionId: newSessionId })
|
|
499
|
+
})().catch(fail)
|
|
218
500
|
})
|
|
219
501
|
|
|
220
|
-
proc.on('error', (
|
|
221
|
-
|
|
222
|
-
signal?.removeEventListener('abort', abortHandler)
|
|
223
|
-
void unlink(promptFile).catch(() => {})
|
|
224
|
-
reject(err)
|
|
502
|
+
proc.on('error', (error) => {
|
|
503
|
+
fail(error)
|
|
225
504
|
})
|
|
226
505
|
|
|
227
506
|
return promise
|
package/src/codex.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type RunResult,
|
|
11
11
|
type ThreadOptions,
|
|
12
12
|
} from '@openai/codex-sdk'
|
|
13
|
+
import { OutputLimitError } from './errors.js'
|
|
13
14
|
import { PROJECT_ROOT } from './paths.js'
|
|
14
15
|
import type {
|
|
15
16
|
CodexOptions,
|
|
@@ -18,22 +19,29 @@ import type {
|
|
|
18
19
|
RunAgentResult,
|
|
19
20
|
} from './types.js'
|
|
20
21
|
|
|
22
|
+
const MAX_CODEX_LOG_LINE_BYTES = 1024 * 1024
|
|
23
|
+
|
|
21
24
|
class CodexLogFilter {
|
|
22
25
|
private buf = ''
|
|
23
26
|
private suppressHtml = false
|
|
24
27
|
|
|
25
28
|
write(text: string): string {
|
|
26
|
-
this.buf += text
|
|
27
29
|
let out = ''
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
let cursor = 0
|
|
31
|
+
while (cursor < text.length) {
|
|
32
|
+
const idx = text.indexOf('\n', cursor)
|
|
30
33
|
if (idx < 0) {
|
|
31
|
-
|
|
34
|
+
this.append(text.slice(cursor))
|
|
35
|
+
break
|
|
32
36
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
|
|
38
|
+
const line = text.slice(cursor, idx + 1)
|
|
39
|
+
cursor = idx + 1
|
|
40
|
+
this.append(line)
|
|
41
|
+
out += this.filterLine(this.buf)
|
|
42
|
+
this.buf = ''
|
|
36
43
|
}
|
|
44
|
+
return out
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
flush(): string {
|
|
@@ -42,6 +50,16 @@ class CodexLogFilter {
|
|
|
42
50
|
return this.filterLine(line)
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
private append(text: string): void {
|
|
54
|
+
if (
|
|
55
|
+
Buffer.byteLength(this.buf, 'utf8') + Buffer.byteLength(text, 'utf8') >
|
|
56
|
+
MAX_CODEX_LOG_LINE_BYTES
|
|
57
|
+
) {
|
|
58
|
+
throw new OutputLimitError('Codex log line', MAX_CODEX_LOG_LINE_BYTES)
|
|
59
|
+
}
|
|
60
|
+
this.buf += text
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
private filterLine(line: string): string {
|
|
46
64
|
if (this.suppressHtml) {
|
|
47
65
|
if (line.includes('</html>')) {
|
|
@@ -239,7 +257,9 @@ export function formatCodexRunSummary(summary: CodexRunSummary): string {
|
|
|
239
257
|
return lines.join('\n')
|
|
240
258
|
}
|
|
241
259
|
|
|
242
|
-
function isFailedCommand(
|
|
260
|
+
function isFailedCommand(
|
|
261
|
+
command: CodexRunSummary['commands'][number],
|
|
262
|
+
): boolean {
|
|
243
263
|
return (
|
|
244
264
|
command.status === 'failed' ||
|
|
245
265
|
(command.exitCode !== null && command.exitCode !== 0)
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** ocpipe typed runtime errors. */
|
|
2
|
+
|
|
3
|
+
function formatBytes(bytes: number): string {
|
|
4
|
+
if (bytes >= 1024 * 1024 * 1024) return `${bytes / (1024 * 1024 * 1024)} GiB`
|
|
5
|
+
if (bytes >= 1024 * 1024) return `${bytes / (1024 * 1024)} MiB`
|
|
6
|
+
if (bytes >= 1024) return `${bytes / 1024} KiB`
|
|
7
|
+
return `${bytes} bytes`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** OutputLimitError reports a bounded output buffer or file limit breach. */
|
|
11
|
+
export class OutputLimitError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
public readonly source: string,
|
|
14
|
+
public readonly limitBytes: number,
|
|
15
|
+
) {
|
|
16
|
+
super(`${source} exceeded ${formatBytes(limitBytes)} limit`)
|
|
17
|
+
this.name = 'OutputLimitError'
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ export { createSessionId, createBaseState, extendBaseState } from './state.js'
|
|
|
50
50
|
// Agent integration
|
|
51
51
|
export { runAgent, logStep } from './agent.js'
|
|
52
52
|
export { buildCodexRunSummary, formatCodexRunSummary } from './codex.js'
|
|
53
|
+
export { OutputLimitError } from './errors.js'
|
|
53
54
|
|
|
54
55
|
// Response parsing
|
|
55
56
|
export {
|