Skip to content

Commit ba78777

Browse files
David Liuclaude
andcommitted
feat(pi): add the Pi coding agent (pi.dev) as a provider
Pi is an MIT-licensed agent harness that fronts 15+ model providers. This wires it in alongside Claude Code, Codex, Cursor and Gemini. Backend - server/pi-cli.js drives `pi --mode json -p --session-id <uuid>` and maps its JSONL event stream onto the existing chat contract (pi-response / pi-complete / pi-error / token-budget), with abort support. - server/projects.js discovers sessions under ~/.pi/agent/sessions and reads their messages; server/index.js routes pi-command and abort. - server/routes/cli-auth.js reports install/login state and can install the CLI. Two behaviours were found by running the real CLI rather than reading its docs, and both would have shipped as bugs: - **The prompt must go over stdin, not argv.** Pi parses positional arguments, so against pi 0.83.0 a prompt of "-rf please" fails with "Unknown option" and "@notes.md explain" fails with "File not found" — and Pi has no `--` end-of-options terminator ("Unknown option: --"). Both are ordinary user input, and `@` mentions are a Dr. Claw feature. Pi's documented stdin piping accepts arbitrary bytes, so the prompt travels there. - **stdin must be explicitly closed.** Pi waits for EOF before running, so an open stdin pipe hangs the turn forever with no output and a running timer — the exact symptom this project has been fixing elsewhere. This was observed live before being fixed. Session discovery reads the `cwd` from each transcript's header line rather than decoding the directory name, which is lossy (a path segment containing '-' is indistinguishable from a separator). Transcripts are memoized on (size, mtimeMs) so repeated project refreshes do not re-read the tree. Frontend - 'pi' added to SessionProvider, the agent picker, model selection (PI_MODELS, provider/model slugs, free-text entry), session lists, tag/delete/navigation paths, and streaming handlers that reuse the existing delta buffer. Testing - 36 new tests drive the real provider code against a fake `pi` binary speaking the captured 0.83.0 event stream: argv construction, stdin delivery of adversarial prompts, empty prompts, error turns, stderr-only crashes, missing binary, non-JSON banner output, multi-byte UTF-8 split across stdout chunks, abort mid-stream, session identity, and session discovery/messages/delete. - Full suite 143 passed; typecheck and build clean. Verified end to end against the real CLI: streaming, clean error reporting, no hangs, session cleanup. Reviewed with Codex, which found duplicate terminal websocket events when 'error' and 'close' both fire on a failed spawn; fixed and covered by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6da7f7c commit ba78777

19 files changed

Lines changed: 1834 additions & 11 deletions

File tree

