ocpipe 0.6.8 → 0.6.9

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
@@ -1,5 +1,5 @@
1
1
  <p align="center"><strong>ocpipe</strong></p>
2
- <p align="center">Build LLM pipelines with <a href="https://github.com/sst/opencode">OpenCode</a>, <a href="https://github.com/anthropics/claude-code">Claude Code</a>, and <a href="https://zod.dev">Zod</a>.</p>
2
+ <p align="center">Build LLM pipelines with <a href="https://github.com/sst/opencode">OpenCode</a>, <a href="https://github.com/anthropics/claude-code">Claude Code</a>, Pi, and <a href="https://zod.dev">Zod</a>.</p>
3
3
  <p align="center">Inspired by <a href="https://github.com/stanfordnlp/dspy">DSPy</a>.</p>
4
4
  <p align="center">
5
5
  <a href="https://www.npmjs.com/package/ocpipe"><img alt="npm" src="https://img.shields.io/npm/v/ocpipe?style=flat-square" /></a>
@@ -11,7 +11,7 @@
11
11
  - **Type-safe** Define inputs and outputs with Zod schemas
12
12
  - **Modular** Compose modules into complex pipelines
13
13
  - **Checkpoints** Resume from any step
14
- - **Multi-backend** Choose between OpenCode (75+ providers) or Claude Code SDK
14
+ - **Multi-backend** Choose between OpenCode (75+ providers), Claude Code SDK, Codex SDK, or Pi
15
15
  - **Auto-correction** Fixes schema mismatches automatically
16
16
 
17
17
  ### Quick Start
@@ -49,7 +49,7 @@ type GreetOut = InferOutputs<typeof Greet> // { greeting: string }
49
49
 
50
50
  ### Backends
51
51
 
52
- ocpipe supports three backends for running LLM agents:
52
+ ocpipe supports four backends for running LLM agents:
53
53
 
54
54
  **OpenCode** (default) - Requires `opencode` CLI in your PATH. Supports 75+ providers.
55
55
 
@@ -83,6 +83,13 @@ defaultModel: { backend: 'codex', modelID: 'gpt-5.4' },
83
83
  codex: { sandbox: 'read-only', reasoningEffort: 'high' },
