ocpipe 0.6.11 → 0.6.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocpipe",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "SDK for LLM pipelines with OpenCode, OMP, Codex, Claude Code, Pi, and Zod",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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
- let newSessionId = sessionId || ''
115
- const stdoutChunks: string[] = []
116
- const stderrChunks: string[] = []
117
- let aborted = false
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 abortHandler = () => {
120
- if (aborted) return
121
- aborted = true
122
- console.error(`\n[abort] Killing OpenCode subprocess...`)
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
- proc.stderr.on('data', (data: Buffer) => {
133
- const text = data.toString()
134
- stderrChunks.push(text)
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
- for (const line of text.split('\n')) {
137
- if (line.startsWith('[session:')) {
138
- newSessionId = line.trim().slice(9, -1)
139
- continue
140
- }
141
- if (line.includes('baseline-browser-mapping')) continue
142
- if (line.startsWith('$ bun run')) continue
143
- if (line.trim()) {
144
- process.stderr.write(line + '\n')
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
- proc.stdout.on('data', (data: Buffer) => {
150
- const text = data.toString()
151
- stdoutChunks.push(text)
152
- process.stderr.write(text)
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
- const timeout =
426
+ timers.timeout =
156
427
  timeoutSec > 0 ?
157
428
  setTimeout(() => {
158
- proc.kill()
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', async (code) => {
165
- clearTimeout(timeout)
166
- signal?.removeEventListener('abort', abortHandler)
167
- if (aborted) return
168
-
169
- void unlink(promptFile).catch(() => {})
170
- const stderr = stderrChunks.join('').trim()
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
- if (code !== 0) {
173
- const lastLines = stderr.split('\n').slice(-5).join('\n')
174
- const detail = lastLines ? `\n${lastLines}` : ''
175
- reject(new Error(`OpenCode exited with code ${code}${detail}`))
176
- return
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
- for (const { pattern, message } of knownOpenCodeErrors) {
180
- if (pattern.test(stderr)) {
181
- const errorLines = stderr
182
- .split('\n')
183
- .filter(
184
- (line) =>
185
- pattern.test(line) ||
186
- line.includes('Error') ||
187
- line.includes('error:'),
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
- .slice(0, 5)
190
- .join('\n')
191
- reject(new Error(`OpenCode ${message}:\n${errorLines}`))
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
- let response = stdoutChunks.join('').trim()
197
- if (newSessionId) {
198
- response =
199
- (await exportSession(newSessionId, cwd, command, opencode?.env)) ??
200
- response
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
- if (response.length === 0 && stderr.includes('Error')) {
204
- const lastLines = stderr.split('\n').slice(-10).join('\n')
205
- reject(
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
- return
211
- }
212
-
213
- const sessionStr = newSessionId || 'none'
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', (err) => {
221
- clearTimeout(timeout)
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
- for (;;) {
29
- const idx = this.buf.indexOf('\n')
30
+ let cursor = 0
31
+ while (cursor < text.length) {
32
+ const idx = text.indexOf('\n', cursor)
30
33
  if (idx < 0) {
31
- return out
34
+ this.append(text.slice(cursor))
35
+ break
32
36
  }
33
- const line = this.buf.slice(0, idx + 1)
34
- this.buf = this.buf.slice(idx + 1)
35
- out += this.filterLine(line)
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(command: CodexRunSummary['commands'][number]): boolean {
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 {
package/src/omp.ts CHANGED
@@ -176,6 +176,9 @@ function buildOmpArgs(
176
176
  if (thinking) {
177
177
  args.push('--thinking', thinking)
178
178
  }
179
+ if (omp?.serviceTier) {
180
+ args.push('--service-tier', omp.serviceTier)
181
+ }
179
182
  args.push(...(omp?.extraArgs ?? []))
180
183
  if (prompt) {
181
184
  args.push('--', prompt)
package/src/types.ts CHANGED
@@ -148,6 +148,8 @@ export interface OmpOptions {
148
148
  approvalMode?: string
149
149
  /** Thinking effort passed with `--thinking` (default: `high`). */
150
150
  thinking?: string
151
+ /** OpenAI service tier passed with `--service-tier` (for example `priority`); omitted when unset. */
152
+ serviceTier?: string
151
153
  /** Run Oh My Pi as a continuing headless goal. */
152
154
  goalMode?: boolean
153
155
  /** Goal objective; setting this also enables goal mode. */