ocpipe 0.6.7 → 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 +10 -3
- package/package.json +5 -5
- package/src/agent.ts +6 -1
- package/src/codex.ts +105 -2
- package/src/index.ts +6 -0
- package/src/pi.ts +383 -0
- package/src/pipeline.ts +1 -0
- package/src/predict.ts +3 -0
- package/src/types.ts +55 -1
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)
|
|
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
|
|
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.
|
|
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.
|
|
35
|
-
"@openai/codex-sdk": "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.
|
|
46
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.0",
|
|
47
47
|
"@eslint/js": "^10.0.1",
|
|
48
|
-
"@openai/codex-sdk": "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,
|
|
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/codex.ts
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
import {
|
|
8
8
|
Codex,
|
|
9
9
|
type CodexOptions as CodexSdkClientOptions,
|
|
10
|
+
type RunResult,
|
|
10
11
|
type ThreadOptions,
|
|
11
12
|
} from '@openai/codex-sdk'
|
|
12
13
|
import { PROJECT_ROOT } from './paths.js'
|
|
13
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
CodexOptions,
|
|
16
|
+
CodexRunSummary,
|
|
17
|
+
RunAgentOptions,
|
|
18
|
+
RunAgentResult,
|
|
19
|
+
} from './types.js'
|
|
14
20
|
|
|
15
21
|
class CodexLogFilter {
|
|
16
22
|
private buf = ''
|
|
@@ -121,13 +127,15 @@ export async function runCodexAgent(
|
|
|
121
127
|
|
|
122
128
|
const nextSessionId = codex?.ephemeral ? '' : (thread.id ?? '')
|
|
123
129
|
const sessionStr = nextSessionId || 'none'
|
|
130
|
+
const runSummary = buildCodexRunSummary(result)
|
|
124
131
|
console.error(
|
|
125
|
-
`<<< Codex SDK done
|
|
132
|
+
`<<< Codex SDK done [thread:${sessionStr}]\n${formatCodexRunSummary(runSummary)}`,
|
|
126
133
|
)
|
|
127
134
|
|
|
128
135
|
return {
|
|
129
136
|
text: response,
|
|
130
137
|
sessionId: nextSessionId,
|
|
138
|
+
runSummary,
|
|
131
139
|
}
|
|
132
140
|
} catch (err) {
|
|
133
141
|
if (timedOut) {
|
|
@@ -143,6 +151,101 @@ export async function runCodexAgent(
|
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
/** buildCodexRunSummary projects a Codex turn into a parent-readable summary. */
|
|
155
|
+
export function buildCodexRunSummary(result: RunResult): CodexRunSummary {
|
|
156
|
+
const commands: CodexRunSummary['commands'] = []
|
|
157
|
+
const fileChanges: CodexRunSummary['fileChanges'] = []
|
|
158
|
+
let errorMessage = ''
|
|
159
|
+
let finalMessage = result.finalResponse.trim()
|
|
160
|
+
|
|
161
|
+
for (const item of result.items) {
|
|
162
|
+
switch (item.type) {
|
|
163
|
+
case 'agent_message':
|
|
164
|
+
if (item.text) finalMessage = item.text
|
|
165
|
+
break
|
|
166
|
+
case 'command_execution':
|
|
167
|
+
commands.push({
|
|
168
|
+
command: item.command,
|
|
169
|
+
status: item.status,
|
|
170
|
+
exitCode: item.exit_code ?? null,
|
|
171
|
+
})
|
|
172
|
+
break
|
|
173
|
+
case 'file_change':
|
|
174
|
+
for (const change of item.changes) {
|
|
175
|
+
fileChanges.push({
|
|
176
|
+
path: change.path,
|
|
177
|
+
kind: change.kind,
|
|
178
|
+
status: item.status,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
break
|
|
182
|
+
case 'error':
|
|
183
|
+
errorMessage = item.message
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const usage = result.usage
|
|
189
|
+
return {
|
|
190
|
+
status: errorMessage ? 'failed' : 'completed',
|
|
191
|
+
finalMessage: finalMessage.trim(),
|
|
192
|
+
errorMessage,
|
|
193
|
+
commands,
|
|
194
|
+
fileChanges,
|
|
195
|
+
tokens:
|
|
196
|
+
usage ?
|
|
197
|
+
{
|
|
198
|
+
input: usage.input_tokens,
|
|
199
|
+
cached: usage.cached_input_tokens,
|
|
200
|
+
output: usage.output_tokens,
|
|
201
|
+
reasoning: usage.reasoning_output_tokens,
|
|
202
|
+
}
|
|
203
|
+
: null,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** formatCodexRunSummary renders a Codex run summary as a clean text block. */
|
|
208
|
+
export function formatCodexRunSummary(summary: CodexRunSummary): string {
|
|
209
|
+
const lines: string[] = [`status: ${summary.status}`]
|
|
210
|
+
if (summary.errorMessage) {
|
|
211
|
+
lines.push(`error: ${summary.errorMessage}`)
|
|
212
|
+
}
|
|
213
|
+
if (summary.commands.length > 0) {
|
|
214
|
+
const failed = summary.commands.filter(isFailedCommand).length
|
|
215
|
+
lines.push(
|
|
216
|
+
`commands: ${summary.commands.length} completed, ${failed} failed`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
if (summary.fileChanges.length > 0) {
|
|
220
|
+
let add = 0
|
|
221
|
+
let update = 0
|
|
222
|
+
let del = 0
|
|
223
|
+
for (const change of summary.fileChanges) {
|
|
224
|
+
if (change.kind === 'add') add++
|
|
225
|
+
else if (change.kind === 'delete') del++
|
|
226
|
+
else update++
|
|
227
|
+
}
|
|
228
|
+
lines.push(`files: add=${add} update=${update} delete=${del}`)
|
|
229
|
+
}
|
|
230
|
+
if (summary.tokens) {
|
|
231
|
+
const t = summary.tokens
|
|
232
|
+
lines.push(
|
|
233
|
+
`tokens: input=${t.input} cached=${t.cached} output=${t.output} reasoning=${t.reasoning}`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
if (summary.finalMessage) {
|
|
237
|
+
lines.push(`final_message:\n${summary.finalMessage}`)
|
|
238
|
+
}
|
|
239
|
+
return lines.join('\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isFailedCommand(command: CodexRunSummary['commands'][number]): boolean {
|
|
243
|
+
return (
|
|
244
|
+
command.status === 'failed' ||
|
|
245
|
+
(command.exitCode !== null && command.exitCode !== 0)
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
146
249
|
function buildCodexClientOptions(
|
|
147
250
|
codex: CodexOptions | undefined,
|
|
148
251
|
): CodexSdkClientOptions {
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ export { createSessionId, createBaseState, extendBaseState } from './state.js'
|
|
|
50
50
|
|
|
51
51
|
// Agent integration
|
|
52
52
|
export { runAgent, logStep } from './agent.js'
|
|
53
|
+
export { buildCodexRunSummary, formatCodexRunSummary } from './codex.js'
|
|
53
54
|
|
|
54
55
|
// Response parsing
|
|
55
56
|
export {
|
|
@@ -91,6 +92,7 @@ export type {
|
|
|
91
92
|
BackendType,
|
|
92
93
|
PermissionMode,
|
|
93
94
|
ClaudeCodeOptions,
|
|
95
|
+
PiOptions,
|
|
94
96
|
CodexApprovalPolicy,
|
|
95
97
|
CodexConfigValue,
|
|
96
98
|
CodexOptions,
|
|
@@ -116,6 +118,10 @@ export type {
|
|
|
116
118
|
// Agent types
|
|
117
119
|
RunAgentOptions,
|
|
118
120
|
RunAgentResult,
|
|
121
|
+
CodexRunSummary,
|
|
122
|
+
CodexRunCommand,
|
|
123
|
+
CodexRunFileChange,
|
|
124
|
+
CodexRunTokens,
|
|
119
125
|
// Correction types
|
|
120
126
|
CorrectionMethod,
|
|
121
127
|
CorrectionConfig,
|
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
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
|
}
|
|
@@ -418,4 +438,38 @@ export interface RunAgentResult {
|
|
|
418
438
|
text: string
|
|
419
439
|
/** Session ID (for continuing the conversation). */
|
|
420
440
|
sessionId: string
|
|
441
|
+
/** Clean run summary, set for the Codex backend. */
|
|
442
|
+
runSummary?: CodexRunSummary
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** CodexRunCommand records one command the Codex agent executed. */
|
|
446
|
+
export interface CodexRunCommand {
|
|
447
|
+
command: string
|
|
448
|
+
status: string
|
|
449
|
+
exitCode: number | null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** CodexRunFileChange records one file the Codex agent changed. */
|
|
453
|
+
export interface CodexRunFileChange {
|
|
454
|
+
path: string
|
|
455
|
+
kind: 'add' | 'update' | 'delete'
|
|
456
|
+
status: string
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** CodexRunTokens records token usage for a Codex turn. */
|
|
460
|
+
export interface CodexRunTokens {
|
|
461
|
+
input: number
|
|
462
|
+
cached: number
|
|
463
|
+
output: number
|
|
464
|
+
reasoning: number
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** CodexRunSummary is the parent-readable projection of a Codex turn. */
|
|
468
|
+
export interface CodexRunSummary {
|
|
469
|
+
status: 'completed' | 'failed' | 'unknown'
|
|
470
|
+
finalMessage: string
|
|
471
|
+
errorMessage: string
|
|
472
|
+
commands: CodexRunCommand[]
|
|
473
|
+
fileChanges: CodexRunFileChange[]
|
|
474
|
+
tokens: CodexRunTokens | null
|
|
421
475
|
}
|