ocpipe 0.6.9 → 0.6.11

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,29 +1,25 @@
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
7
  import { spawn } from 'child_process'
9
- import { mkdir, writeFile, unlink } from 'fs/promises'
8
+ import { randomUUID } from 'crypto'
9
+ import { mkdir, unlink, writeFile } from 'fs/promises'
10
10
  import { join } from 'path'
11
11
  import { PROJECT_ROOT, TMP_DIR } from './paths.js'
12
12
  import type { RunAgentOptions, RunAgentResult } from './types.js'
13
+ import { runOmpAgent } from './omp.js'
14
+ import { runPiAgent } from './pi.js'
13
15
 
14
- /** Get command and args to invoke opencode from PATH */
15
- function getOpencodeCommand(args: string[]): { cmd: string; args: string[] } {
16
- return { cmd: 'opencode', args }
17
- }
18
-
19
- /** runAgent dispatches to the appropriate backend based on model configuration. */
16
+ /** runAgent dispatches to the selected backend. */
20
17
  export async function runAgent(
21
18
  options: RunAgentOptions,
22
19
  ): Promise<RunAgentResult> {
23
- const backend = options.model.backend ?? 'opencode'
20
+ const backend = options.model.backend ?? 'omp'
24
21
 
25
22
  if (backend === 'claude-code') {
26
- // Dynamic import to avoid requiring @anthropic-ai/claude-agent-sdk when using opencode
27
23
  const { runClaudeCodeAgent } = await import('./claude-code.js')
28
24
  return runClaudeCodeAgent(options)
29
25
  }
@@ -33,57 +29,60 @@ export async function runAgent(
33
29
  return runCodexAgent(options)
34
30
  }
35
31
 
32
+ if (backend === 'opencode') {
33
+ return runOpencodeAgent(options)
34
+ }
35
+
36
36
  if (backend === 'pi') {
37
- const { runPiAgent } = await import('./pi.js')
38
37
  return runPiAgent(options)
39
38
  }
40
39
 
41
- return runOpencodeAgent(options)
40
+ if (backend === 'omp') {
41
+ return runOmpAgent(options)
42
+ }
43
+
44
+ const unreachable: never = backend
45
+ throw new Error(`Unsupported backend: ${unreachable}`)
42
46
  }
43
47
 