docs/configuration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Dr. Claw is configured through environment variables in a `.env` file at the pro
2626
| `CURSOR_CLI_PATH` | No | Auto-detect (`cursor-agent` then `agent`) | Override Cursor CLI command/binary. Useful when your environment only provides one alias. |
2727
| `GEMINI_CLI_PATH` | No | `gemini` | Override Gemini CLI command/binary. Useful when your shell resolves Gemini through a custom alias or path. |
2828
| `CODEX_CLI_PATH` | No | `codex` | Override Codex CLI command/binary. Useful when Codex is installed outside your default `PATH`. |
29+
| `PI_CLI_PATH` | No | `pi` | Override the [Pi coding agent](https://pi.dev) command/binary. |
2930

3031
### Database
3132

@@ -69,6 +70,34 @@ Platform mode is an advanced deployment option. Most users should leave these co
6970
| Variable | Required | Default | Description |
7071
|----------|----------|---------|-------------|
7172
| `CLAUDE_TOOL_APPROVAL_TIMEOUT_MS` | No | `55000` | Timeout in milliseconds for Claude tool-approval prompts before auto-declining. |
73+
| `PI_MODEL` | No | `anthropic/claude-sonnet-4-6` | Default model for the Pi provider, as `provider/model`. |
74+
75+
---
76+
77+
## Pi Coding Agent
78+
79+
[Pi](https://pi.dev) is an MIT-licensed agent harness that fronts 15+ model
80+
providers. Dr. Claw drives it non-interactively over its JSON event stream.
81+
82+
```bash
83+
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
84+
```
85+
86+
Then run `pi` once and use `/login` to authenticate a provider — Pi has no
87+
credentials of its own, so with none configured it reports no available models
88+
and Dr. Claw shows the provider as installed but not logged in.
89+
90+
| Detail | Value |
91+
|--------|-------|
92+
| Binary | `pi` (override with `PI_CLI_PATH`) |
93+
| Model format | `provider/model`, e.g. `anthropic/claude-sonnet-4-6`. Any slug `pi --list-models` reports is valid. |
94+
| Sessions | `~/.pi/agent/sessions/--<encoded-cwd>--/<timestamp>_<uuid>.jsonl` |
95+
| Status endpoint | `GET /api/cli/pi/status` |
96+
97+
Prompts are sent to Pi over stdin rather than as a command-line argument: Pi
98+
parses positional arguments, so a prompt beginning with `-` is read as a flag
99+
and one beginning with `@` as a file include — and `@` mentions are a normal
100+
Dr. Claw feature.
72101

73102
---
74103

server/__tests__/pi-cli.test.mjs

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'fs/promises';
3+
import os from 'os';
4+
import path from 'path';
5+
6+
/**
7+
* Drives the real Pi provider code against a fake `pi` binary: a Node script
8+
* that speaks the same JSONL event stream the real CLI emits (captured from
9+
* pi 0.83.0). This keeps argv construction, stdin handling, stream parsing and
10+
* abort semantics under test without requiring Pi or any model credentials.
11+
*/
12+
13+
let tmpDir;
14+
let mod;
15+
16+
async function writeFakePi(name, body) {
17+
const file = path.join(tmpDir, name);
18+
await writeFile(file, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
19+
return file;
20+
}
21+
22+
// Reads the prompt from stdin (as the real CLI does) and echoes a full turn.
23+
const FAKE_PI_HAPPY_PATH = `
24+
import { readFileSync } from 'fs';
25+
const prompt = readFileSync(0, 'utf8');
26+
const out = (o) => process.stdout.write(JSON.stringify(o) + '\\n');
27+
out({ type: 'session', version: 3, id: 'sess-from-pi', timestamp: '2026-08-05T19:00:00.000Z', cwd: process.cwd() });
28+
out({ type: 'agent_start' });
29+
out({ type: 'turn_start' });
30+
out({ type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: prompt }] } });
31+
out({ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: 'Hello ' } });
32+
out({ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: 'world' } });
33+
out({ type: 'tool_execution_start', toolCallId: 'tc1', toolName: 'bash', args: { cmd: 'ls' } });
34+
out({ type: 'tool_execution_end', toolCallId: 'tc1', toolName: 'bash', result: 'file.txt', isError: false });
35+
out({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Hello world' }], model: 'claude-sonnet-4-6', provider: 'anthropic', stopReason: 'stop', usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 } } });
36+
out({ type: 'turn_end', message: { role: 'assistant', content: [], stopReason: 'stop', usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 } }, toolResults: [] });
37+
out({ type: 'agent_end', messages: [] });
38+
`;
39+
40+
beforeEach(async () => {
41+
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'drclaw-pi-'));
42+
vi.resetModules();
43+
mod = await import('../pi-cli.js');
44+
});
45+
46+
afterEach(async () => {
47+
await rm(tmpDir, { recursive: true, force: true });
48+
vi.restoreAllMocks();
49+
});
50+
51+
function collectingWs() {
52+
const events = [];
53+
return {
54+
events,
55+
send: (msg) => events.push(msg),
56+
setSessionId: () => {},
57+
setProjectPath: () => {},
58+
};
59+
}
60+
61+
describe('buildPiArgs', () => {
62+
it('requests the JSON event stream in non-interactive mode', () => {
63+
const args = mod.buildPiArgs({ sessionId: 'abc' });
64+
expect(args.slice(0, 3)).toEqual(['--mode', 'json', '-p']);
65+
});
66+
67+
it('addresses the session by id so Dr. Claw owns session identity', () => {
68+
const args = mod.buildPiArgs({ sessionId: 'abc-123' });
69+
expect(args).toContain('--session-id');
70+
expect(args[args.indexOf('--session-id') + 1]).toBe('abc-123');
71+
});
72+
73+
it('never puts the prompt in argv', () => {
74+
// Verified against pi 0.83.0: a positional "-rf ..." is parsed as a flag and
75+
// "@notes.md ..." as a file include, and pi has no `--` terminator. Both are
76+
// ordinary user input, so the prompt has to travel over stdin.
77+
const args = mod.buildPiArgs({ sessionId: 'abc', model: 'anthropic/claude-sonnet-4-6' });
78+
expect(args).not.toContain('--');
79+
expect(args.join(' ')).not.toContain('prompt');
80+
expect(args).toEqual(['--mode', 'json', '-p', '--session-id', 'abc', '--model', 'anthropic/claude-sonnet-4-6']);
81+
});
82+
83+
it('passes model and thinking level through', () => {
84+
const args = mod.buildPiArgs({ sessionId: 'a', model: 'openai/gpt-5', thinking: 'high' });
85+
expect(args[args.indexOf('--model') + 1]).toBe('openai/gpt-5');
86+
expect(args[args.indexOf('--thinking') + 1]).toBe('high');
87+
});
88+
89+
it('only trusts project-local files when permissions are skipped', () => {
90+
expect(mod.buildPiArgs({ sessionId: 'a' })).not.toContain('--approve');
91+
expect(mod.buildPiArgs({ sessionId: 'a', skipPermissions: true })).toContain('--approve');
92+
});
93+
});
94+
95+
describe('transformPiEvent', () => {
96+
it('maps text deltas', () => {
97+
expect(mod.transformPiEvent({
98+
type: 'message_update',
99+
assistantMessageEvent: { type: 'text_delta', delta: 'hi' },
100+
})).toEqual({ type: 'text_delta', delta: 'hi' });
101+
});
102+
103+
it('ignores lifecycle chatter that has nothing to display', () => {
104+
for (const type of ['agent_start', 'turn_start', 'queue_update', 'auto_retry_start', 'agent_settled']) {
105+
expect(mod.transformPiEvent({ type })).toBeNull();
106+
}
107+
});
108+
109+
it('suppresses an assistant message that stopped on error', () => {
110+
// The caller turns this into a pi-error; emitting it as a message too would
111+
// render an empty assistant bubble above the error.
112+
expect(mod.transformPiEvent({
113+
type: 'message_end',
114+
message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: '401' },
115+
})).toBeNull();
116+
});
117+
118+
it('maps tool execution to tool_use / tool_result', () => {
119+
expect(mod.transformPiEvent({
120+
type: 'tool_execution_start', toolCallId: 't1', toolName: 'bash', args: { cmd: 'ls' },
121+
})).toMatchObject({ type: 'tool_use', toolName: 'bash', toolInput: { cmd: 'ls' } });
122+
123+
expect(mod.transformPiEvent({
124+
type: 'tool_execution_end', toolCallId: 't1', toolName: 'bash', result: 'out', isError: false,
125+
})).toMatchObject({ type: 'tool_result', output: 'out', isError: false });
126+
});
127+
});
128+
129+
describe('buildPiTokenBudget', () => {
130+
it('normalizes Pi usage to the shared budget shape', () => {
131+
const budget = mod.buildPiTokenBudget({ input: 100, output: 50, cacheRead: 10, cacheWrite: 5, totalTokens: 165 });
132+
expect(budget).toMatchObject({ used: 165, inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheCreationTokens: 5 });
133+
});
134+
135+
it('falls back to summing components when totalTokens is absent', () => {
136+
expect(mod.buildPiTokenBudget({ input: 3, output: 4 }).used).toBe(7);
137+
});
138+
139+
it('returns null for empty usage rather than a zeroed budget', () => {
140+
expect(mod.buildPiTokenBudget(null)).toBeNull();
141+
expect(mod.buildPiTokenBudget({ input: 0, output: 0 })).toBeNull();
142+
});
143+
});
144+
145+
describe('spawnPi', () => {
146+
it('streams a full turn and completes', async () => {
147+
const fake = await writeFakePi('pi-happy.mjs', FAKE_PI_HAPPY_PATH);
148+
const ws = collectingWs();
149+
150+
const result = await mod.spawnPi('say hi', {
151+
cwd: tmpDir,
152+
model: 'anthropic/claude-sonnet-4-6',
153+
env: { ...process.env, PI_CLI_PATH: fake },
154+
}, ws);
155+
156+
const types = ws.events.map((e) => e.type);
157+
expect(types).toContain('session-created');
158+
expect(types).toContain('pi-complete');
159+
expect(types).not.toContain('pi-error');
160+
161+
const responses = ws.events.filter((e) => e.type === 'pi-response').map((e) => e.data);
162+
expect(responses.filter((d) => d.type === 'text_delta').map((d) => d.delta)).toEqual(['Hello ', 'world']);
163+
expect(responses.some((d) => d.type === 'tool_use' && d.toolName === 'bash')).toBe(true);
164+
expect(responses.some((d) => d.type === 'tool_result' && d.output === 'file.txt')).toBe(true);
165+
expect(ws.events.some((e) => e.type === 'token-budget' && e.data.used === 15)).toBe(true);
166+
expect(result.sessionId).toBeTruthy();
167+
});
168+
169+
it('delivers the prompt over stdin, intact, including @ and - prefixes', async () => {
170+
const capture = path.join(tmpDir, 'captured-prompt.txt');
171+
const fake = await writeFakePi('pi-capture.mjs', `
172+
import { readFileSync, writeFileSync } from 'fs';
173+
writeFileSync(${JSON.stringify(capture)}, readFileSync(0, 'utf8'));
174+
process.stdout.write(JSON.stringify({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() }) + '\\n');
175+
`);
176+
177+
const prompt = '@notes.md -rf 请解释这段代码 🦞';
178+
await mod.spawnPi(prompt, { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, collectingWs());
179+
180+
expect(await readFile(capture, 'utf8')).toBe(prompt);
181+
});
182+
183+
it('does not hang when the prompt is empty', async () => {
184+
// Pi blocks until stdin reaches EOF, so stdin must be closed even with
185+
// nothing to write.
186+
const fake = await writeFakePi('pi-empty.mjs', FAKE_PI_HAPPY_PATH);
187+
const ws = collectingWs();
188+
189+
const started = Date.now();
190+
await mod.spawnPi('', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws);
191+
192+
expect(Date.now() - started).toBeLessThan(10_000);
193+
expect(ws.events.map((e) => e.type)).toContain('pi-complete');
194+
});
195+
196+
it('surfaces a failed assistant turn as pi-error', async () => {
197+
const fake = await writeFakePi('pi-autherr.mjs', `
198+
import { readFileSync } from 'fs';
199+
readFileSync(0, 'utf8');
200+
const out = (o) => process.stdout.write(JSON.stringify(o) + '\\n');
201+
out({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() });
202+
out({ type: 'message_end', message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: '401 invalid x-api-key' } });
203+
out({ type: 'agent_end', messages: [] });
204+
`);
205+
const ws = collectingWs();
206+
207+
await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws);
208+
209+
const error = ws.events.find((e) => e.type === 'pi-error');
210+
expect(error.error).toContain('401');
211+
// No empty assistant bubble alongside the error.
212+
expect(ws.events.filter((e) => e.type === 'pi-response')).toHaveLength(0);
213+
});
214+
215+
it('reports stderr when the CLI dies before emitting any events', async () => {
216+
const fake = await writeFakePi('pi-crash.mjs', `
217+
process.stderr.write('No models available. Use /login to log into a provider.\\n');
218+
process.exit(1);
219+
`);
220+
const ws = collectingWs();
221+
222+
await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws);
223+
224+
const error = ws.events.find((e) => e.type === 'pi-error');
225+
expect(error.error).toContain('No models available');
226+
});
227+
228+
it('gives an actionable message when the CLI is not installed', async () => {
229+
const ws = collectingWs();
230+
231+
await expect(mod.spawnPi('hi', {
232+
cwd: tmpDir,
233+
env: { ...process.env, PI_CLI_PATH: path.join(tmpDir, 'not-installed') },
234+
}, ws)).rejects.toThrow(/Pi CLI not found/);
235+
236+
expect(ws.events.find((e) => e.type === 'pi-error').error).toContain('@earendil-works/pi-coding-agent');
237+
});
238+
239+
it('emits exactly one terminal event when the CLI is missing', async () => {
240+
// 'error' and 'close' both fire on a failed spawn. The client must not
241+
// receive a pi-complete contradicting the pi-error it was just sent.
242+
const ws = collectingWs();
243+
244+
await mod.spawnPi('hi', {
245+
cwd: tmpDir,
246+
env: { ...process.env, PI_CLI_PATH: path.join(tmpDir, 'absent') },
247+
}, ws).catch(() => {});
248+
249+
// Give the 'close' event a chance to fire after 'error'.
250+
await new Promise((resolve) => setTimeout(resolve, 200));
251+
252+
expect(ws.events.filter((e) => e.type === 'pi-error')).toHaveLength(1);
253+
expect(ws.events.filter((e) => e.type === 'pi-complete')).toHaveLength(0);
254+
});
255+
256+
it('ignores non-JSON output rather than failing the turn', async () => {
257+
const fake = await writeFakePi('pi-noise.mjs', `
258+
import { readFileSync } from 'fs';
259+
readFileSync(0, 'utf8');
260+
const out = (o) => process.stdout.write(JSON.stringify(o) + '\\n');
261+
process.stdout.write('Checking for updates...\\n');
262+
out({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() });
263+
process.stdout.write('not json either\\n');
264+
out({ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: 'ok' } });
265+
out({ type: 'agent_end', messages: [] });
266+
`);
267+
const ws = collectingWs();
268+
269+
await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws);
270+
271+
expect(ws.events.map((e) => e.type)).toContain('pi-complete');
272+
expect(ws.events.some((e) => e.type === 'pi-error')).toBe(false);
273+
});
274+
275+
it('keeps multi-byte text intact when a delta straddles a stdout chunk', async () => {
276+
const fake = await writeFakePi('pi-bytewise.mjs', `
277+
import { readFileSync } from 'fs';
278+
readFileSync(0, 'utf8');
279+
const lines = [
280+
{ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() },
281+
{ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: '请问大家有变卡的情况吗 🦞' } },
282+
{ type: 'agent_end', messages: [] },
283+
].map((o) => JSON.stringify(o)).join('\\n') + '\\n';
284+
for (const byte of Buffer.from(lines, 'utf8')) process.stdout.write(Buffer.from([byte]));
285+
`);
286+
const ws = collectingWs();
287+
288+
await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws);
289+
290+
const delta = ws.events.find((e) => e.type === 'pi-response' && e.data.type === 'text_delta');
291+
expect(delta.data.delta).toBe('请问大家有变卡的情况吗 🦞');
292+
expect(delta.data.delta).not.toContain('�');
293+
});
294+
295+
it('tracks and clears the active session', async () => {
296+
const fake = await writeFakePi('pi-happy.mjs', FAKE_PI_HAPPY_PATH);
297+
const result = await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, collectingWs());
298+
299+
expect(mod.isPiSessionActive(result.sessionId)).toBe(false);
300+
expect(mod.getActivePiSessions()).toEqual([]);
301+
});
302+
303+
it('reuses a provided session id instead of minting a new one', async () => {
304+
const fake = await writeFakePi('pi-happy.mjs', FAKE_PI_HAPPY_PATH);
305+
const ws = collectingWs();
306+
307+
const result = await mod.spawnPi('hi', {
308+
cwd: tmpDir,
309+
sessionId: 'existing-session-id',
310+
env: { ...process.env, PI_CLI_PATH: fake },
311+
}, ws);
312+
313+
expect(result.sessionId).toBe('existing-session-id');
314+
// Resuming must not announce a new session to the UI.
315+
expect(ws.events.some((e) => e.type === 'session-created')).toBe(false);
316+
});
317+
});
318+
319+
describe('abortPiSession', () => {
320+
it('stops a running turn and reports it as aborted', async () => {
321+
const fake = await writeFakePi('pi-slow.mjs', `
322+
import { readFileSync } from 'fs';
323+
readFileSync(0, 'utf8');
324+
process.stdout.write(JSON.stringify({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() }) + '\\n');
325+
setTimeout(() => {}, 60000);
326+
`);
327+
const ws = collectingWs();
328+
329+
const pending = mod.spawnPi('hi', {
330+
cwd: tmpDir,
331+
sessionId: 'abort-me',
332+
env: { ...process.env, PI_CLI_PATH: fake },
333+
}, ws);
334+
335+
// Wait for the child to actually start before aborting.
336+
await new Promise((resolve) => setTimeout(resolve, 300));
337+
expect(mod.isPiSessionActive('abort-me')).toBe(true);
338+
expect(mod.abortPiSession('abort-me')).toBe(true);
339+
340+
const result = await pending;
341+
expect(result.aborted).toBe(true);
342+
expect(ws.events.some((e) => e.type === 'pi-complete' && e.aborted)).toBe(true);
343+
// An abort is a user action, not a failure.
344+
expect(ws.events.some((e) => e.type === 'pi-error')).toBe(false);
345+
});
346+
347+
it('returns false for an unknown session', () => {
348+
expect(mod.abortPiSession('never-existed')).toBe(false);
349+
});
350+
});

0 commit comments

Comments
 (0)