ocpipe 0.5.2 → 0.5.4

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 CHANGED
@@ -54,33 +54,26 @@ ocpipe supports two backends for running LLM agents:
54
54
  **OpenCode** (default) - Requires `opencode` CLI in your PATH. Supports 75+ providers.
55
55
 
56
56
  ```typescript
57
- const pipeline = new Pipeline({
58
- name: 'my-pipeline',
59
- defaultModel: { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' },
60
- defaultAgent: 'default',
61
- }, createBaseState)
57
+ const pipeline = new Pipeline(
58
+ {
59
+ name: 'my-pipeline',
60
+ defaultModel: {
61
+ providerID: 'anthropic',
62
+ modelID: 'claude-sonnet-4-20250514',
63
+ },
64
+ defaultAgent: 'default',
65
+ },
66
+ createBaseState,
67
+ )
62
68
  ```
63
69
 
64
70
  **Claude Code** - Uses `@anthropic-ai/claude-agent-sdk`. Install as a peer dependency.
65
71
 
66
72
  ```typescript
67
- const pipeline = new Pipeline({
68
- name: 'my-pipeline',
69
- defaultModel: { backend: 'claude-code', providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' },
70
- defaultAgent: 'default',
71
- // Permission mode controls what Claude Code can do without prompting
72
- // Options: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
73
- claudeCode: { permissionMode: 'acceptEdits' }, // default: auto-approve file writes
74
- }, createBaseState)
75
-
76
- // To bypass all permission prompts (use with caution):
77
- const pipeline = new Pipeline({
78
- ...config,
79
- claudeCode: {
80
- permissionMode: 'bypassPermissions',
81
- dangerouslySkipPermissions: true, // required safety flag
82
- },
83
- }, createBaseState)
73
+ // modelID: 'opus', 'sonnet', or 'haiku'
74
+ defaultModel: { backend: 'claude-code', modelID: 'sonnet' },
75
+ // permissionMode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
76
+ claudeCode: { permissionMode: 'acceptEdits' },
84
77
  ```
85
78
 
86
79
  ### Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocpipe",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "SDK for LLM pipelines with OpenCode and Zod",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/agent.ts CHANGED