84
84
  ```
85
85
 
86
+ **Pi** - Uses the `pi` coding-agent CLI JSONL RPC mode.
87
+
88
+ ```typescript
89
+ defaultModel: { backend: 'pi', modelID: 'gemma' },
90
+ pi: { command: 'pi' },
91
+ ```
92
+
86
93
  ### Requirements
87
94
 
88
95
  **For OpenCode backend:** Currently requires [this OpenCode fork](https://github.com/paralin/opencode). Once the following PRs are merged, the official release will work:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocpipe",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "SDK for LLM pipelines with OpenCode, Codex, and Zod",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "peerDependencies": {
33
33
  "zod": "4.4.3",
34
- "@anthropic-ai/claude-agent-sdk": "0.2.126",
35
- "@openai/codex-sdk": "0.130.0"
34
+ "@anthropic-ai/claude-agent-sdk": "0.3.185",
35
+ "@openai/codex-sdk": "0.141.0"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@anthropic-ai/claude-agent-sdk": {
@@ -43,9 +43,9 @@
43
43
  }
44
44
  },
45
45
  "devDependencies": {
46
- "@anthropic-ai/claude-agent-sdk": "^0.2.132",
46
+ "@anthropic-ai/claude-agent-sdk": "^0.3.0",
47
47
  "@eslint/js": "^10.0.1",
48
- "@openai/codex-sdk": "0.130.0",
48
+ "@openai/codex-sdk": "0.141.0",
49
49
  "@typescript/native-preview": "^7.0.0-dev.20260506.1",
50
50
  "bun-types": "^1.3.13",
51
51
  "eslint": "^10.3.0",
package/src/agent.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ocpipe agent integration.
3
3
  *
4
- * Dispatches to OpenCode CLI, Claude Code SDK, or Codex SDK based on backend
4
+ * Dispatches to OpenCode CLI, Claude Code SDK, Codex SDK, or Pi based on backend
5
5
  * configuration.
6
6
  */
7
7
 
@@ -33,6 +33,11 @@ export async function runAgent(
33
33
  return runCodexAgent(options)
34
34
  }
35
35
 
36
+ if (backend === 'pi') {
37
+ const { runPiAgent } = await import('./pi.js')
38
+ return runPiAgent(options)
39
+ }
40
+
36
41
  return runOpencodeAgent(options)
37
42
  }
38
43
 
package/src/index.ts CHANGED
@@ -92,6 +92,7 @@ export type {
92
92
  BackendType,
93
93
  PermissionMode,
94
94
  ClaudeCodeOptions,
95
+ PiOptions,
95
96
  CodexApprovalPolicy,
96
97
  CodexConfigValue,
97
98
  CodexOptions,
package/src/pi.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * ocpipe Pi coding-agent integration.
3
+ *
4
+ * Runs Pi through its JSONL RPC mode.
5
+ */
6
+
7
+ import { spawn, type ChildProcess } from 'child_process'
8
+ import { createInterface } from 'readline'
9
+ import { join } from 'path'
10
+ import { homedir } from 'os'
11
+ import { PROJECT_ROOT } from './paths.js'
12
+ import type { PiOptions, RunAgentOptions, RunAgentResult } from './types.js'
13
+
14
+ interface PiProcessRequest {
15
+ command: string
16
+ args: string[]
17
+ cwd: string
18
+ env: NodeJS.ProcessEnv
19
+ }
20
+
21
+ export interface PiConnection {
22
+ send(line: string): void
23
+ recv(signal?: AbortSignal): Promise<string>
24
+ close(): void
25
+ }
26
+
27
+ export interface PiProcess {
28
+ start(req: PiProcessRequest): PiConnection
29
+ }
30
+
31
+ interface PiRPCState {
32
+ sessionID: string
33
+ modelSummary: string
34
+ }
35
+
36
+ const defaultPiCommand = 'pi'
37
+
38
+ /** runPiAgent executes a Pi coding-agent turn over JSONL RPC. */
39
+ export async function runPiAgent(
40
+ options: RunAgentOptions,
41
+ processRunner: PiProcess = commandPiProcess,
42
+ ): Promise<RunAgentResult> {
43
+ const {
44
+ prompt,
45
+ model,
46
+ sessionId,
47
+ timeoutSec = 3600,
48
+ workdir,
49
+ pi,
50
+ signal,
51
+ } = options
52
+
53
+ if (signal?.aborted) {
54
+ throw new Error('Request aborted')
55
+ }
56
+
57
+ const cwd = workdir ?? PROJECT_ROOT
58
+ const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
59
+ const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
60
+ console.error(
61
+ `\n>>> Pi [${model.modelID}] ${sessionInfo}: ${promptPreview}...`,
62
+ )
63
+
64
+ const abort = new AbortController()
65
+ const abortHandler = () => abort.abort()
66
+ signal?.addEventListener('abort', abortHandler, { once: true })
67
+ let timedOut = false
68
+ const timeout =
69
+ timeoutSec > 0 ?
70
+ setTimeout(() => {
71
+ timedOut = true
72
+ abort.abort()
73
+ }, timeoutSec * 1000)
74
+ : null
75
+
76
+ const conn = processRunner.start({
77
+ command: pi?.command ?? defaultPiCommand,
78
+ args: buildPiArgs(model.modelID, sessionId, pi),
79
+ cwd,
80
+ env: buildPiEnv(pi),
81
+ })
82
+ const client = new PiRPCClient(conn)
83
+
84
+ try {
85
+ const initial = await client.getState(abort.signal)
86
+ await client.prompt(prompt, abort.signal)
87
+ await client.waitAgentEnd(abort.signal)
88
+ const response = await client.getLastAssistantText(abort.signal)
89
+ const final = await client.getState(abort.signal)
90
+ const nextSessionId = firstNonEmpty(
91
+ final.sessionID,
92
+ initial.sessionID,
93
+ sessionId ?? '',
94
+ )
95
+ if (!nextSessionId) {
96
+ throw new Error('Pi RPC did not emit a provider session ID')
97
+ }
98
+ if (!response) {
99
+ throw new Error('Pi RPC returned an empty final message')
100
+ }
101
+ const modelSummary =
102
+ final.modelSummary ? ` model=${final.modelSummary}` : ''
103
+ console.error(
104
+ `<<< Pi done (${response.length} chars) [session:${nextSessionId}]${modelSummary}`,
105
+ )
106
+ return {
107
+ text: response,
108
+ sessionId: nextSessionId,
109
+ }
110
+ } catch (err) {
111
+ if (timedOut) {
112
+ throw new Error(`Timeout after ${timeoutSec}s`, { cause: err })
113
+ }
114
+ if (signal?.aborted) {
115
+ throw new Error('Request aborted', { cause: err })
116
+ }
117
+ throw err
118
+ } finally {
119
+ if (timeout) clearTimeout(timeout)
120
+ signal?.removeEventListener('abort', abortHandler)
121
+ conn.close()
122
+ }
123
+ }
124
+
125
+ function buildPiArgs(
126
+ modelID: string,
127
+ sessionId: string | undefined,
128
+ pi: PiOptions | undefined,
129
+ ): string[] {
130
+ const args = ['--mode', 'rpc', '--approve']
131
+ const sessionDir = piSessionDir(pi)
132
+ if (sessionDir) {
133
+ args.push('--session-dir', sessionDir)
134
+ }
135
+ if (sessionId) {
136
+ args.push('--session-id', sessionId)
137
+ }
138
+ if (modelID) {
139
+ args.push('--model', modelID)
140
+ }
141
+ return args
142
+ }
143
+
144
+ function buildPiEnv(pi: PiOptions | undefined): NodeJS.ProcessEnv {
145
+ const providerHome = piProviderHome(pi)
146
+ const sessionDir = piSessionDir(pi)
147
+ return {
148
+ ...process.env,
149
+ ...pi?.env,
150
+ PI_CODING_AGENT_DIR: providerHome,
151
+ PI_CODING_AGENT_SESSION_DIR: sessionDir,
152
+ ...(pi?.baseUrl ? { LLAMA_BASE_URL: pi.baseUrl } : {}),
153
+ }
154
+ }
155
+
156
+ function piProviderHome(pi: PiOptions | undefined): string {
157
+ return pi?.providerHome ?? join(homedir(), '.pi-coding-agent')
158
+ }
159
+
160
+ function piSessionDir(pi: PiOptions | undefined): string {
161
+ return pi?.sessionDir ?? join(piProviderHome(pi), 'sessions')
162
+ }
163
+
164
+ class PiRPCClient {
165
+ private nextID = 0
166
+
167
+ constructor(private readonly conn: PiConnection) {}
168
+
169
+ async prompt(message: string, signal?: AbortSignal): Promise<void> {
170
+ const response = await this.request('prompt', { message }, signal)
171
+ const command = piString(response.command)
172
+ if (command && command !== 'prompt') {
173
+ throw new Error(`Pi RPC command mismatch: expected prompt got ${command}`)
174
+ }
175
+ }
176
+
177
+ async getState(signal?: AbortSignal): Promise<PiRPCState> {
178
+ const response = await this.request('get_state', {}, signal)
179
+ const data = piObject(response.data, 'Pi get_state response missing data')
180
+ return {
181
+ sessionID: piString(data.sessionId),
182
+ modelSummary: piModelSummary(data.model),
183
+ }
184
+ }
185
+
186
+ async getLastAssistantText(signal?: AbortSignal): Promise<string> {
187
+ const response = await this.request('get_last_assistant_text', {}, signal)
188
+ const data = piObject(
189
+ response.data,
190
+ 'Pi get_last_assistant_text response missing data',
191
+ )
192
+ return piString(data.text)
193
+ }
194
+
195
+ async waitAgentEnd(signal?: AbortSignal): Promise<void> {
196
+ for (;;) {
197
+ const { value } = await this.recv(signal)
198
+ if (piString(value.type) === 'agent_end') {
199
+ return
200
+ }
201
+ }
202
+ }
203
+
204
+ private async request(
205
+ type: string,
206
+ fields: Record<string, string>,
207
+ signal?: AbortSignal,
208
+ ): Promise<Record<string, unknown>> {
209
+ this.nextID++
210
+ const id = `ocpipe-pi-${this.nextID}`
211
+ this.conn.send(JSON.stringify({ type, id, ...fields }))
212
+ for (;;) {
213
+ const { value, line } = await this.recv(signal)
214
+ if (piString(value.type) !== 'response' || piString(value.id) !== id) {
215
+ continue
216
+ }
217
+ if (value.success !== true) {
218
+ const errorText = piString(value.error) || line
219
+ throw new Error(`Pi RPC ${type} failed: ${errorText}`)
220
+ }
221
+ return value
222
+ }
223
+ }
224
+
225
+ private async recv(
226
+ signal?: AbortSignal,
227
+ ): Promise<{ value: Record<string, unknown>; line: string }> {
228
+ const line = await this.conn.recv(signal)
229
+ let parsed: unknown
230
+ try {
231
+ parsed = JSON.parse(line)
232
+ } catch (err) {
233
+ throw new Error(`Parse Pi RPC JSONL failed: ${line}`, { cause: err })
234
+ }
235
+ return { value: piObject(parsed, 'Pi RPC line must be an object'), line }
236
+ }
237
+ }
238
+
239
+ const commandPiProcess: PiProcess = {
240
+ start(req) {
241
+ const child = spawn(req.command, req.args, {
242
+ cwd: req.cwd,
243
+ env: req.env,
244
+ stdio: ['pipe', 'pipe', 'inherit'],
245
+ })
246
+ return new CommandPiConnection(child)
247
+ },
248
+ }
249
+
250
+ class CommandPiConnection implements PiConnection {
251
+ private readonly lines: string[] = []
252
+ private readonly waiters: Array<{
253
+ resolve: (line: string) => void
254
+ reject: (err: Error) => void
255
+ signal?: AbortSignal
256
+ abort?: () => void
257
+ }> = []
258
+ private closedError: Error | null = null
259
+
260
+ constructor(private readonly child: ChildProcess) {
261
+ if (!child.stdout || !child.stdin) {
262
+ throw new Error('Pi RPC process pipes were not opened')
263
+ }
264
+ const rl = createInterface({ input: child.stdout })
265
+ rl.on('line', (line) => this.push(line))
266
+ child.on('error', (err) => this.closeWith(err))
267
+ child.on('close', (code, signal) => {
268
+ if (this.closedError) return
269
+ if (code === 0) {
270
+ this.closeWith(new Error('Pi RPC closed'))
271
+ return
272
+ }
273
+ const detail = signal ? `signal ${signal}` : `status ${code}`
274
+ this.closeWith(new Error(`Pi RPC exited with ${detail}`))
275
+ })
276
+ }
277
+
278
+ send(line: string): void {
279
+ if (!this.child.stdin) {
280
+ throw new Error('Pi RPC stdin is closed')
281
+ }
282
+ this.child.stdin.write(line.trimEnd() + '\n')
283
+ }
284
+
285
+ recv(signal?: AbortSignal): Promise<string> {
286
+ if (this.lines.length > 0) {
287
+ return Promise.resolve(this.lines.shift() ?? '')
288
+ }
289
+ if (this.closedError) {
290
+ return Promise.reject(this.closedError)
291
+ }
292
+ if (signal?.aborted) {
293
+ return Promise.reject(new Error('Request aborted'))
294
+ }
295
+ return new Promise((resolve, reject) => {
296
+ const waiter = {
297
+ resolve,
298
+ reject,
299
+ signal,
300
+ abort: undefined as (() => void) | undefined,
301
+ }
302
+ waiter.abort = () => {
303
+ this.removeWaiter(waiter)
304
+ reject(new Error('Request aborted'))
305
+ }
306
+ signal?.addEventListener('abort', waiter.abort, { once: true })
307
+ this.waiters.push(waiter)
308
+ })
309
+ }
310
+
311
+ close(): void {
312
+ this.child.stdin?.destroy()
313
+ this.child.kill()
314
+ }
315
+
316
+ private push(line: string): void {
317
+ const waiter = this.waiters.shift()
318
+ if (!waiter) {
319
+ this.lines.push(line)
320
+ return
321
+ }
322
+ if (waiter.abort) {
323
+ waiter.signal?.removeEventListener('abort', waiter.abort)
324
+ }
325
+ waiter.resolve(line)
326
+ }
327
+
328
+ private closeWith(err: Error): void {
329
+ this.closedError = err
330
+ for (const waiter of this.waiters.splice(0)) {
331
+ if (waiter.abort) {
332
+ waiter.signal?.removeEventListener('abort', waiter.abort)
333
+ }
334
+ waiter.reject(err)
335
+ }
336
+ }
337
+
338
+ private removeWaiter(waiter: (typeof this.waiters)[number]): void {
339
+ const idx = this.waiters.indexOf(waiter)
340
+ if (idx >= 0) {
341
+ this.waiters.splice(idx, 1)
342
+ }
343
+ }
344
+ }
345
+
346
+ function piObject(value: unknown, message: string): Record<string, unknown> {
347
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
348
+ throw new Error(message)
349
+ }
350
+ return value as Record<string, unknown>
351
+ }
352
+
353
+ function piString(value: unknown): string {
354
+ return typeof value === 'string' ? value.trim() : ''
355
+ }
356
+
357
+ function piModelSummary(value: unknown): string {
358
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
359
+ return ''
360
+ }
361
+ const model = value as Record<string, unknown>
362
+ const provider = firstNonEmpty(
363
+ piString(model.provider),
364
+ piString(model.providerId),
365
+ )
366
+ const id = firstNonEmpty(
367
+ piString(model.id),
368
+ piString(model.model),
369
+ piString(model.name),
370
+ )
371
+ if (provider && id) {
372
+ return `${provider}/${id}`
373
+ }
374
+ return firstNonEmpty(id, provider)
375
+ }
376
+
377
+ function firstNonEmpty(...values: string[]): string {
378
+ for (const value of values) {
379
+ const trimmed = value.trim()
380
+ if (trimmed) return trimmed
381
+ }
382
+ return ''
383
+ }
package/src/pipeline.ts CHANGED
@@ -35,6 +35,7 @@ export class Pipeline<S extends BaseState> {
35
35
  workdir: config.workdir,
36
36
  claudeCode: config.claudeCode,
37
37
  codex: config.codex,
38
+ pi: config.pi,
38
39
  }
39
40
  }
40
41
 
package/src/predict.ts CHANGED
@@ -78,6 +78,7 @@ export class Predict<S extends AnySignature> {
78
78
  workdir: ctx.workdir,
79
79
  claudeCode: ctx.claudeCode,
80
80
  codex: ctx.codex,
81
+ pi: ctx.pi,
81
82
  signal: ctx.signal,
82
83
  })
83
84
 
@@ -209,6 +210,7 @@ export class Predict<S extends AnySignature> {
209
210
  workdir: ctx.workdir,
210
211
  claudeCode: ctx.claudeCode,
211
212
  codex: ctx.codex,
213
+ pi: ctx.pi,
212
214
  signal: ctx.signal,
213
215
  })
214
216
 
@@ -287,6 +289,7 @@ export class Predict<S extends AnySignature> {
287
289
  workdir: ctx.workdir,
288
290
  claudeCode: ctx.claudeCode,
289
291
  codex: ctx.codex,
292
+ pi: ctx.pi,
290
293
  signal: ctx.signal,
291
294
  })
292
295
 
package/src/types.ts CHANGED
@@ -9,7 +9,7 @@ import type { z } from 'zod/v4'
9
9
  // ============================================================================
10
10
 
11
11
  /** Backend type for running agents. */
12
- export type BackendType = 'opencode' | 'claude-code' | 'codex'
12
+ export type BackendType = 'opencode' | 'claude-code' | 'codex' | 'pi'
13
13
 
14
14
  /** Reasoning effort for Codex SDK threads. */
15
15
  export type CodexReasoningEffort =
@@ -119,6 +119,20 @@ export interface CodexOptions {
119
119
  webSearchEnabled?: boolean
120
120
  }
121
121
 
122
+ /** Pi coding agent specific session options. */
123
+ export interface PiOptions {
124
+ /** Path to the Pi executable (default: `pi` from PATH). */
125
+ command?: string
126
+ /** Pi runtime home passed as PI_CODING_AGENT_DIR. */
127
+ providerHome?: string
128
+ /** Pi session directory passed by flag and PI_CODING_AGENT_SESSION_DIR. */
129
+ sessionDir?: string
130
+ /** Base URL passed as LLAMA_BASE_URL. */
131
+ baseUrl?: string
132
+ /** Extra environment variables passed to the Pi subprocess. */
133
+ env?: Record<string, string>
134
+ }
135
+
122
136
  /** Model configuration for LLM backends. */
123
137
  export interface ModelConfig {
124
138
  /** Backend to use (default: 'opencode'). */
@@ -152,6 +166,8 @@ export interface ExecutionContext {
152
166
  claudeCode?: ClaudeCodeOptions
153
167
  /** Codex SDK specific options. */
154
168
  codex?: CodexOptions
169
+ /** Pi coding agent specific options. */
170
+ pi?: PiOptions
155
171
  /** AbortSignal for cancelling in-flight backend requests. */
156
172
  signal?: AbortSignal
157
173
  }
@@ -372,6 +388,8 @@ export interface PipelineConfig {
372
388
  claudeCode?: ClaudeCodeOptions
373
389
  /** Codex SDK specific options. */
374
390
  codex?: CodexOptions
391
+ /** Pi coding agent specific options. */
392
+ pi?: PiOptions
375
393
  }
376
394
 
377
395
  /** Options for running a pipeline step. */
@@ -408,6 +426,8 @@ export interface RunAgentOptions {
408
426
  claudeCode?: ClaudeCodeOptions
409
427
  /** Codex SDK specific options. */
410
428
  codex?: CodexOptions
429
+ /** Pi coding agent specific options. */
430
+ pi?: PiOptions
411
431
  /** AbortSignal for cancelling the request. */
412
432
  signal?: AbortSignal
413
433
  }