44
- /** runOpencodeAgent executes an OpenCode agent with a prompt, streaming output in real-time. */
45
48
  async function runOpencodeAgent(
46
49
  options: RunAgentOptions,
47
50
  ): Promise<RunAgentResult> {
48
51
  const {
49
52
  prompt,
50
- agent,
51
53
  model,
52
54
  sessionId,
53
55
  timeoutSec = 3600,
54
56
  workdir,
57
+ opencode,
55
58
  signal,
56
59
  } = options
57
60
 
58
61
  if (!model.providerID) {
59
62
  throw new Error('providerID is required for OpenCode backend')
60
63
  }
61
- const modelStr = `${model.providerID}/${model.modelID}`
62
- const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
63
- const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
64
64
 
65
- console.error(
66
- `\n>>> OpenCode [${agent}] [${modelStr}] ${sessionInfo}: ${promptPreview}...`,
67
- )
68
-
69
- // Check if already aborted
70
65
  if (signal?.aborted) {
71
66
  throw new Error('Request aborted')
72
67
  }
73
68
 
74
- // Write prompt to .opencode/prompts/ within the working directory
75
69
  const cwd = workdir ?? PROJECT_ROOT
76
- const promptsDir = join(cwd, '.opencode', 'prompts')
77
- await mkdir(promptsDir, { recursive: true })
78
- const promptFile = join(promptsDir, `prompt_${Date.now()}.txt`)
70
+ const modelStr = `${model.providerID}/${model.modelID}`
71
+ const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
72
+ const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
73
+ console.error(
74
+ `\n>>> OpenCode [${modelStr}] ${sessionInfo}: ${promptPreview}...`,
75
+ )
76
+
77
+ const promptDir = opencode?.promptDir ?? TMP_DIR
78
+ await mkdir(promptDir, { recursive: true })
79
+ const promptFile = join(promptDir, `ocpipe_prompt_${randomUUID()}.txt`)
79
80
  await writeFile(promptFile, prompt)
80
81
 
81
82
  const args = [
82
83
  'run',
83
84
  '--format',
84
85
  'default',
85
- '--agent',
86
- agent,
87
86
  '--model',
88
87
  modelStr,
89
88
  '--prompt-file',
@@ -93,11 +92,9 @@ async function runOpencodeAgent(
93
92
  if (sessionId) {
94
93
  args.push('--session', sessionId)
95
94
  }
96
-
97
95
  if (model.variant) {
98
96
  args.push('--model-variant', model.variant)
99
97
  }
100
-
101
98
  if (model.variantThinkingBudget !== undefined) {
102
99
  args.push(
103
100
  '--model-variant-thinking-budget',
@@ -105,194 +102,164 @@ async function runOpencodeAgent(
105
102
  )
106
103
  }
107
104
 
108
- return new Promise((resolve, reject) => {
109
- const opencodeCmd = getOpencodeCommand(args)
110
- console.error(
111
- `[DEBUG] Running: ${opencodeCmd.cmd} ${opencodeCmd.args.join(' ')}`,
112
- )
113
- const proc = spawn(opencodeCmd.cmd, opencodeCmd.args, {
114
- cwd,
115
- stdio: ['ignore', 'pipe', 'pipe'],
116
- })
117
-
118
- let newSessionId = sessionId || ''
119
- const stdoutChunks: string[] = []
120
- const stderrChunks: string[] = []
121
- let aborted = false
122
-
123
- // Handle abort signal - kill subprocess when aborted
124
- const abortHandler = async () => {
125
- if (aborted) return
126
- aborted = true
127
- console.error(`\n[abort] Killing OpenCode subprocess...`)
128
- proc.kill('SIGTERM')
129
- // Give it a moment to clean up, then force kill
130
- setTimeout(() => {
131
- if (!proc.killed) proc.kill('SIGKILL')
132
- }, 1000)
133
- await unlink(promptFile).catch(() => {})
134
- reject(new Error('Request aborted'))
135
- }
136
- signal?.addEventListener('abort', abortHandler, { once: true })
137
-
138
- // Stream stderr in real-time (OpenCode progress output)
139
- proc.stderr.on('data', (data: Buffer) => {
140
- const text = data.toString()
141
- stderrChunks.push(text)
142
-
143
- // Parse session ID from output
144
- for (const line of text.split('\n')) {
145
- if (line.startsWith('[session:')) {
146
- newSessionId = line.trim().slice(9, -1)
147
- continue
148
- }
149
- // Filter noise
150
- if (line.includes('baseline-browser-mapping')) continue
151
- if (line.startsWith('$ bun run')) continue
152
- if (line.trim()) {
153
- process.stderr.write(line + '\n')
154
- }
155
- }
156
- })
157
-
158
- // Collect stdout
159
- proc.stdout.on('data', (data: Buffer) => {
160
- const text = data.toString()
161
- stdoutChunks.push(text)
162
- process.stderr.write(text)
163
- })
164
-
165
- // Timeout handling (0 = no timeout)
166
- const timeout =
167
- timeoutSec > 0 ?
168
- setTimeout(async () => {
169
- proc.kill()
170
- await unlink(promptFile).catch(() => {})
171
- reject(new Error(`Timeout after ${timeoutSec}s`))
172
- }, timeoutSec * 1000)
173
- : null
174
-
175
- proc.on('close', async (code) => {
176
- if (timeout) clearTimeout(timeout)
177
- signal?.removeEventListener('abort', abortHandler)
178
-
179
- // If aborted, we already rejected
180
- if (aborted) return
181
-
182
- // Clean up prompt file
183
- await unlink(promptFile).catch(() => {})
184
-
185
- const stderr = stderrChunks.join('').trim()
186
-
187
- if (code !== 0) {
188
- const lastLines = stderr.split('\n').slice(-5).join('\n')
189
- const detail = lastLines ? `\n${lastLines}` : ''
190
- reject(new Error(`OpenCode exited with code ${code}${detail}`))
191
- return
192
- }
105
+ const command = opencode?.command ?? 'opencode'
106
+ 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
+ })
193
112
 
194
- // Check for OpenCode errors that exit with code 0 but produce no output
195
- const knownErrors = [
196
- {
197
- pattern: /ProviderModelNotFoundError/,
198
- message: 'Provider/model not found',
199
- },
200
- { pattern: /ModelNotFoundError/, message: 'Model not found' },
201
- { pattern: /ProviderNotFoundError/, message: 'Provider not found' },
202
- { pattern: /API key.*not.*found/i, message: 'API key not configured' },
203
- {
204
- pattern: /authentication.*failed/i,
205
- message: 'Authentication failed',
206
- },
207
- ]
208
-
209
- for (const { pattern, message } of knownErrors) {
210
- if (pattern.test(stderr)) {
211
- // Extract the relevant error lines
212
- const errorLines = stderr
213
- .split('\n')
214
- .filter(
215
- (line) =>
216
- pattern.test(line) ||
217
- line.includes('Error') ||
218
- line.includes('error:'),
219
- )
220
- .slice(0, 5)
221
- .join('\n')
222
- reject(new Error(`OpenCode ${message}:\n${errorLines}`))
223
- return
224
- }
225
- }
113
+ const { promise, resolve, reject } = Promise.withResolvers<RunAgentResult>()
114
+ let newSessionId = sessionId || ''
115
+ const stdoutChunks: string[] = []
116
+ const stderrChunks: string[] = []
117
+ let aborted = false
118
+
119
+ const abortHandler = () => {
120
+ if (aborted) return
121
+ aborted = true
122
+ console.error(`\n[abort] Killing OpenCode subprocess...`)
123
+ proc.kill('SIGTERM')
124
+ setTimeout(() => {
125
+ if (!proc.killed) proc.kill('SIGKILL')
126
+ }, 1000)
127
+ void unlink(promptFile).catch(() => {})
128
+ reject(new Error('Request aborted'))
129
+ }
130
+ signal?.addEventListener('abort', abortHandler, { once: true })
226
131
 
227
- // Export session to get structured response
228
- let response = stdoutChunks.join('').trim()
132
+ proc.stderr.on('data', (data: Buffer) => {
133
+ const text = data.toString()
134
+ stderrChunks.push(text)
229
135
 
230
- if (newSessionId) {
231
- const exported = await exportSession(newSessionId, workdir)
232
- if (exported) {
233
- response = exported
234
- }
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')
235
145
  }
146
+ }
147
+ })
148
+
149
+ proc.stdout.on('data', (data: Buffer) => {
150
+ const text = data.toString()
151
+ stdoutChunks.push(text)
152
+ process.stderr.write(text)
153
+ })
154
+
155
+ const timeout =
156
+ timeoutSec > 0 ?
157
+ setTimeout(() => {
158
+ proc.kill()
159
+ void unlink(promptFile).catch(() => {})
160
+ reject(new Error(`Timeout after ${timeoutSec}s`))
161
+ }, timeoutSec * 1000)
162
+ : undefined
163
+
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()
171
+
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
+ }
236
178
 
237
- // Check for empty response with errors in stderr (likely a silent failure)
238
- if (response.length === 0 && stderr.includes('Error')) {
239
- const lastLines = stderr.split('\n').slice(-10).join('\n')
240
- reject(
241
- new Error(
242
- `OpenCode returned empty response with errors:\n${lastLines}`,
243
- ),
244
- )
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:'),
188
+ )
189
+ .slice(0, 5)
190
+ .join('\n')
191
+ reject(new Error(`OpenCode ${message}:\n${errorLines}`))
245
192
  return
246
193
  }
194
+ }
195
+
196
+ let response = stdoutChunks.join('').trim()
197
+ if (newSessionId) {
198
+ response =
199
+ (await exportSession(newSessionId, cwd, command, opencode?.env)) ??
200
+ response
201
+ }
247
202
 
248
- const sessionStr = newSessionId || 'none'
249
- console.error(
250
- `<<< OpenCode done (${response.length} chars) [session:${sessionStr}]`,
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
+ ),
251
209
  )
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 })
218
+ })
252
219
 