@@ -34,8 +34,11 @@ export async function runAgent(
34
34
  async function runOpencodeAgent(
35
35
  options: RunAgentOptions,
36
36
  ): Promise<RunAgentResult> {
37
- const { prompt, agent, model, sessionId, timeoutSec = 600, workdir } = options
37
+ const { prompt, agent, model, sessionId, timeoutSec = 600, workdir, signal } = options
38
38
 
39
+ if (!model.providerID) {
40
+ throw new Error('providerID is required for OpenCode backend')
41
+ }
39
42
  const modelStr = `${model.providerID}/${model.modelID}`
40
43
  const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
41
44
  const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
@@ -44,6 +47,11 @@ async function runOpencodeAgent(
44
47
  `\n>>> OpenCode [${agent}] [${modelStr}] ${sessionInfo}: ${promptPreview}...`,
45
48
  )
46
49
 
50
+ // Check if already aborted
51
+ if (signal?.aborted) {
52
+ throw new Error('Request aborted')
53
+ }
54
+
47
55
  // Write prompt to .opencode/prompts/ within the working directory
48
56
  const cwd = workdir ?? PROJECT_ROOT
49
57
  const promptsDir = join(cwd, '.opencode', 'prompts')
@@ -71,7 +79,9 @@ async function runOpencodeAgent(
71
79
 
72
80
  return new Promise((resolve, reject) => {
73
81
  const opencodeCmd = getOpencodeCommand(args)
74
- console.error(`[DEBUG] Running: ${opencodeCmd.cmd} ${opencodeCmd.args.join(' ')}`)
82
+ console.error(
83
+ `[DEBUG] Running: ${opencodeCmd.cmd} ${opencodeCmd.args.join(' ')}`,
84
+ )
75
85
  const proc = spawn(opencodeCmd.cmd, opencodeCmd.args, {
76
86
  cwd,
77
87
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -80,6 +90,22 @@ async function runOpencodeAgent(
80
90
  let newSessionId = sessionId || ''
81
91
  const stdoutChunks: string[] = []
82
92
  const stderrChunks: string[] = []
93
+ let aborted = false
94
+
95
+ // Handle abort signal - kill subprocess when aborted
96
+ const abortHandler = async () => {
97
+ if (aborted) return
98
+ aborted = true
99
+ console.error(`\n[abort] Killing OpenCode subprocess...`)
100
+ proc.kill('SIGTERM')
101
+ // Give it a moment to clean up, then force kill
102
+ setTimeout(() => {
103
+ if (!proc.killed) proc.kill('SIGKILL')
104
+ }, 1000)
105
+ await unlink(promptFile).catch(() => {})
106
+ reject(new Error('Request aborted'))
107
+ }
108
+ signal?.addEventListener('abort', abortHandler, { once: true })
83
109
 
84
110
  // Stream stderr in real-time (OpenCode progress output)
85
111
  proc.stderr.on('data', (data: Buffer) => {
@@ -120,6 +146,10 @@ async function runOpencodeAgent(
120
146
 
121
147
  proc.on('close', async (code) => {
122
148
  if (timeout) clearTimeout(timeout)
149
+ signal?.removeEventListener('abort', abortHandler)
150
+
151
+ // If aborted, we already rejected
152
+ if (aborted) return
123
153
 
124
154
  // Clean up prompt file
125
155
  await unlink(promptFile).catch(() => {})
@@ -135,19 +165,32 @@ async function runOpencodeAgent(
135
165
 
136
166
  // Check for OpenCode errors that exit with code 0 but produce no output
137
167
  const knownErrors = [
138
- { pattern: /ProviderModelNotFoundError/, message: 'Provider/model not found' },
168
+ {
169
+ pattern: /ProviderModelNotFoundError/,
170
+ message: 'Provider/model not found',
171
+ },
139
172
  { pattern: /ModelNotFoundError/, message: 'Model not found' },
140
173
  { pattern: /ProviderNotFoundError/, message: 'Provider not found' },
141
174
  { pattern: /API key.*not.*found/i, message: 'API key not configured' },
142
- { pattern: /authentication.*failed/i, message: 'Authentication failed' },
175
+ {
176
+ pattern: /authentication.*failed/i,
177
+ message: 'Authentication failed',
178
+ },
143
179
  ]
144
180
 
145
181
  for (const { pattern, message } of knownErrors) {
146
182
  if (pattern.test(stderr)) {
147
183
  // Extract the relevant error lines
148
- const errorLines = stderr.split('\n').filter(line =>
149
- pattern.test(line) || line.includes('Error') || line.includes('error:')
150
- ).slice(0, 5).join('\n')
184
+ const errorLines = stderr
185
+ .split('\n')
186
+ .filter(
187
+ (line) =>
188
+ pattern.test(line) ||
189
+ line.includes('Error') ||
190
+ line.includes('error:'),
191
+ )
192
+ .slice(0, 5)
193
+ .join('\n')
151
194
  reject(new Error(`OpenCode ${message}:\n${errorLines}`))
152
195
  return
153
196
  }
@@ -166,7 +209,11 @@ async function runOpencodeAgent(
166
209
  // Check for empty response with errors in stderr (likely a silent failure)
167
210
  if (response.length === 0 && stderr.includes('Error')) {
168
211
  const lastLines = stderr.split('\n').slice(-10).join('\n')
169
- reject(new Error(`OpenCode returned empty response with errors:\n${lastLines}`))
212
+ reject(
213
+ new Error(
214
+ `OpenCode returned empty response with errors:\n${lastLines}`,
215
+ ),
216
+ )
170
217
  return
171
218
  }
172
219
 
@@ -62,7 +62,12 @@ const logToolCall: HookCallback = async (input) => {
62
62
  export async function runClaudeCodeAgent(
63
63
  options: RunAgentOptions,
64
64
  ): Promise<RunAgentResult> {
65
- const { prompt, model, sessionId, timeoutSec = 600, claudeCode } = options
65
+ const { prompt, model, sessionId, timeoutSec = 600, claudeCode, signal } = options
66
+
67
+ // Check if already aborted
68
+ if (signal?.aborted) {
69
+ throw new Error('Request aborted')
70
+ }
66
71
 
67
72
  // Claude Code understands simple names: opus, sonnet, haiku
68
73
  const modelStr = normalizeModelId(model.modelID)
@@ -94,6 +99,13 @@ export async function runClaudeCodeAgent(
94
99
  unstable_v2_resumeSession(sessionId, sessionOptions)
95
100
  : unstable_v2_createSession(sessionOptions)
96
101
 
102
+ // Handle abort signal
103
+ const abortHandler = () => {
104
+ console.error(`\n[abort] Closing Claude Code session...`)
105
+ session.close()
106
+ }
107
+ signal?.addEventListener('abort', abortHandler, { once: true })
108
+
97
109
  try {
98
110
  // Send the prompt
99
111
  await session.send(prompt)
@@ -113,6 +125,15 @@ export async function runClaudeCodeAgent(
113
125
  })
114
126
  : null
115
127
 
128
+ // Set up abort promise
129
+ const abortPromise = signal ?
130
+ new Promise<never>((_, reject) => {
131
+ signal.addEventListener('abort', () => {
132
+ reject(new Error('Request aborted'))
133
+ }, { once: true })
134
+ })
135
+ : null
136
+
116
137
  // Stream the response
117
138
  const streamPromise = (async () => {
118
139
  for await (const msg of session.stream()) {
@@ -129,12 +150,11 @@ export async function runClaudeCodeAgent(
129
150
  }
130
151
  })()
131
152
 
132
- // Race between stream and timeout
133
- if (timeoutPromise) {
134
- await Promise.race([streamPromise, timeoutPromise])
135
- } else {
136
- await streamPromise
137
- }
153
+ // Race between stream, timeout, and abort
154
+ const promises: Promise<void | never>[] = [streamPromise]
155
+ if (timeoutPromise) promises.push(timeoutPromise)
156
+ if (abortPromise) promises.push(abortPromise)
157
+ await Promise.race(promises)
138
158
 
139
159
  const response = textParts.join('')
140
160
  const sessionStr = newSessionId || 'none'
@@ -147,6 +167,7 @@ export async function runClaudeCodeAgent(
147
167
  sessionId: newSessionId,
148
168
  }
149
169
  } finally {
170
+ signal?.removeEventListener('abort', abortHandler)
150
171
  session.close()
151
172
  }
152
173
  }
package/src/predict.ts CHANGED
@@ -77,6 +77,7 @@ export class Predict<S extends AnySignature> {
77
77
  timeoutSec: ctx.timeoutSec,
78
78
  workdir: ctx.workdir,
79
79
  claudeCode: ctx.claudeCode,
80
+ signal: ctx.signal,
80
81
  })
81
82
 
82
83
  // Update context with new session ID for continuity
@@ -206,6 +207,7 @@ export class Predict<S extends AnySignature> {
206
207
  timeoutSec: 60,
207
208
  workdir: ctx.workdir,
208
209
  claudeCode: ctx.claudeCode,
210
+ signal: ctx.signal,
209
211
  })
210
212
 
211
213
  // Try to parse the repaired JSON
@@ -282,6 +284,7 @@ export class Predict<S extends AnySignature> {
282
284
  timeoutSec: 60, // Short timeout for simple patches
283
285
  workdir: ctx.workdir,
284
286
  claudeCode: ctx.claudeCode,
287
+ signal: ctx.signal,
285
288
  })
286
289
 
287
290
  // Extract and apply the patch based on method
package/src/types.ts CHANGED
@@ -30,7 +30,8 @@ export interface ClaudeCodeOptions {
30
30
  export interface ModelConfig {
31
31
  /** Backend to use (default: 'opencode'). */
32
32
  backend?: BackendType
33
- providerID: string
33
+ /** Provider ID (required for OpenCode, ignored for Claude Code). */
34
+ providerID?: string
34
35
  modelID: string
35
36
  }
36
37
 
@@ -52,6 +53,8 @@ export interface ExecutionContext {
52
53
  workdir?: string
53
54
  /** Claude Code specific options. */
54
55
  claudeCode?: ClaudeCodeOptions
56
+ /** AbortSignal for cancelling requests. When aborted, kills subprocesses. */
57
+ signal?: AbortSignal
55
58
  }
56
59
 
57
60
  // ============================================================================
@@ -302,6 +305,8 @@ export interface RunAgentOptions {
302
305
  workdir?: string
303
306
  /** Claude Code specific options. */
304
307
  claudeCode?: ClaudeCodeOptions
308
+ /** AbortSignal for cancelling the request. When aborted, kills the subprocess. */
309
+ signal?: AbortSignal
305
310
  }
306
311
 
307
312
  /** Result from running an OpenCode agent. */