253
- resolve({
254
- text: response,
255
- sessionId: newSessionId,
256
- })
257
- })
258
-
259
- proc.on('error', async (err) => {
260
- if (timeout) clearTimeout(timeout)
261
- await unlink(promptFile).catch(() => {})
262
- reject(err)
263
- })
220
+ proc.on('error', (err) => {
221
+ clearTimeout(timeout)
222
+ signal?.removeEventListener('abort', abortHandler)
223
+ void unlink(promptFile).catch(() => {})
224
+ reject(err)
264
225
  })
226
+
227
+ return promise
265
228
  }
266
229
 
267
- /** exportSession exports a session and extracts assistant text responses. */
268
230
  async function exportSession(
269
231
  sessionId: string,
270
- workdir?: string,
232
+ workdir: string,
233
+ command: string,
234
+ env: Record<string, string> | undefined,
271
235
  ): Promise<string | null> {
272
- const tmpPath = `${TMP_DIR}/opencode_export_${Date.now()}.json`
236
+ const exportFile = join(TMP_DIR, `opencode_export_${randomUUID()}.json`)
273
237
 
274
238
  try {
275
239
  await mkdir(TMP_DIR, { recursive: true })
276
- const opencodeCmd = getOpencodeCommand([
277
- 'session',
278
- 'export',
279
- sessionId,
280
- '--format',
281
- 'json',
282
- '--turn',
283
- '-1',
284
- '-o',
285
- tmpPath,
286
- ])
287
- const proc = Bun.spawn([opencodeCmd.cmd, ...opencodeCmd.args], {
288
- cwd: workdir ?? PROJECT_ROOT,
289
- stdout: 'pipe',
290
- stderr: 'pipe',
291
- })
240
+ const proc = Bun.spawn(
241
+ [
242
+ command,
243
+ 'session',
244
+ 'export',
245
+ sessionId,
246
+ '--format',
247
+ 'json',
248
+ '--turn',
249
+ '-1',
250
+ '-o',
251
+ exportFile,
252
+ ],
253
+ {
254
+ cwd: workdir,
255
+ stdout: 'pipe',
256
+ stderr: 'pipe',
257
+ env: { ...process.env, ...env },
258
+ },
259
+ )
292
260
 
293
261
  await proc.exited
294
-
295
- const file = Bun.file(tmpPath)
262
+ const file = Bun.file(exportFile)
296
263
  if (!(await file.exists())) return null
297
264
 
298
265
  const data = (await file.json()) as {
@@ -301,29 +268,37 @@ async function exportSession(
301
268
  parts?: Array<{ type?: string; text?: string }>
302
269
  }>
303
270
  }
304
- await Bun.write(tmpPath, '') // Clean up
271
+ void unlink(exportFile).catch(() => {})
305
272
 
306
- // Extract all assistant text parts from the exported turn.
307
- // The --turn -1 flag ensures we only get the last turn's messages.
308
- const messages = data.messages || []
309
273
  const textParts: string[] = []
310
-
311
- for (const msg of messages) {
312
- if (msg.info?.role === 'assistant') {
313
- for (const part of msg.parts || []) {
314
- if (part.type === 'text' && part.text) {
315
- textParts.push(part.text)
316
- }
274
+ for (const msg of data.messages ?? []) {
275
+ if (msg.info?.role !== 'assistant') continue
276
+ for (const part of msg.parts ?? []) {
277
+ if (part.type === 'text' && part.text) {
278
+ textParts.push(part.text)
317
279
  }
318
280
  }
319
281
  }
320
282
 
321
283
  return textParts.length > 0 ? textParts.join('\n') : null
322
284
  } catch {
285
+ void unlink(exportFile).catch(() => {})
323
286
  return null
324
287
  }
325
288
  }
326
289
 
290
+ const knownOpenCodeErrors: ReadonlyArray<{ pattern: RegExp; message: string }> =
291
+ [
292
+ {
293
+ pattern: /ProviderModelNotFoundError/,
294
+ message: 'Provider/model not found',
295
+ },
296
+ { pattern: /ModelNotFoundError/, message: 'Model not found' },
297
+ { pattern: /ProviderNotFoundError/, message: 'Provider not found' },
298
+ { pattern: /API key.*not.*found/i, message: 'API key not configured' },
299
+ { pattern: /authentication.*failed/i, message: 'Authentication failed' },
300
+ ]
301
+
327
302
  /** logStep logs a step header for workflow progress. */
328
303
  export function logStep(step: number, title: string, detail = ''): void {
329
304
  const detailStr = detail ? ` (${detail})` : ''
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { execSync } from 'child_process'
14
- import { existsSync, readFileSync } from 'fs'
14
+ import { existsSync } from 'fs'
15
15
  import { join } from 'path'
16
16
  import { homedir } from 'os'
17
17
  import {
@@ -66,9 +66,14 @@ function resolveClaudeCodePath(explicit?: string): string | undefined {
66
66
  if (explicit) return explicit
67
67
 
68
68
  try {
69
- const found = execSync('which claude', { encoding: 'utf8', timeout: 3000 }).trim()
69
+ const found = execSync('which claude', {
70
+ encoding: 'utf8',
71
+ timeout: 3000,
72
+ }).trim()
70
73
  if (found) return found
71
- } catch { /* which not found or timed out — fall through to defaults */ }
74
+ } catch {
75
+ /* which not found or timed out — fall through to defaults */
76
+ }
72
77
 
73
78
  const defaults = [
74
79
  join(homedir(), '.local', 'bin', 'claude'),
@@ -90,21 +95,6 @@ function normalizeModelId(modelId: string): string {
90
95
  return modelId
91
96
  }
92
97
 
93
- /** loadAgentDefinition loads an OpenCode agent definition file and extracts the
94
- * markdown body (stripping the YAML frontmatter) for use as a system prompt. */
95
- function loadAgentDefinition(agent: string, workdir?: string): string | undefined {
96
- if (!agent || !workdir) return undefined
97
-
98
- const agentPath = join(workdir, '.opencode', 'agents', `${agent}.md`)
99
- if (!existsSync(agentPath)) return undefined
100
-
101
- const content = readFileSync(agentPath, 'utf8')
102
-
103
- // Strip YAML frontmatter (--- delimited block at the start).
104
- const stripped = content.replace(/^---\n[\s\S]*?\n---\n*/, '')
105
- return stripped.trim() || undefined
106
- }
107
-
108
98
  /** Extract text from assistant messages. */
109
99
  function getAssistantText(msg: SDKMessage): string | null {
110
100
  if (msg.type !== 'assistant') return null
@@ -147,7 +137,6 @@ export async function runClaudeCodeAgent(
147
137
  ): Promise<RunAgentResult> {
148
138
  const {
149
139
  prompt,
150
- agent,
151
140
  model,
152
141
  sessionId,
153
142
  timeoutSec = 3600,
@@ -169,8 +158,8 @@ export async function runClaudeCodeAgent(
169
158
  // Build query options with configurable permission mode (default: acceptEdits)
170
159
  const permissionMode = claudeCode?.permissionMode ?? 'acceptEdits'
171
160
 
172
- // Resolve system prompt: explicit option > agent definition file > none
173
- const systemPrompt = claudeCode?.systemPrompt ?? loadAgentDefinition(agent, workdir)
161
+ // Resolve system prompt from explicit configuration only.
162
+ const systemPrompt = claudeCode?.systemPrompt
174
163
 
175
164
  // Bridge external abort signal to an AbortController for the SDK
176
165
  const abortController = new AbortController()
@@ -217,16 +206,22 @@ export async function runClaudeCodeAgent(
217
206
  }),
218
207
  // Resolve executable path: explicit option > PATH lookup > default locations
219
208
  ...(() => {
220
- const resolved = resolveClaudeCodePath(claudeCode?.pathToClaudeCodeExecutable)
209
+ const resolved = resolveClaudeCodePath(
210
+ claudeCode?.pathToClaudeCodeExecutable,
211
+ )
221
212
  return resolved ? { pathToClaudeCodeExecutable: resolved } : {}
222
213
  })(),
223
214
  }
224
215
 
225
216
  if (claudeCode?.agents) {
226
- console.error(`[ocpipe] Subagents defined: ${Object.keys(claudeCode.agents).join(', ')}`)
217
+ console.error(
218
+ `[ocpipe] Subagents defined: ${Object.keys(claudeCode.agents).join(', ')}`,
219
+ )
227
220
  }
228
221
  if (claudeCode?.allowedTools) {
229
- console.error(`[ocpipe] Allowed tools: ${claudeCode.allowedTools.join(', ')}`)
222
+ console.error(
223
+ `[ocpipe] Allowed tools: ${claudeCode.allowedTools.join(', ')}`,
224
+ )
230
225
  }
231
226
  console.error(
232
227
  `\n>>> Claude Code [${modelStr}] [${permissionMode}] ${sessionInfo}: ${promptPreview}...`,
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ocpipe: LLM pipelines with OpenCode and Zod.
2
+ * ocpipe: LLM pipelines with coding-agent backends and Zod.
3
3
  *
4
4
  * Inspired by DSPy.
5
5
  *
@@ -22,8 +22,7 @@
22
22
  * // Run in a pipeline
23
23
  * const pipeline = new Pipeline({
24
24
  * name: 'my-workflow',
25
- * defaultModel: { providerID: 'opencode', modelID: 'minimax-m2.1-free' },
26
- * defaultAgent: 'general',
25
+ * defaultModel: { backend: 'omp', modelID: 'gpt-5.5' },
27
26
  * checkpointDir: './ckpt',
28
27
  * logDir: './logs',
29
28
  * }, createBaseState)
@@ -93,6 +92,7 @@ export type {
93
92
  PermissionMode,
94
93
  ClaudeCodeOptions,
95
94
  PiOptions,
95
+ OmpOptions,
96
96
  CodexApprovalPolicy,
97
97
  CodexConfigValue,
98
98
  CodexOptions,