From ba78777e996d74ad14800d7f6dd3a0da2800f456 Mon Sep 17 00:00:00 2001 From: David Liu Date: Wed, 5 Aug 2026 12:53:10 -0700 Subject: [PATCH] feat(pi): add the Pi coding agent (pi.dev) as a provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- docs/configuration.md | 29 ++ server/__tests__/pi-cli.test.mjs | 350 +++++++++++++ server/__tests__/pi-session-index.test.mjs | 180 +++++++ server/index.js | 36 +- server/pi-cli.js | 487 ++++++++++++++++++ server/projects.js | 396 ++++++++++++++ server/routes/cli-auth.js | 105 ++++ server/utils/piCli.js | 48 ++ shared/modelConstants.js | 33 ++ .../chat/hooks/useChatComposerState.ts | 24 + .../chat/hooks/useChatProviderState.ts | 7 +- .../chat/hooks/useChatRealtimeHandlers.ts | 96 ++++ .../chat/hooks/useChatSessionState.ts | 1 + src/components/chat/view/ChatInterface.tsx | 13 +- .../chat/view/subcomponents/ChatComposer.tsx | 14 +- .../view/ProjectDashboard.tsx | 1 + src/components/sidebar/utils/utils.ts | 7 +- src/hooks/useProjectsState.ts | 15 +- src/types/app.ts | 3 +- 19 files changed, 1834 insertions(+), 11 deletions(-) create mode 100644 server/__tests__/pi-cli.test.mjs create mode 100644 server/__tests__/pi-session-index.test.mjs create mode 100644 server/pi-cli.js create mode 100644 server/utils/piCli.js diff --git a/docs/configuration.md b/docs/configuration.md index 89d4c3a4..1e887ad7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,7 @@ Dr. Claw is configured through environment variables in a `.env` file at the pro | `CURSOR_CLI_PATH` | No | Auto-detect (`cursor-agent` then `agent`) | Override Cursor CLI command/binary. Useful when your environment only provides one alias. | | `GEMINI_CLI_PATH` | No | `gemini` | Override Gemini CLI command/binary. Useful when your shell resolves Gemini through a custom alias or path. | | `CODEX_CLI_PATH` | No | `codex` | Override Codex CLI command/binary. Useful when Codex is installed outside your default `PATH`. | +| `PI_CLI_PATH` | No | `pi` | Override the [Pi coding agent](https://pi.dev) command/binary. | ### Database @@ -69,6 +70,34 @@ Platform mode is an advanced deployment option. Most users should leave these co | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `CLAUDE_TOOL_APPROVAL_TIMEOUT_MS` | No | `55000` | Timeout in milliseconds for Claude tool-approval prompts before auto-declining. | +| `PI_MODEL` | No | `anthropic/claude-sonnet-4-6` | Default model for the Pi provider, as `provider/model`. | + +--- + +## Pi Coding Agent + +[Pi](https://pi.dev) is an MIT-licensed agent harness that fronts 15+ model +providers. Dr. Claw drives it non-interactively over its JSON event stream. + +```bash +npm install -g --ignore-scripts @earendil-works/pi-coding-agent +``` + +Then run `pi` once and use `/login` to authenticate a provider — Pi has no +credentials of its own, so with none configured it reports no available models +and Dr. Claw shows the provider as installed but not logged in. + +| Detail | Value | +|--------|-------| +| Binary | `pi` (override with `PI_CLI_PATH`) | +| Model format | `provider/model`, e.g. `anthropic/claude-sonnet-4-6`. Any slug `pi --list-models` reports is valid. | +| Sessions | `~/.pi/agent/sessions/----/_.jsonl` | +| Status endpoint | `GET /api/cli/pi/status` | + +Prompts are sent to Pi over stdin rather than as a command-line argument: Pi +parses positional arguments, so a prompt beginning with `-` is read as a flag +and one beginning with `@` as a file include — and `@` mentions are a normal +Dr. Claw feature. --- diff --git a/server/__tests__/pi-cli.test.mjs b/server/__tests__/pi-cli.test.mjs new file mode 100644 index 00000000..d1231d8e --- /dev/null +++ b/server/__tests__/pi-cli.test.mjs @@ -0,0 +1,350 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile, readFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +/** + * Drives the real Pi provider code against a fake `pi` binary: a Node script + * that speaks the same JSONL event stream the real CLI emits (captured from + * pi 0.83.0). This keeps argv construction, stdin handling, stream parsing and + * abort semantics under test without requiring Pi or any model credentials. + */ + +let tmpDir; +let mod; + +async function writeFakePi(name, body) { + const file = path.join(tmpDir, name); + await writeFile(file, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 }); + return file; +} + +// Reads the prompt from stdin (as the real CLI does) and echoes a full turn. +const FAKE_PI_HAPPY_PATH = ` +import { readFileSync } from 'fs'; +const prompt = readFileSync(0, 'utf8'); +const out = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); +out({ type: 'session', version: 3, id: 'sess-from-pi', timestamp: '2026-08-05T19:00:00.000Z', cwd: process.cwd() }); +out({ type: 'agent_start' }); +out({ type: 'turn_start' }); +out({ type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: prompt }] } }); +out({ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: 'Hello ' } }); +out({ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: 'world' } }); +out({ type: 'tool_execution_start', toolCallId: 'tc1', toolName: 'bash', args: { cmd: 'ls' } }); +out({ type: 'tool_execution_end', toolCallId: 'tc1', toolName: 'bash', result: 'file.txt', isError: false }); +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 } } }); +out({ type: 'turn_end', message: { role: 'assistant', content: [], stopReason: 'stop', usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 } }, toolResults: [] }); +out({ type: 'agent_end', messages: [] }); +`; + +beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'drclaw-pi-')); + vi.resetModules(); + mod = await import('../pi-cli.js'); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function collectingWs() { + const events = []; + return { + events, + send: (msg) => events.push(msg), + setSessionId: () => {}, + setProjectPath: () => {}, + }; +} + +describe('buildPiArgs', () => { + it('requests the JSON event stream in non-interactive mode', () => { + const args = mod.buildPiArgs({ sessionId: 'abc' }); + expect(args.slice(0, 3)).toEqual(['--mode', 'json', '-p']); + }); + + it('addresses the session by id so Dr. Claw owns session identity', () => { + const args = mod.buildPiArgs({ sessionId: 'abc-123' }); + expect(args).toContain('--session-id'); + expect(args[args.indexOf('--session-id') + 1]).toBe('abc-123'); + }); + + it('never puts the prompt in argv', () => { + // Verified against pi 0.83.0: a positional "-rf ..." is parsed as a flag and + // "@notes.md ..." as a file include, and pi has no `--` terminator. Both are + // ordinary user input, so the prompt has to travel over stdin. + const args = mod.buildPiArgs({ sessionId: 'abc', model: 'anthropic/claude-sonnet-4-6' }); + expect(args).not.toContain('--'); + expect(args.join(' ')).not.toContain('prompt'); + expect(args).toEqual(['--mode', 'json', '-p', '--session-id', 'abc', '--model', 'anthropic/claude-sonnet-4-6']); + }); + + it('passes model and thinking level through', () => { + const args = mod.buildPiArgs({ sessionId: 'a', model: 'openai/gpt-5', thinking: 'high' }); + expect(args[args.indexOf('--model') + 1]).toBe('openai/gpt-5'); + expect(args[args.indexOf('--thinking') + 1]).toBe('high'); + }); + + it('only trusts project-local files when permissions are skipped', () => { + expect(mod.buildPiArgs({ sessionId: 'a' })).not.toContain('--approve'); + expect(mod.buildPiArgs({ sessionId: 'a', skipPermissions: true })).toContain('--approve'); + }); +}); + +describe('transformPiEvent', () => { + it('maps text deltas', () => { + expect(mod.transformPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'hi' }, + })).toEqual({ type: 'text_delta', delta: 'hi' }); + }); + + it('ignores lifecycle chatter that has nothing to display', () => { + for (const type of ['agent_start', 'turn_start', 'queue_update', 'auto_retry_start', 'agent_settled']) { + expect(mod.transformPiEvent({ type })).toBeNull(); + } + }); + + it('suppresses an assistant message that stopped on error', () => { + // The caller turns this into a pi-error; emitting it as a message too would + // render an empty assistant bubble above the error. + expect(mod.transformPiEvent({ + type: 'message_end', + message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: '401' }, + })).toBeNull(); + }); + + it('maps tool execution to tool_use / tool_result', () => { + expect(mod.transformPiEvent({ + type: 'tool_execution_start', toolCallId: 't1', toolName: 'bash', args: { cmd: 'ls' }, + })).toMatchObject({ type: 'tool_use', toolName: 'bash', toolInput: { cmd: 'ls' } }); + + expect(mod.transformPiEvent({ + type: 'tool_execution_end', toolCallId: 't1', toolName: 'bash', result: 'out', isError: false, + })).toMatchObject({ type: 'tool_result', output: 'out', isError: false }); + }); +}); + +describe('buildPiTokenBudget', () => { + it('normalizes Pi usage to the shared budget shape', () => { + const budget = mod.buildPiTokenBudget({ input: 100, output: 50, cacheRead: 10, cacheWrite: 5, totalTokens: 165 }); + expect(budget).toMatchObject({ used: 165, inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheCreationTokens: 5 }); + }); + + it('falls back to summing components when totalTokens is absent', () => { + expect(mod.buildPiTokenBudget({ input: 3, output: 4 }).used).toBe(7); + }); + + it('returns null for empty usage rather than a zeroed budget', () => { + expect(mod.buildPiTokenBudget(null)).toBeNull(); + expect(mod.buildPiTokenBudget({ input: 0, output: 0 })).toBeNull(); + }); +}); + +describe('spawnPi', () => { + it('streams a full turn and completes', async () => { + const fake = await writeFakePi('pi-happy.mjs', FAKE_PI_HAPPY_PATH); + const ws = collectingWs(); + + const result = await mod.spawnPi('say hi', { + cwd: tmpDir, + model: 'anthropic/claude-sonnet-4-6', + env: { ...process.env, PI_CLI_PATH: fake }, + }, ws); + + const types = ws.events.map((e) => e.type); + expect(types).toContain('session-created'); + expect(types).toContain('pi-complete'); + expect(types).not.toContain('pi-error'); + + const responses = ws.events.filter((e) => e.type === 'pi-response').map((e) => e.data); + expect(responses.filter((d) => d.type === 'text_delta').map((d) => d.delta)).toEqual(['Hello ', 'world']); + expect(responses.some((d) => d.type === 'tool_use' && d.toolName === 'bash')).toBe(true); + expect(responses.some((d) => d.type === 'tool_result' && d.output === 'file.txt')).toBe(true); + expect(ws.events.some((e) => e.type === 'token-budget' && e.data.used === 15)).toBe(true); + expect(result.sessionId).toBeTruthy(); + }); + + it('delivers the prompt over stdin, intact, including @ and - prefixes', async () => { + const capture = path.join(tmpDir, 'captured-prompt.txt'); + const fake = await writeFakePi('pi-capture.mjs', ` +import { readFileSync, writeFileSync } from 'fs'; +writeFileSync(${JSON.stringify(capture)}, readFileSync(0, 'utf8')); +process.stdout.write(JSON.stringify({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() }) + '\\n'); +`); + + const prompt = '@notes.md -rf 请解释这段代码 🦞'; + await mod.spawnPi(prompt, { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, collectingWs()); + + expect(await readFile(capture, 'utf8')).toBe(prompt); + }); + + it('does not hang when the prompt is empty', async () => { + // Pi blocks until stdin reaches EOF, so stdin must be closed even with + // nothing to write. + const fake = await writeFakePi('pi-empty.mjs', FAKE_PI_HAPPY_PATH); + const ws = collectingWs(); + + const started = Date.now(); + await mod.spawnPi('', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws); + + expect(Date.now() - started).toBeLessThan(10_000); + expect(ws.events.map((e) => e.type)).toContain('pi-complete'); + }); + + it('surfaces a failed assistant turn as pi-error', async () => { + const fake = await writeFakePi('pi-autherr.mjs', ` +import { readFileSync } from 'fs'; +readFileSync(0, 'utf8'); +const out = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); +out({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() }); +out({ type: 'message_end', message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: '401 invalid x-api-key' } }); +out({ type: 'agent_end', messages: [] }); +`); + const ws = collectingWs(); + + await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws); + + const error = ws.events.find((e) => e.type === 'pi-error'); + expect(error.error).toContain('401'); + // No empty assistant bubble alongside the error. + expect(ws.events.filter((e) => e.type === 'pi-response')).toHaveLength(0); + }); + + it('reports stderr when the CLI dies before emitting any events', async () => { + const fake = await writeFakePi('pi-crash.mjs', ` +process.stderr.write('No models available. Use /login to log into a provider.\\n'); +process.exit(1); +`); + const ws = collectingWs(); + + await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws); + + const error = ws.events.find((e) => e.type === 'pi-error'); + expect(error.error).toContain('No models available'); + }); + + it('gives an actionable message when the CLI is not installed', async () => { + const ws = collectingWs(); + + await expect(mod.spawnPi('hi', { + cwd: tmpDir, + env: { ...process.env, PI_CLI_PATH: path.join(tmpDir, 'not-installed') }, + }, ws)).rejects.toThrow(/Pi CLI not found/); + + expect(ws.events.find((e) => e.type === 'pi-error').error).toContain('@earendil-works/pi-coding-agent'); + }); + + it('emits exactly one terminal event when the CLI is missing', async () => { + // 'error' and 'close' both fire on a failed spawn. The client must not + // receive a pi-complete contradicting the pi-error it was just sent. + const ws = collectingWs(); + + await mod.spawnPi('hi', { + cwd: tmpDir, + env: { ...process.env, PI_CLI_PATH: path.join(tmpDir, 'absent') }, + }, ws).catch(() => {}); + + // Give the 'close' event a chance to fire after 'error'. + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(ws.events.filter((e) => e.type === 'pi-error')).toHaveLength(1); + expect(ws.events.filter((e) => e.type === 'pi-complete')).toHaveLength(0); + }); + + it('ignores non-JSON output rather than failing the turn', async () => { + const fake = await writeFakePi('pi-noise.mjs', ` +import { readFileSync } from 'fs'; +readFileSync(0, 'utf8'); +const out = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); +process.stdout.write('Checking for updates...\\n'); +out({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() }); +process.stdout.write('not json either\\n'); +out({ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: 'ok' } }); +out({ type: 'agent_end', messages: [] }); +`); + const ws = collectingWs(); + + await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws); + + expect(ws.events.map((e) => e.type)).toContain('pi-complete'); + expect(ws.events.some((e) => e.type === 'pi-error')).toBe(false); + }); + + it('keeps multi-byte text intact when a delta straddles a stdout chunk', async () => { + const fake = await writeFakePi('pi-bytewise.mjs', ` +import { readFileSync } from 'fs'; +readFileSync(0, 'utf8'); +const lines = [ + { type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() }, + { type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_delta', delta: '请问大家有变卡的情况吗 🦞' } }, + { type: 'agent_end', messages: [] }, +].map((o) => JSON.stringify(o)).join('\\n') + '\\n'; +for (const byte of Buffer.from(lines, 'utf8')) process.stdout.write(Buffer.from([byte])); +`); + const ws = collectingWs(); + + await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, ws); + + const delta = ws.events.find((e) => e.type === 'pi-response' && e.data.type === 'text_delta'); + expect(delta.data.delta).toBe('请问大家有变卡的情况吗 🦞'); + expect(delta.data.delta).not.toContain('�'); + }); + + it('tracks and clears the active session', async () => { + const fake = await writeFakePi('pi-happy.mjs', FAKE_PI_HAPPY_PATH); + const result = await mod.spawnPi('hi', { cwd: tmpDir, env: { ...process.env, PI_CLI_PATH: fake } }, collectingWs()); + + expect(mod.isPiSessionActive(result.sessionId)).toBe(false); + expect(mod.getActivePiSessions()).toEqual([]); + }); + + it('reuses a provided session id instead of minting a new one', async () => { + const fake = await writeFakePi('pi-happy.mjs', FAKE_PI_HAPPY_PATH); + const ws = collectingWs(); + + const result = await mod.spawnPi('hi', { + cwd: tmpDir, + sessionId: 'existing-session-id', + env: { ...process.env, PI_CLI_PATH: fake }, + }, ws); + + expect(result.sessionId).toBe('existing-session-id'); + // Resuming must not announce a new session to the UI. + expect(ws.events.some((e) => e.type === 'session-created')).toBe(false); + }); +}); + +describe('abortPiSession', () => { + it('stops a running turn and reports it as aborted', async () => { + const fake = await writeFakePi('pi-slow.mjs', ` +import { readFileSync } from 'fs'; +readFileSync(0, 'utf8'); +process.stdout.write(JSON.stringify({ type: 'session', version: 3, id: 's', timestamp: '', cwd: process.cwd() }) + '\\n'); +setTimeout(() => {}, 60000); +`); + const ws = collectingWs(); + + const pending = mod.spawnPi('hi', { + cwd: tmpDir, + sessionId: 'abort-me', + env: { ...process.env, PI_CLI_PATH: fake }, + }, ws); + + // Wait for the child to actually start before aborting. + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(mod.isPiSessionActive('abort-me')).toBe(true); + expect(mod.abortPiSession('abort-me')).toBe(true); + + const result = await pending; + expect(result.aborted).toBe(true); + expect(ws.events.some((e) => e.type === 'pi-complete' && e.aborted)).toBe(true); + // An abort is a user action, not a failure. + expect(ws.events.some((e) => e.type === 'pi-error')).toBe(false); + }); + + it('returns false for an unknown session', () => { + expect(mod.abortPiSession('never-existed')).toBe(false); + }); +}); diff --git a/server/__tests__/pi-session-index.test.mjs b/server/__tests__/pi-session-index.test.mjs new file mode 100644 index 00000000..2c0f5e0b --- /dev/null +++ b/server/__tests__/pi-session-index.test.mjs @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import fsSync from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * Session discovery for Pi. The transcript layout here matches real files + * written by pi 0.83.0, including the directory-name encoding + * (`/private/tmp/a/b` -> `--private-tmp-a-b--`) and the header-first JSONL. + */ + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalDatabasePath = process.env.DATABASE_PATH; + +let tempRoot; +let projectRoot; + +async function loadModules() { + vi.resetModules(); + const projects = await import('../projects.js'); + const piCli = await import('../utils/piCli.js'); + return { projects, piCli }; +} + +function sessionLines({ sessionId, cwd, prompt = 'hello', reply = 'hi there', timestamp = '2026-08-05T19:00:00.000Z' }) { + return [ + { type: 'session', version: 3, id: sessionId, timestamp, cwd }, + { type: 'model_change', id: 'm1', parentId: null, timestamp, provider: 'anthropic', modelId: 'claude-sonnet-4-6' }, + { type: 'message', id: 'u1', parentId: 'm1', timestamp, message: { role: 'user', content: [{ type: 'text', text: prompt }] } }, + { type: 'message', id: 'a1', parentId: 'u1', timestamp, message: { role: 'assistant', content: [{ type: 'text', text: reply }], stopReason: 'stop' } }, + ].map((entry) => JSON.stringify(entry)).join('\n') + '\n'; +} + +async function writeSession({ sessionId, cwd, prompt, reply, fileName }) { + // Mirrors Pi's own directory naming. + const dirName = `--${path.resolve(cwd).replace(/^\/+/, '').replace(/\//g, '-')}--`; + const dir = path.join(tempRoot, '.pi', 'agent', 'sessions', dirName); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, fileName || `2026-08-05T19-00-00-000Z_${sessionId}.jsonl`); + await writeFile(file, sessionLines({ sessionId, cwd, prompt, reply }), 'utf8'); + return file; +} + +beforeEach(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), 'drclaw-pi-index-')); + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db'); + + projectRoot = path.join(tempRoot, 'workspace', 'demo'); + await mkdir(projectRoot, { recursive: true }); +}); + +afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + process.env.DATABASE_PATH = originalDatabasePath; + vi.restoreAllMocks(); +}); + +describe('encodePiSessionDirName', () => { + it('matches the layout pi writes on disk', async () => { + const { piCli } = await loadModules(); + // Captured from pi 0.83.0. + expect(piCli.encodePiSessionDirName('/private/tmp/a/b')).toBe('--private-tmp-a-b--'); + expect(piCli.encodePiSessionDirName('/Users/me/project')).toBe('--Users-me-project--'); + }); +}); + +describe('buildPiSessionsIndex', () => { + it('groups sessions by the cwd recorded in the header', async () => { + await writeSession({ sessionId: 'pi-1', cwd: projectRoot, prompt: 'first question' }); + const { projects } = await loadModules(); + + const index = await projects.buildPiSessionsIndex(); + const sessions = [...index.values()].flat(); + + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe('pi-1'); + expect(sessions[0].summary).toBe('first question'); + expect(sessions[0].messageCount).toBe(2); + expect(sessions[0].model).toBe('anthropic/claude-sonnet-4-6'); + }); + + it('trusts the header cwd over the directory name', async () => { + // A path segment containing '-' is indistinguishable from a separator once + // encoded, so the directory name alone cannot identify the project. + const trickyPath = path.join(tempRoot, 'work-space', 'my-project'); + await mkdir(trickyPath, { recursive: true }); + await writeSession({ sessionId: 'pi-tricky', cwd: trickyPath }); + + const { projects } = await loadModules(); + const sessions = await projects.getPiSessions(trickyPath, { limit: 0 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe('pi-tricky'); + }); + + it('does not re-read transcripts whose size and mtime are unchanged', async () => { + await writeSession({ sessionId: 'pi-cache', cwd: projectRoot }); + const { projects } = await loadModules(); + + await projects.buildPiSessionsIndex(); + + const createReadStream = vi.spyOn(fsSync, 'createReadStream'); + await projects.buildPiSessionsIndex(); + + expect(createReadStream).not.toHaveBeenCalled(); + }); + + it('drops cache entries for deleted transcripts', async () => { + const file = await writeSession({ sessionId: 'pi-gone', cwd: projectRoot }); + const { projects } = await loadModules(); + + expect([...(await projects.buildPiSessionsIndex()).values()].flat()).toHaveLength(1); + await rm(file); + expect([...(await projects.buildPiSessionsIndex()).values()].flat()).toHaveLength(0); + }); + + it('skips malformed transcripts without failing the whole scan', async () => { + await writeSession({ sessionId: 'pi-good', cwd: projectRoot }); + const dirName = `--${path.resolve(projectRoot).replace(/^\/+/, '').replace(/\//g, '-')}--`; + await writeFile(path.join(tempRoot, '.pi', 'agent', 'sessions', dirName, 'broken.jsonl'), '{not json\n', 'utf8'); + + const { projects } = await loadModules(); + const sessions = [...(await projects.buildPiSessionsIndex()).values()].flat(); + + expect(sessions.map((s) => s.id)).toEqual(['pi-good']); + }); +}); + +describe('getPiSessionMessages', () => { + it('returns user and assistant turns in order', async () => { + await writeSession({ sessionId: 'pi-msg', cwd: projectRoot, prompt: 'what is 2+2', reply: 'four' }); + const { projects } = await loadModules(); + + const { messages, total } = await projects.getPiSessionMessages('pi-msg'); + + expect(total).toBe(2); + expect(messages[0]).toMatchObject({ type: 'user', message: { content: 'what is 2+2' } }); + expect(messages[1]).toMatchObject({ type: 'assistant', message: { content: 'four' } }); + }); + + it('finds a session whose filename does not contain the id', async () => { + await writeSession({ sessionId: 'pi-hidden', cwd: projectRoot, fileName: 'opaque-name.jsonl' }); + const { projects } = await loadModules(); + + const { messages } = await projects.getPiSessionMessages('pi-hidden'); + expect(messages.length).toBeGreaterThan(0); + }); + + it('returns empty rather than throwing for an unknown session', async () => { + const { projects } = await loadModules(); + const result = await projects.getPiSessionMessages('does-not-exist'); + expect(result).toEqual({ messages: [], total: 0, hasMore: false }); + }); +}); + +describe('deletePiSession', () => { + it('removes the transcript from disk', async () => { + const file = await writeSession({ sessionId: 'pi-del', cwd: projectRoot }); + const { projects } = await loadModules(); + const database = await import('../database/db.js'); + await database.initializeDatabase(); + + expect(await projects.deletePiSession('demo', 'pi-del')).toBe(true); + expect(fsSync.existsSync(file)).toBe(false); + }); + + it('reports false when nothing matched', async () => { + const { projects } = await loadModules(); + const database = await import('../database/db.js'); + await database.initializeDatabase(); + + expect(await projects.deletePiSession('demo', 'never-existed')).toBe(false); + }); +}); diff --git a/server/index.js b/server/index.js index 3b83e75c..6ce5ff7f 100755 --- a/server/index.js +++ b/server/index.js @@ -51,6 +51,7 @@ import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getGeminiSessio import { queryOpenRouter, abortOpenRouterSession, isOpenRouterSessionActive, getOpenRouterSessionStartTime, getActiveOpenRouterSessions } from './openrouter.js'; import { queryLocalGPU, abortLocalGPUSession, isLocalGPUSessionActive, getLocalGPUSessionStartTime, getActiveLocalGPUSessions } from './local-gpu.js'; import { spawnNanoClaudeCode, abortNanoClaudeCodeSession, isNanoClaudeCodeSessionActive, getNanoClaudeCodeSessionStartTime, getActiveNanoClaudeCodeSessions } from './nano-claude-code.js'; +import { spawnPi, abortPiSession, isPiSessionActive, getPiSessionStartTime, getActivePiSessions } from './pi-cli.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import mcpRoutes from './routes/mcp.js'; @@ -90,6 +91,7 @@ import { } from './utils/runtimePorts.js'; import { buildCodexTokenUsageFromJsonl } from './utils/sessionTokenUsage.js'; import { getNanoDrClawSessionsRoot } from './nanoSessionPaths.js'; +import { getPiSessionsRoot } from './utils/piCli.js'; // File system watchers for provider project/session folders const PROVIDER_WATCH_PATHS = [ @@ -98,6 +100,7 @@ const PROVIDER_WATCH_PATHS = [ { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }, { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }, { provider: 'nano', rootPath: getNanoDrClawSessionsRoot() }, + { provider: 'pi', rootPath: getPiSessionsRoot() }, ]; const WATCHER_IGNORED_PATTERNS = [ '**/node_modules/**', @@ -123,7 +126,7 @@ function shouldProcessProjectsWatcherEvent(eventType, filePath, provider) { } const normalized = String(filePath || '').toLowerCase(); - if (provider === 'claude' || provider === 'codex' || provider === 'gemini') { + if (provider === 'claude' || provider === 'codex' || provider === 'gemini' || provider === 'pi') { return normalized.endsWith('.jsonl'); } @@ -1624,6 +1627,35 @@ function handleChatConnection(ws, request) { queryCodex(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Codex query error:', error); }); + } else if (data.type === 'pi-command') { + console.log('[DEBUG] Pi message:', data.command || '[Continue/Resume]'); + console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown'); + console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); + console.log('🤖 Model:', data.options?.model || 'default'); + const commandTelemetryEnabled = data.options?.telemetryEnabled !== false; + const sessionId = data.options?.sessionId || data.sessionId; + + if (sessionId && isPiSessionActive(sessionId)) { + console.log(`[WARN] Pi session ${sessionId} is already active. Ignoring concurrent request.`); + return; + } + + enqueueConversationTelemetry( + { + name: 'agent_dialogue_meta', + direction: 'user_to_agent', + provider: 'pi', + sessionId: sessionId || null, + projectPath: data.options?.projectPath || data.options?.cwd || null, + transportType: data.type, + }, + { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled }, + ); + writer.telemetryContext = { ...telemetryContext, provider: 'pi', telemetryEnabled: commandTelemetryEnabled }; + writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + spawnPi(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { + console.error('[ERROR] Pi query error:', error); + }); } else if (data.type === 'gemini-command') { console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown'); @@ -1776,6 +1808,8 @@ function handleChatConnection(ws, request) { success = abortLocalGPUSession(data.sessionId); } else if (provider === 'nano') { success = abortNanoClaudeCodeSession(data.sessionId); + } else if (provider === 'pi') { + success = abortPiSession(data.sessionId); } else { // Use Claude Agents SDK success = await abortClaudeSDKSession(data.sessionId); diff --git a/server/pi-cli.js b/server/pi-cli.js new file mode 100644 index 00000000..3d0921c0 --- /dev/null +++ b/server/pi-cli.js @@ -0,0 +1,487 @@ +/** + * Pi Coding Agent Integration (https://pi.dev) + * ============================================ + * + * Pi is a minimal, MIT-licensed agent harness that fronts 15+ model providers. + * Dr. Claw drives it non-interactively: + * + * pi --mode json -p --session-id --model + * + * `--mode json` emits one JSON object per line: a session header first, then + * agent/turn/message/tool events. `--session-id` addresses a session by id and + * creates it if missing, which maps cleanly onto Dr. Claw's model of "a session + * is a uuid" without needing Pi's interactive resume picker. + * + * Exports mirror the other providers so server/index.js can treat them alike: + * spawnPi(command, options, ws) + * abortPiSession(sessionId) + * isPiSessionActive(sessionId) + * getPiSessionStartTime(sessionId) + * getActivePiSessions() + */ + +import { spawn } from 'child_process'; +import crossSpawn from 'cross-spawn'; +import crypto from 'crypto'; +import { StringDecoder } from 'string_decoder'; + +import { getPiCliCommand } from './utils/piCli.js'; +import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js'; +import { classifyError } from '../shared/errorClassifier.js'; + +// cross-spawn resolves .cmd shims correctly on Windows. +const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; + +const activePiSessions = new Map(); // sessionId -> { process, startTime, aborted } + +/** + * Split a stream into complete lines. + * + * StringDecoder rather than chunk.toString(): a multi-byte UTF-8 character can + * straddle two 'data' events, and decoding each chunk independently turns it + * into replacement characters — which for a CJK conversation means corrupting + * the model's actual output. + */ +function createLineSplitter(onLine) { + const decoder = new StringDecoder('utf8'); + let buffer = ''; + + return { + push(chunk) { + buffer += decoder.write(chunk); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).replace(/\r$/, ''); + buffer = buffer.slice(newlineIndex + 1); + if (line.trim()) onLine(line); + } + }, + flush() { + buffer += decoder.end(); + const line = buffer.replace(/\r$/, ''); + buffer = ''; + if (line.trim()) onLine(line); + }, + }; +} + +/** Concatenate the text blocks of a Pi message's content array. */ +function extractText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter((block) => block?.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join(''); +} + +function extractThinking(content) { + if (!Array.isArray(content)) return ''; + return content + .filter((block) => block?.type === 'thinking' && typeof block.thinking === 'string') + .map((block) => block.thinking) + .join(''); +} + +/** + * Translate a Pi event into the payload Dr. Claw's chat UI consumes. + * + * Returns null for events with nothing to display — Pi emits a lot of + * lifecycle chatter (queue updates, retry bookkeeping) that would only add + * noise to the transcript. + */ +export function transformPiEvent(event) { + if (!event || typeof event !== 'object') return null; + + switch (event.type) { + case 'message_update': { + // Incremental assistant text. Pi also emits thinking deltas; those are + // surfaced separately so the UI can collapse them. + const delta = event.assistantMessageEvent; + if (delta?.type === 'text_delta' && typeof delta.delta === 'string' && delta.delta) { + return { type: 'text_delta', delta: delta.delta }; + } + if (delta?.type === 'thinking_delta' && typeof delta.delta === 'string' && delta.delta) { + return { type: 'thinking_delta', delta: delta.delta }; + } + return null; + } + + case 'message_end': { + const message = event.message; + if (!message || message.role !== 'assistant') return null; + + // An assistant message that stopped on error carries no content; the + // caller turns this into a pi-error rather than an empty bubble. + if (message.stopReason === 'error') return null; + + const text = extractText(message.content); + const thinking = extractThinking(message.content); + if (!text.trim() && !thinking.trim()) return null; + + return { + type: 'assistant_message', + message: { role: 'assistant', content: text }, + thinking: thinking.trim() ? thinking : undefined, + model: message.model, + provider: message.provider, + }; + } + + case 'tool_execution_start': + return { + type: 'tool_use', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolInput: event.args ?? {}, + }; + + case 'tool_execution_end': + return { + type: 'tool_result', + toolCallId: event.toolCallId, + toolName: event.toolName, + isError: Boolean(event.isError), + output: typeof event.result === 'string' ? event.result : JSON.stringify(event.result ?? null), + }; + + default: + return null; + } +} + +/** Pi reports usage per assistant message; normalize to Dr. Claw's budget shape. */ +export function buildPiTokenBudget(usage, contextWindow = null) { + if (!usage || typeof usage !== 'object') return null; + + const used = Number(usage.totalTokens) + || (Number(usage.input) || 0) + (Number(usage.output) || 0) + + (Number(usage.cacheRead) || 0) + (Number(usage.cacheWrite) || 0); + + if (!used) return null; + + return { + used, + total: Number(contextWindow) || Number(process.env.CONTEXT_WINDOW) || 200000, + inputTokens: Number(usage.input) || 0, + outputTokens: Number(usage.output) || 0, + cacheReadTokens: Number(usage.cacheRead) || 0, + cacheCreationTokens: Number(usage.cacheWrite) || 0, + }; +} + +/** + * Build the argv for one non-interactive Pi run. + * + * Exported for tests: argv construction is where provider integrations + * usually go wrong, and it is pure. + */ +export function buildPiArgs({ sessionId, model, thinking, skipPermissions, extraArgs = [] }) { + const args = ['--mode', 'json', '-p']; + + if (sessionId) { + // Addresses the session by id, creating it when absent. This lets Dr. Claw + // own session identity instead of relying on Pi's "most recent" heuristics, + // which would be wrong the moment two sessions run in one directory. + args.push('--session-id', sessionId); + } + + if (model) { + args.push('--model', model); + } + + if (thinking) { + args.push('--thinking', thinking); + } + + if (skipPermissions) { + // Trust project-local extensions/skills for this run. + args.push('--approve'); + } + + args.push(...extraArgs); + + // The prompt is deliberately NOT an argv entry — it is piped on stdin. Pi + // parses positional arguments, and verified against pi 0.83.0: + // "-rf please" -> Error: Unknown option: -rf please + // "@notes.md explain" -> Error: File not found: .../notes.md explain + // Pi has no `--` end-of-options terminator ("Error: Unknown option: --"), so + // argv cannot carry an arbitrary user prompt safely. Both cases are ordinary + // user input here, and '@' file mentions are a Dr. Claw feature, so a prompt + // beginning with '@' is expected rather than exotic. Pi's documented stdin + // piping accepts any bytes, so that is what we use. + return args; +} + +/** + * Run one Pi turn, streaming events to the websocket writer. + * + * @param {string} command Prompt text. + * @param {object} options { sessionId, projectPath, cwd, model, thinking, sessionMode, ... } + * @param {object} ws Writer with .send() + */ +export async function spawnPi(command, options = {}, ws) { + const { + sessionId, + projectPath, + cwd, + model, + thinking, + skipPermissions, + sessionMode, + stageTagKeys, + stageTagSource = 'task_context', + env, + } = options; + + const workingDir = cwd || projectPath || process.cwd(); + const piCommand = getPiCliCommand(env || process.env); + + // Pi creates the session on demand for a --session-id it does not know, so we + // can mint the id up front and report it immediately. The UI therefore has a + // stable session to attach to before the model produces its first token. + const effectiveSessionId = sessionId || crypto.randomUUID(); + const isNewSession = !sessionId; + + if (workingDir) { + applyStageTagsToSession({ + sessionId: effectiveSessionId, + projectPath: workingDir, + stageTagKeys, + source: stageTagSource, + }); + } + + const args = buildPiArgs({ sessionId: effectiveSessionId, model, thinking, skipPermissions }); + + return new Promise((resolve, reject) => { + let piProcess; + try { + piProcess = spawnFunction(piCommand, args, { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...(env || process.env) }, + }); + } catch (error) { + ws.send({ + type: 'pi-error', + error: `Failed to start Pi CLI: ${error.message}`, + sessionId: effectiveSessionId, + }); + reject(error); + return; + } + + // Write the prompt and close stdin. Pi blocks until it sees EOF on stdin, + // so the end() is mandatory even for an empty prompt — leaving the pipe open + // hangs the turn forever with no output and a running timer, exactly the + // symptom this app is trying to stop having. + try { + if (command && command.trim()) { + piProcess.stdin?.write(command); + } + piProcess.stdin?.end(); + } catch (error) { + // EPIPE if the child died between spawn and write; the 'error'/'close' + // handlers below report it. + } + + const startTime = Date.now(); + activePiSessions.set(effectiveSessionId, { process: piProcess, startTime, aborted: false }); + + let settled = false; + let stderrBuffer = ''; + let sawSessionHeader = false; + let reportedError = null; + let latestUsage = null; + + const finish = (fn, value) => { + if (settled) return; + settled = true; + activePiSessions.delete(effectiveSessionId); + fn(value); + }; + + try { + if (ws.setSessionId && typeof ws.setSessionId === 'function') { + ws.setSessionId(effectiveSessionId); + } + } catch (error) { + // A writer that throws here must not strand the child process or leave a + // stale entry in activePiSessions. + try { piProcess.kill(); } catch (_) { /* already gone */ } + finish(reject, error); + return; + } + + const emitSessionCreated = (headerCwd) => { + if (sawSessionHeader) return; + sawSessionHeader = true; + + if (!isNewSession) return; + + recordIndexedSession({ + sessionId: effectiveSessionId, + provider: 'pi', + projectPath: headerCwd || workingDir, + sessionMode: sessionMode || 'research', + stageTagKeys, + tagSource: stageTagSource, + }); + + ws.send({ + type: 'session-created', + sessionId: effectiveSessionId, + provider: 'pi', + mode: sessionMode || 'research', + startTime, + }); + }; + + const handleLine = (line) => { + let event; + try { + event = JSON.parse(line); + } catch (_) { + // Pi may print non-JSON notices before the stream begins. + return; + } + + if (event.type === 'session') { + emitSessionCreated(event.cwd); + return; + } + + // An assistant turn that failed carries its cause in errorMessage rather + // than in a dedicated error event. + const failedMessage = (event.type === 'message_end' || event.type === 'turn_end') + && event.message?.role === 'assistant' + && event.message?.stopReason === 'error'; + + if (failedMessage && !reportedError) { + reportedError = event.message.errorMessage || 'Pi reported an error'; + const { errorType, isRetryable } = classifyError(reportedError); + ws.send({ + type: 'pi-error', + error: reportedError, + errorType, + isRetryable, + sessionId: effectiveSessionId, + }); + return; + } + + if (event.message?.usage) { + latestUsage = event.message.usage; + } + + const transformed = transformPiEvent(event); + if (transformed) { + ws.send({ type: 'pi-response', data: transformed, sessionId: effectiveSessionId }); + } + + if (event.type === 'turn_end' && latestUsage) { + const budget = buildPiTokenBudget(latestUsage); + if (budget) { + ws.send({ type: 'token-budget', data: budget, sessionId: effectiveSessionId }); + } + } + }; + + const stdoutSplitter = createLineSplitter(handleLine); + piProcess.stdout?.on('data', (chunk) => stdoutSplitter.push(chunk)); + + piProcess.stderr?.on('data', (chunk) => { + // Bounded so a noisy failure cannot grow this without limit. + if (stderrBuffer.length < 16384) stderrBuffer += chunk.toString(); + }); + + piProcess.on('error', (error) => { + const message = error.code === 'ENOENT' + ? `Pi CLI not found. Install it with "npm install -g --ignore-scripts @earendil-works/pi-coding-agent", or set PI_CLI_PATH.` + : `Pi CLI failed to start: ${error.message}`; + + ws.send({ type: 'pi-error', error: message, sessionId: effectiveSessionId }); + finish(reject, new Error(message)); + }); + + piProcess.on('close', (code) => { + // 'error' and 'close' both fire when a spawn fails. The promise is already + // guarded, but the websocket is not: without this the client would receive + // a pi-complete (or a second pi-error) contradicting the failure it was + // just told about. + if (settled) return; + + stdoutSplitter.flush(); + + const session = activePiSessions.get(effectiveSessionId); + const wasAborted = Boolean(session?.aborted); + + if (wasAborted) { + ws.send({ type: 'pi-complete', sessionId: effectiveSessionId, aborted: true }); + finish(resolve, { sessionId: effectiveSessionId, aborted: true }); + return; + } + + if (code !== 0 && !reportedError) { + // A non-zero exit with nothing on the event stream means Pi never got + // far enough to report through JSON — surface stderr so the user sees + // the actual cause (missing credentials, unknown model, ...). + const message = stderrBuffer.trim() || `Pi CLI exited with code ${code}`; + const { errorType, isRetryable } = classifyError(message); + ws.send({ + type: 'pi-error', + error: message, + errorType, + isRetryable, + sessionId: effectiveSessionId, + }); + finish(resolve, { sessionId: effectiveSessionId, error: message }); + return; + } + + ws.send({ + type: 'pi-complete', + sessionId: effectiveSessionId, + actualSessionId: effectiveSessionId, + }); + finish(resolve, { sessionId: effectiveSessionId }); + }); + }); +} + +export function abortPiSession(sessionId) { + const session = activePiSessions.get(sessionId); + if (!session) return false; + + session.aborted = true; + try { + session.process.kill('SIGTERM'); + // Escalate: a harness that traps SIGTERM would otherwise keep running with + // its pipes attached long after the user pressed stop. + const killTimer = setTimeout(() => { + try { session.process.kill('SIGKILL'); } catch (_) { /* already gone */ } + }, 2000); + killTimer.unref?.(); + } catch (error) { + console.warn(`[Pi] Failed to abort session ${sessionId}:`, error.message); + return false; + } + + return true; +} + +export function isPiSessionActive(sessionId) { + return activePiSessions.has(sessionId); +} + +export function getPiSessionStartTime(sessionId) { + return activePiSessions.get(sessionId)?.startTime; +} + +export function getActivePiSessions() { + return Array.from(activePiSessions.entries()).map(([sessionId, session]) => ({ + sessionId, + startTime: session.startTime, + })); +} diff --git a/server/projects.js b/server/projects.js index 3862b022..b558026d 100755 --- a/server/projects.js +++ b/server/projects.js @@ -85,6 +85,7 @@ import { readExplicitSessionModeFromMetadata, } from './utils/sessionMode.js'; import { resolveNanoSessionAbsPath, safeNanoSessionFilename } from './nanoSessionPaths.js'; +import { getPiSessionsRoot } from './utils/piCli.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DRCLAW_SKILLS_DIR = path.join(__dirname, '..', 'skills'); @@ -1484,6 +1485,7 @@ async function getProjects(userId, progressCallback = null) { const openrouterSessions = projectSessions.filter((session) => session.provider === 'openrouter'); const localSessions = projectSessions.filter((session) => session.provider === 'local'); const nanoSessions = projectSessions.filter((session) => session.provider === 'nano'); + const piSessions = projectSessions.filter((session) => session.provider === 'pi'); project.sessions = claudeSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'claude')); project.sessionMeta = { @@ -1496,6 +1498,7 @@ async function getProjects(userId, progressCallback = null) { project.openrouterSessions = openrouterSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'openrouter')); project.localSessions = localSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'local')); project.nanoSessions = nanoSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'nano')); + project.piSessions = piSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'pi')); const taskmasterResult = await detectTaskMasterFolder(actualProjectDir).catch(() => null); @@ -2107,6 +2110,9 @@ function nanoSessionJsonToMessages(data) { // Get messages for a specific session with pagination support async function getSessionMessages(projectName, sessionId, limit = null, offset = 0, provider = 'claude', userId = null) { console.log(`[DEBUG] getSessionMessages - project: ${projectName}, session: ${sessionId}, provider: ${provider}`); + if (provider === 'pi') { + return getPiSessionMessages(sessionId, limit, offset); + } if (provider === 'gemini') { const geminiSessionFile = path.join(os.homedir(), '.gemini', 'sessions', `${sessionId}.jsonl`); console.log(`[DEBUG] Reading Gemini session file: ${geminiSessionFile}`); @@ -2534,6 +2540,14 @@ async function deleteSession(projectName, sessionId, provider = 'claude') { const { sessionDb } = await import('./database/db.js'); const indexedSession = sessionDb.getSessionById(sessionId); + if (provider === 'pi') { + const deleted = await deletePiSession(projectName, sessionId); + if (!deleted) { + throw new Error(`Pi session ${sessionId} not found in file system or index`); + } + return true; + } + if (provider === 'gemini') { const geminiSessionFile = path.join(os.homedir(), '.gemini', 'sessions', `${sessionId}.jsonl`); let deletedFile = false; @@ -4492,6 +4506,382 @@ async function renameSession(projectName, sessionId, newSummary, provider = 'cla } } +// --------------------------------------------------------------------------- +// Pi coding agent (https://pi.dev) sessions +// +// Pi writes one JSONL file per session under +// ~/.pi/agent/sessions/----/_.jsonl +// The first line is a header carrying the session id and the real `cwd`. The +// directory name is a lossy encoding of that path (a path segment containing +// '-' is indistinguishable from a separator), so the header is the authority +// for which project a session belongs to. +// --------------------------------------------------------------------------- + +/** + * Memo keyed on (size, mtimeMs) so repeated index builds do not re-read every + * transcript. Session directories only grow, and re-parsing all of them on + * every projects refresh is what made Codex discovery pathological. + */ +const piSessionFileCache = new Map(); + +function resetPiSessionFileCache() { + piSessionFileCache.clear(); +} + +async function findPiSessionFiles(sessionsRoot) { + const files = []; + let dirEntries; + try { + dirEntries = await fs.readdir(sessionsRoot, { withFileTypes: true }); + } catch (error) { + return files; + } + + for (const dirEntry of dirEntries) { + if (!dirEntry.isDirectory()) continue; + const projectDir = path.join(sessionsRoot, dirEntry.name); + try { + const sessionFiles = await fs.readdir(projectDir); + for (const fileName of sessionFiles) { + if (fileName.endsWith('.jsonl')) { + files.push(path.join(projectDir, fileName)); + } + } + } catch (_) { + // Unreadable directory; skip. + } + } + + return files; +} + +function summarizePiText(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) return ''; + const firstLine = trimmed.split('\n')[0]; + return firstLine.length > 50 ? `${firstLine.slice(0, 50)}...` : firstLine; +} + +function extractPiMessageText(message) { + if (!message) return ''; + const content = message.content; + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter((block) => block?.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join(''); +} + +async function parsePiSessionFile(filePath) { + const fileStream = fsSync.createReadStream(filePath); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + let header = null; + let firstUserMessage = null; + let lastTimestamp = null; + let messageCount = 0; + let model = null; + let sessionName = null; + + try { + for await (const line of rl) { + if (!line.trim()) continue; + let entry; + try { + entry = JSON.parse(line); + } catch (_) { + continue; // Skip malformed lines + } + + if (entry.type === 'session') { + header = entry; + lastTimestamp = entry.timestamp || lastTimestamp; + sessionName = entry.name || sessionName; + continue; + } + + if (entry.timestamp) lastTimestamp = entry.timestamp; + if (entry.type === 'model_change' && entry.modelId) { + model = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; + } + + if (entry.type === 'message' && entry.message) { + const role = entry.message.role; + if (role === 'user' || role === 'assistant') { + messageCount += 1; + } + if (role === 'user' && !firstUserMessage) { + const text = extractPiMessageText(entry.message); + if (text.trim()) firstUserMessage = text; + } + } + } + } finally { + rl.close(); + fileStream.destroy(); + } + + if (!header?.id) return null; + + return { + id: header.id, + cwd: header.cwd, + summary: sessionName || summarizePiText(firstUserMessage) || 'Pi Session', + messageCount, + model, + timestamp: lastTimestamp || header.timestamp, + createdAt: header.timestamp, + filePath, + provider: 'pi', + }; +} + +async function readPiSessionFileCached(filePath) { + const stats = await fs.stat(filePath); + const cached = piSessionFileCache.get(filePath); + if (cached && cached.size === stats.size && cached.mtimeMs === stats.mtimeMs) { + return cached.result; + } + + const result = await parsePiSessionFile(filePath); + piSessionFileCache.set(filePath, { size: stats.size, mtimeMs: stats.mtimeMs, result }); + return result; +} + +async function buildPiSessionsIndex() { + const sessionsRoot = getPiSessionsRoot(); + const sessionsByProject = new Map(); + + const files = await findPiSessionFiles(sessionsRoot); + const liveFiles = new Set(files); + for (const cachedPath of piSessionFileCache.keys()) { + if (!liveFiles.has(cachedPath)) piSessionFileCache.delete(cachedPath); + } + + const normalizedCache = new Map(); + const normalizeCwd = async (cwd) => { + if (!cwd) return ''; + if (normalizedCache.has(cwd)) return normalizedCache.get(cwd); + const normalized = await normalizeComparablePath(cwd); + normalizedCache.set(cwd, normalized); + return normalized; + }; + + for (const filePath of files) { + try { + const session = await readPiSessionFileCached(filePath); + if (!session?.id) continue; + + const normalizedProjectPath = await normalizeCwd(session.cwd); + if (!normalizedProjectPath) continue; + + if (!sessionsByProject.has(normalizedProjectPath)) { + sessionsByProject.set(normalizedProjectPath, []); + } + sessionsByProject.get(normalizedProjectPath).push({ + ...session, + lastActivity: session.timestamp ? new Date(session.timestamp) : new Date(), + }); + } catch (error) { + console.warn(`Could not parse Pi session file ${filePath}:`, error.message); + } + } + + for (const sessions of sessionsByProject.values()) { + sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + } + + return sessionsByProject; +} + +async function getPiSessions(projectPath, options = {}) { + const { limit = 5, sessionId: targetSessionId = null, projectName: providedProjectName = null } = options; + const projectName = providedProjectName || encodeProjectPath(projectPath); + + try { + const { sessionDb } = await import('./database/db.js'); + const normalizedProjectPath = await normalizeComparablePath(projectPath); + if (!normalizedProjectPath) return []; + + const sessionsByProject = await buildPiSessionsIndex(); + const sessions = [...(sessionsByProject.get(normalizedProjectPath) || [])]; + + const dbSessionMap = new Map( + sessionDb.getSessionsByProject(projectName) + .filter((session) => session.provider === 'pi') + .map((session) => [session.id, session]), + ); + + const hydrated = sessions.map((session) => { + const indexed = dbSessionMap.get(session.id); + return { + ...session, + projectPath, + mode: indexed + ? (readExplicitSessionModeFromMetadata(indexed.metadata) || normalizeSessionMode(session.mode)) + : normalizeSessionMode(session.mode), + tags: Array.isArray(indexed?.tags) ? indexed.tags : [], + }; + }); + + const filtered = targetSessionId ? hydrated.filter((s) => s.id === targetSessionId) : hydrated; + return limit > 0 ? filtered.slice(0, limit) : filtered; + } catch (error) { + console.error('Error fetching Pi sessions:', error); + return []; + } +} + +/** Bring the session index in the database up to date with Pi's files on disk. */ +async function reconcilePiSessionIndex(projectPath, options = {}) { + const { sessionId = null, projectName: providedProjectName = null } = options; + const projectName = providedProjectName || encodeProjectPath(projectPath); + + try { + const { sessionDb } = await import('./database/db.js'); + const sessions = await getPiSessions(projectPath, { limit: 0, sessionId, projectName }); + + for (const session of sessions) { + sessionDb.upsertSessionFromSource(session.id, projectName, 'pi', { + displayName: session.summary || 'Pi Session', + lastActivity: session.lastActivity || new Date(), + messageCount: session.messageCount || 0, + createdAt: session.createdAt || session.lastActivity || new Date(), + metadata: { + projectPath, + sessionMode: normalizeSessionMode(session.mode), + indexState: 'synced', + }, + }); + } + + return sessions.length; + } catch (error) { + console.warn('[projects] Failed to reconcile Pi session index:', error.message); + return 0; + } +} + +/** Locate a Pi transcript by session id. Filenames embed the uuid. */ +async function findPiSessionFilePath(sessionId) { + if (!sessionId) return null; + + const files = await findPiSessionFiles(getPiSessionsRoot()); + for (const filePath of files) { + if (path.basename(filePath).includes(sessionId)) return filePath; + } + + // Fall back to headers for a file whose name does not carry the id. + for (const filePath of files) { + try { + const session = await readPiSessionFileCached(filePath); + if (session?.id === sessionId) return filePath; + } catch (_) { + // Skip unreadable files + } + } + + return null; +} + +async function getPiSessionMessages(sessionId, limit = null, offset = 0) { + const filePath = await findPiSessionFilePath(sessionId); + if (!filePath) { + console.warn(`Pi session file not found for session ${sessionId}`); + return { messages: [], total: 0, hasMore: false }; + } + + const messages = []; + const fileStream = fsSync.createReadStream(filePath); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + try { + for await (const line of rl) { + if (!line.trim()) continue; + let entry; + try { + entry = JSON.parse(line); + } catch (_) { + continue; + } + + if (entry.type !== 'message' || !entry.message) continue; + const message = entry.message; + + if (message.role === 'user' || message.role === 'assistant') { + const text = extractPiMessageText(message); + const toolCalls = Array.isArray(message.content) + ? message.content.filter((block) => block?.type === 'toolCall') + : []; + + if (text.trim()) { + messages.push({ + type: message.role, + timestamp: entry.timestamp, + message: { role: message.role, content: text }, + }); + } + + for (const toolCall of toolCalls) { + messages.push({ + type: 'tool_use', + timestamp: entry.timestamp, + toolCallId: toolCall.id, + toolName: toolCall.name, + toolInput: toolCall.arguments ?? {}, + }); + } + continue; + } + + if (message.role === 'toolResult') { + messages.push({ + type: 'tool_result', + timestamp: entry.timestamp, + toolCallId: message.toolCallId, + toolName: message.toolName, + isError: Boolean(message.isError), + output: extractPiMessageText(message) || (typeof message.output === 'string' ? message.output : ''), + }); + } + } + } finally { + rl.close(); + fileStream.destroy(); + } + + const total = messages.length; + const start = Number(offset) || 0; + const sliced = limit && limit > 0 ? messages.slice(start, start + limit) : messages.slice(start); + + return { messages: sliced, total, hasMore: limit && limit > 0 ? start + limit < total : false }; +} + +async function deletePiSession(projectName, sessionId) { + const { sessionDb } = await import('./database/db.js'); + const filePath = await findPiSessionFilePath(sessionId); + + let deletedFile = false; + if (filePath) { + try { + await fs.unlink(filePath); + piSessionFileCache.delete(filePath); + deletedFile = true; + } catch (error) { + console.warn(`[projects] Failed to delete Pi session file ${filePath}:`, error.message); + } + } + + const indexed = sessionDb.getSessionById?.(sessionId) || null; + if (deletedFile || indexed?.provider === 'pi') { + sessionDb.deleteSession(sessionId); + return true; + } + + return false; +} + export { getProjects, getTrashedProjects, @@ -4516,6 +4906,12 @@ export { getGeminiSessions, getCodexSessionMessages, deleteCodexSession, + buildPiSessionsIndex, + resetPiSessionFileCache, + getPiSessions, + getPiSessionMessages, + deletePiSession, + reconcilePiSessionIndex, reconcileClaudeSessionIndex, reconcileCodexSessionIndex, reconcileGeminiSessionIndex, diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index f716fa08..ed8001a2 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -7,6 +7,8 @@ import fetch from 'node-fetch'; import { resolveCursorCliCommand } from '../utils/cursorCommand.js'; import { resolveAvailableCliCommand } from '../utils/cliResolution.js'; import { buildCodexCliEnv, getCodexCliCommand } from '../utils/codexCli.js'; +import { getPiCliCommand } from '../utils/piCli.js'; +import spawnAsync from '../utils/spawnAsync.js'; import { DEFAULT_OLLAMA_URL, detectGPUs, @@ -63,6 +65,23 @@ const PROVIDER_INSTALLERS = { ], }, }, + pi: { + displayName: 'Pi Coding Agent', + docsUrl: 'https://pi.dev/docs/latest', + fallbackDownloadUrl: 'https://pi.dev', + commands: { + // --ignore-scripts is what pi.dev's own install instructions use. + darwin: [ + { bin: 'npm', args: ['install', '-g', '--ignore-scripts', '@earendil-works/pi-coding-agent'], label: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' }, + ], + linux: [ + { bin: 'npm', args: ['install', '-g', '--ignore-scripts', '@earendil-works/pi-coding-agent'], label: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' }, + ], + win32: [ + { bin: 'npm.cmd', args: ['install', '-g', '--ignore-scripts', '@earendil-works/pi-coding-agent'], label: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' }, + ], + }, + }, gemini: { displayName: 'Gemini CLI', docsUrl: 'https://github.com/google-gemini/gemini-cli', @@ -249,6 +268,7 @@ router.get('/providers', async (_req, res) => { checkCursorStatus().then((result) => ['cursor', buildStatusPayload(result, 'cursor')]), checkCodexCredentials().then((result) => ['codex', buildStatusPayload(result, 'codex')]), checkGeminiCredentials().then((result) => ['gemini', buildStatusPayload(result, 'gemini')]), + checkPiCredentials().then((result) => ['pi', buildStatusPayload(result, 'pi')]), ]); res.json({ providers: Object.fromEntries(statuses) }); @@ -473,6 +493,91 @@ router.get('/nano/status', async (req, res) => { } }); +/** + * Report whether the Pi CLI is installed and has a usable provider. + * + * `pi --list-models` is the authoritative check: Pi fronts many providers, and + * with none authenticated it prints a "No models available. Use /login ..." + * notice instead of a table. Being installed but logged out is therefore a + * distinct state from being absent, and the UI should say so. + */ +async function checkPiCredentials() { + const configuredCommand = getPiCliCommand(); + + try { + if (isCliMockedMissing('pi')) { + return { + authenticated: false, + email: null, + error: 'Pi CLI not found', + cliAvailable: false, + cliCommand: configuredCommand, + installHint: buildCliInstallHint('pi'), + }; + } + + const resolvedCliCommand = await resolveAvailableCliCommand({ + envVarName: 'PI_CLI_PATH', + defaultCommands: ['pi'], + args: ['--version'], + appendWindowsSuffixes: true, + }); + + if (!resolvedCliCommand) { + return { + authenticated: false, + email: null, + error: 'Pi CLI not found', + cliAvailable: false, + cliCommand: configuredCommand, + installHint: buildCliInstallHint('pi'), + }; + } + + const { stdout } = await spawnAsync(resolvedCliCommand, ['--list-models'], { + env: process.env, + maxBuffer: 512 * 1024, + }).catch((error) => ({ stdout: error.stdout || '' })); + + const hasModels = /^\s*provider\s+model\b/mi.test(stdout); + + if (!hasModels) { + return { + authenticated: false, + email: null, + error: 'Pi is installed but no provider is logged in. Run "pi" and use /login.', + cliAvailable: true, + cliCommand: resolvedCliCommand, + }; + } + + return { + authenticated: true, + email: 'Pi Coding Agent', + cliAvailable: true, + cliCommand: resolvedCliCommand, + }; + } catch (error) { + return { + authenticated: false, + email: null, + error: error.message, + cliAvailable: false, + cliCommand: configuredCommand, + }; + } +} + +router.get('/pi/status', async (req, res) => { + try { + const result = await checkPiCredentials(); + return res.json(buildStatusPayload(result, 'pi')); + } catch (error) { + console.error('Error checking Pi status:', error); + res.status(500).json({ authenticated: false, email: null, error: error.message }); + } +}); + async function checkGeminiCredentials() { console.log('[DEBUG] Checking Gemini credentials...'); let cliCommand = process.env.GEMINI_CLI_PATH || 'gemini'; diff --git a/server/utils/piCli.js b/server/utils/piCli.js new file mode 100644 index 00000000..4d13d638 --- /dev/null +++ b/server/utils/piCli.js @@ -0,0 +1,48 @@ +/** + * Resolution helpers for the Pi coding agent CLI (https://pi.dev). + * + * Mirrors the shape of codexCli.js so provider plumbing stays uniform. + */ + +import os from 'os'; +import path from 'path'; + +/** Command used to invoke Pi. Override with PI_CLI_PATH. */ +export function getPiCliCommand(env = process.env) { + return String(env.PI_CLI_PATH || '').trim() || 'pi'; +} + +/** Root of Pi's on-disk session store. */ +export function getPiSessionsRoot(homeDir = os.homedir()) { + return path.join(homeDir, '.pi', 'agent', 'sessions'); +} + +/** + * Pi stores sessions in a per-working-directory folder. Verified against + * pi 0.83.0: the leading separator is dropped, remaining separators become '-', + * and the result is wrapped in double dashes. + * + * /private/tmp/a/b -> --private-tmp-a-b-- + * + * This is only used as a lookup hint. It is not reversible — a path segment + * containing '-' is indistinguishable from a separator — so anything that needs + * to know a session's real working directory must read `cwd` from the session + * file's header line instead. See readPiSessionHeader() in projects.js. + */ +export function encodePiSessionDirName(projectPath) { + if (!projectPath) return ''; + const normalized = path.resolve(projectPath).replace(/\\/g, '/').replace(/^\/+/, ''); + return `--${normalized.replace(/\//g, '-')}--`; +} + +export function getPiSessionDirForProject(projectPath, homeDir = os.homedir()) { + const dirName = encodePiSessionDirName(projectPath); + return dirName ? path.join(getPiSessionsRoot(homeDir), dirName) : ''; +} + +export default { + getPiCliCommand, + getPiSessionsRoot, + encodePiSessionDirName, + getPiSessionDirForProject, +}; diff --git a/shared/modelConstants.js b/shared/modelConstants.js index 70e32706..53191eba 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -161,6 +161,39 @@ export const NANO_CLAUDE_CODE_MODELS = { 'claude-sonnet-4-6' }; +/** + * Pi Coding Agent models (https://pi.dev) + * + * Pi fronts 15+ providers, and which models are actually usable depends on + * which provider the user has authenticated. `pi --list-models` reports the + * real set, so this list is only a starting point — ALLOWS_CUSTOM lets any + * "provider/model" slug be entered directly. + */ +export const PI_MODELS = { + OPTIONS: [ + // Anthropic + { value: 'anthropic/claude-opus-5', label: 'Claude Opus 5 (Anthropic)' }, + { value: 'anthropic/claude-sonnet-5', label: 'Claude Sonnet 5 (Anthropic)' }, + { value: 'anthropic/claude-fable-5', label: 'Claude Fable 5 (Anthropic)' }, + { value: 'anthropic/claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (Anthropic)' }, + { value: 'anthropic/claude-opus-4-8', label: 'Claude Opus 4.8 (Anthropic)' }, + { value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5 (Anthropic)' }, + // OpenAI + { value: 'openai/gpt-5.5', label: 'GPT-5.5 (OpenAI)' }, + { value: 'openai/gpt-5', label: 'GPT-5 (OpenAI)' }, + { value: 'openai/gpt-5-mini', label: 'GPT-5 Mini (OpenAI)' }, + // Google + { value: 'google/gemini-3.5-flash', label: 'Gemini 3.5 Flash (Google)' }, + { value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro (Google)' }, + ], + + ALLOWS_CUSTOM: true, + + DEFAULT: + (typeof process !== 'undefined' && process.env?.PI_MODEL) || + 'anthropic/claude-sonnet-4-6' +}; + /** * Local GPU Models (open-source models for self-hosted deployment) */ diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index f4aa472d..69b8df40 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -73,6 +73,7 @@ interface UseChatComposerStateArgs { openrouterModel: string; localModel: string; nanoModel: string; + piModel: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: TokenBudget | null; @@ -252,6 +253,7 @@ export function useChatComposerState({ openrouterModel, localModel, nanoModel, + piModel, isLoading, canAbortSession, tokenBudget, @@ -566,6 +568,8 @@ export function useChatComposerState({ ? openrouterModel : provider === 'local' ? localModel + : provider === 'pi' + ? piModel : provider === 'nano' ? nanoModel : claudeModel, @@ -1457,6 +1461,26 @@ export function useChatComposerState({ stageTagSource: 'task_context', }, }); + } else if (provider === 'pi') { + console.log('[DEBUG] Sending pi-command'); + sendMessage({ + type: 'pi-command', + command: messageContent, + sessionId: effectiveSessionId, + options: { + cwd: resolvedProjectPath, + projectPath: resolvedProjectPath, + sessionId: effectiveSessionId, + resume: Boolean(effectiveSessionId), + model: piModel, + toolsSettings, + skipPermissions: effectivePermissionMode === 'bypassPermissions', + telemetryEnabled, + sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, + stageTagKeys: currentStageTagKeys, + stageTagSource: 'task_context', + }, + }); } else if (provider === 'nano') { console.log('[DEBUG] Sending nano-command'); sendMessage({ diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 2e6dea38..5731956a 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; -import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS, LOCAL_MODELS, NANO_CLAUDE_CODE_MODELS, OPENROUTER_MODELS } from '../../../../shared/modelConstants'; +import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS, LOCAL_MODELS, NANO_CLAUDE_CODE_MODELS, OPENROUTER_MODELS, PI_MODELS } from '../../../../shared/modelConstants'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types'; import type { ProjectSession, SessionProvider } from '../../../types/app'; @@ -32,6 +32,9 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr const [localModel, setLocalModel] = useState(() => { return localStorage.getItem('local-model') || LOCAL_MODELS.DEFAULT; }); + const [piModel, setPiModel] = useState(() => { + return localStorage.getItem('pi-model') || PI_MODELS.DEFAULT; + }); const [nanoModel, setNanoModel] = useState(() => { return ( localStorage.getItem('nano-claude-code-model') || @@ -144,6 +147,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr localModel, setLocalModel, nanoModel, + piModel, + setPiModel, setNanoModel, permissionMode, setPermissionMode, diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 56915dfc..b0ad4e51 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -424,6 +424,7 @@ export function useChatRealtimeHandlers({ const lifecycleMessageTypes = new Set([ 'claude-complete', 'codex-complete', + 'pi-complete', 'gemini-complete', 'openrouter-complete', 'localgpu-complete', @@ -432,6 +433,7 @@ export function useChatRealtimeHandlers({ 'claude-error', 'cursor-error', 'codex-error', + 'pi-error', 'gemini-error', 'openrouter-error', 'localgpu-error', @@ -474,6 +476,7 @@ export function useChatRealtimeHandlers({ (latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error' || + latestMessage.type === 'pi-error' || latestMessage.type === 'gemini-error'); const handleBackgroundLifecycle = (sessionId?: string) => { @@ -993,6 +996,99 @@ export function useChatRealtimeHandlers({ } break; + case 'pi-response': { + const piData = latestMessage.data; + if (!piData) break; + + setIsLoading(true); + switch (piData.type) { + case 'text_delta': { + // Mirrors the Claude content_block_delta path: buffer deltas and + // flush on a short timer so React is not re-rendered per token. + setStatusTextOverride(null); + streamBufferRef.current += decodeHtmlEntities(piData.delta); + if (!streamTimerRef.current) { + streamTimerRef.current = window.setTimeout(() => { + const chunk = streamBufferRef.current; + streamBufferRef.current = ''; + streamTimerRef.current = null; + appendStreamingChunk(setChatMessages, chunk, false); + }, 30); + } + break; + } + + case 'thinking_delta': + setStatusTextOverride(i18n.t('chat:status.thinking')); + break; + + case 'assistant_message': + // message_end repeats the text the deltas already rendered, so the + // stream is finalized rather than appended again. + flushAndFinalizePendingStream(); + break; + + case 'tool_use': + setChatMessages((previous) => [ + ...previous, + { + type: 'tool_use', + content: '', + timestamp: new Date(), + toolName: piData.toolName, + toolInput: piData.toolInput, + toolCallId: piData.toolCallId, + }, + ]); + break; + + case 'tool_result': + setChatMessages((previous) => [ + ...previous, + { + type: 'tool_result', + content: piData.output || '', + timestamp: new Date(), + toolName: piData.toolName, + toolCallId: piData.toolCallId, + isError: piData.isError === true, + }, + ]); + break; + + default: + break; + } + break; + } + + case 'pi-complete': { + const piPendingSessionId = sessionStorage.getItem('pendingSessionId'); + const piActualSessionId = latestMessage.actualSessionId || piPendingSessionId; + const piCompletedSessionId = latestMessage.sessionId || currentSessionId || piPendingSessionId; + flushAndFinalizePendingStream(); + clearLoadingIndicators(); + markSessionsAsCompleted(piCompletedSessionId, piActualSessionId, currentSessionId, selectedSession?.id, piPendingSessionId); + if (piPendingSessionId && !currentSessionId) { + setCurrentSessionId(piActualSessionId); + setIsSystemSessionChange(true); + if (piActualSessionId) { + onNavigateToSession?.(piActualSessionId, 'pi', selectedProject?.name, { source: 'system' }); + } + sessionStorage.removeItem('pendingSessionId'); + } + if (selectedProject) safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + break; + } + + case 'pi-error': + flushAndFinalizePendingStream(); + clearLoadingIndicators(); + markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); + setPendingPermissionRequests([]); + setChatMessages((previous) => [...previous, { type: 'error', content: latestMessage.error || 'An error occurred with Pi', timestamp: new Date(), errorType: latestMessage.errorType, isRetryable: latestMessage.isRetryable === true }]); + break; + case 'codex-response': { const codexData = latestMessage.data; if (!codexData) break; diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 2b90c967..f5281fc4 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -57,6 +57,7 @@ function resolveSessionProviderForLoad(session: ProjectSession | null, project: } const { id } = session; if (project.nanoSessions?.some((s) => s.id === id)) return 'nano'; + if (project.piSessions?.some((s) => s.id === id)) return 'pi'; if (project.localSessions?.some((s) => s.id === id)) return 'local'; if (project.openrouterSessions?.some((s) => s.id === id)) return 'openrouter'; if (project.geminiSessions?.some((s) => s.id === id)) return 'gemini'; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 7e9890f7..7f348496 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -24,7 +24,7 @@ import { readCliAvailability, writeCliAvailability } from '../../../utils/cliAva import { Button } from '../../ui/button'; import type { PendingAutoIntake } from '../../../types/app'; import type { EditingFile, DiffInfo } from '../../main-content/types/types'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS, LOCAL_MODELS, NANO_CLAUDE_CODE_MODELS, OPENROUTER_MODELS } from '../../../../shared/modelConstants'; +import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS, LOCAL_MODELS, NANO_CLAUDE_CODE_MODELS, OPENROUTER_MODELS, PI_MODELS } from '../../../../shared/modelConstants'; import { getProviderDisplayName } from '../utils/chatFormatting'; import { buildEditableMessageDraft, buildReplayMessageDraft, getChatMessageId, getMessageReplayContent } from '../utils/chatMessages'; import { normalizePath, toRelativePath, isSafePath, fileNameFromPath } from '../../../utils/pathUtils'; @@ -38,6 +38,7 @@ const DEFAULT_PROVIDER_AVAILABILITY: Record = { openrouter: { cliAvailable: true, cliCommand: 'openrouter', installHint: null }, local: { cliAvailable: true, cliCommand: null, installHint: null }, nano: { cliAvailable: true, cliCommand: 'nano-claude-code', installHint: null }, + pi: { cliAvailable: true, cliCommand: 'pi', installHint: null }, }; const INTAKE_GREETING = `Hello! I'm your Dr. Claw research assistant, here to help you set up your research pipeline.\n\nTo get started, could you tell me about your research field or topic?`; @@ -71,6 +72,7 @@ const getProviderModelConfig = (provider: Provider) => { if (provider === 'openrouter') return OPENROUTER_MODELS; if (provider === 'local') return LOCAL_MODELS; if (provider === 'nano') return NANO_CLAUDE_CODE_MODELS; + if (provider === 'pi') return PI_MODELS; return CURSOR_MODELS; }; @@ -180,6 +182,8 @@ function ChatInterface({ setLocalModel, nanoModel, setNanoModel, + piModel, + setPiModel, permissionMode, pendingPermissionRequests, setPendingPermissionRequests, @@ -315,6 +319,7 @@ function ChatInterface({ openrouterModel, localModel, nanoModel, + piModel, isLoading, canAbortSession, tokenBudget, @@ -449,6 +454,7 @@ function ChatInterface({ openrouter: cached.openrouter ?? DEFAULT_PROVIDER_AVAILABILITY.openrouter, local: cached.local ?? DEFAULT_PROVIDER_AVAILABILITY.local, nano: cached.nano ?? DEFAULT_PROVIDER_AVAILABILITY.nano, + pi: cached.pi ?? DEFAULT_PROVIDER_AVAILABILITY.pi, }; }); @@ -482,6 +488,7 @@ function ChatInterface({ { provider: 'gemini', endpoint: '/api/cli/gemini/status', fallbackCommand: 'gemini' }, { provider: 'openrouter', endpoint: '/api/cli/openrouter/status', fallbackCommand: 'openrouter' }, { provider: 'nano', endpoint: '/api/cli/nano/status', fallbackCommand: 'nano-claude-code' }, + { provider: 'pi', endpoint: '/api/cli/pi/status', fallbackCommand: 'pi' }, ]; const results = await Promise.all(checks.map(async ({ provider: nextProvider, endpoint, fallbackCommand }) => { @@ -527,7 +534,7 @@ function ChatInterface({ useEffect(() => { if (providerAvailability[provider]?.cliAvailable === false) { - const fallbackProvider = (['claude', 'cursor', 'codex', 'gemini', 'openrouter', 'local', 'nano'] as const).find( + const fallbackProvider = (['claude', 'cursor', 'codex', 'gemini', 'openrouter', 'local', 'nano', 'pi'] as const).find( (candidate) => providerAvailability[candidate]?.cliAvailable !== false, ); @@ -986,6 +993,8 @@ function ChatInterface({ localModel={localModel} setLocalModel={setLocalModel} nanoModel={nanoModel} + piModel={piModel} + setPiModel={setPiModel} setNanoModel={setNanoModel} providerAvailability={providerAvailability} newSessionMode={newSessionMode} diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index f4c087b8..9c48a363 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -27,7 +27,7 @@ import type { GeminiThinkingModeId } from '../../../../../shared/geminiThinkingS import type { AttachedPrompt, PendingPermissionRequest, PermissionMode, Provider, TokenBudget } from '../../types/types'; import type { ProviderAvailability } from '../../types/types'; import type { SessionMode, SessionProvider } from '../../../../types/app'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS, LOCAL_MODELS, NANO_CLAUDE_CODE_MODELS, OPENROUTER_MODELS } from '../../../../../shared/modelConstants'; +import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS, LOCAL_MODELS, NANO_CLAUDE_CODE_MODELS, OPENROUTER_MODELS, PI_MODELS } from '../../../../../shared/modelConstants'; import { authenticatedFetch } from '../../../../utils/api'; import { isAutoResearchScenario } from '../../utils/autoResearch'; @@ -64,6 +64,7 @@ const PROVIDERS: ProviderDef[] = [ { id: 'codex', name: 'Codex', accent: 'border-emerald-600 dark:border-emerald-400', ring: 'ring-emerald-600/15', check: 'bg-emerald-600 dark:bg-emerald-500 text-white' }, { id: 'openrouter', name: 'OpenRouter', accent: 'border-violet-500 dark:border-violet-400', ring: 'ring-violet-500/15', check: 'bg-violet-500 text-white' }, { id: 'local', name: 'Local GPU', accent: 'border-emerald-500 dark:border-emerald-400', ring: 'ring-emerald-500/15', check: 'bg-emerald-500 text-white' }, + { id: 'pi', name: 'Pi', accent: 'border-rose-500 dark:border-rose-400', ring: 'ring-rose-500/15', check: 'bg-rose-500 text-white' }, // { id: 'nano', name: 'Nano Claude Code', accent: 'border-amber-600 dark:border-amber-400', ring: 'ring-amber-600/15', check: 'bg-amber-600 text-white' }, ]; @@ -74,16 +75,18 @@ function getModelConfig(p: SessionProvider) { if (p === 'openrouter') return OPENROUTER_MODELS; if (p === 'local') return LOCAL_MODELS; if (p === 'nano') return NANO_CLAUDE_CODE_MODELS; + if (p === 'pi') return PI_MODELS; return CURSOR_MODELS; } -function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string, or: string, lo: string, na: string) { +function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string, or: string, lo: string, na: string, pi: string) { if (p === 'claude') return c; if (p === 'codex') return co; if (p === 'gemini') return g; if (p === 'openrouter') return or; if (p === 'local') return lo; if (p === 'nano') return na; + if (p === 'pi') return pi; return cu; } @@ -172,6 +175,8 @@ interface ChatComposerProps { setLocalModel?: (model: string) => void; nanoModel?: string; setNanoModel?: (model: string) => void; + piModel?: string; + setPiModel?: (model: string) => void; providerAvailability?: Record; newSessionMode?: SessionMode; onNewSessionModeChange?: (mode: SessionMode) => void; @@ -258,6 +263,8 @@ export default function ChatComposer({ localModel: localModelProp, setLocalModel, nanoModel: nanoModelProp, + piModel: piModelProp, + setPiModel, setNanoModel, providerAvailability, newSessionMode, @@ -285,7 +292,7 @@ export default function ChatComposer({ // Provider/model handling for centered mode const sessionProvider = provider as SessionProvider; - const currentModel = getModelValue(sessionProvider, claudeModelProp || '', cursorModelProp || '', codexModel, geminiModel, openrouterModelProp || '', localModelProp || '', nanoModelProp || ''); + const currentModel = getModelValue(sessionProvider, claudeModelProp || '', cursorModelProp || '', codexModel, geminiModel, openrouterModelProp || '', localModelProp || '', nanoModelProp || '', piModelProp || ''); const [ollamaModels, setOllamaModels] = useState>([]); const [isLoadingOllamaModels, setIsLoadingOllamaModels] = useState(false); @@ -355,6 +362,7 @@ export default function ChatComposer({ else if (sessionProvider === 'openrouter') { setOpenrouterModel?.(value); localStorage.setItem('openrouter-model', value); } else if (sessionProvider === 'local') { setLocalModel?.(value); localStorage.setItem('local-model', value); } else if (sessionProvider === 'nano') { setNanoModel?.(value); localStorage.setItem('nano-claude-code-model', value); } + else if (sessionProvider === 'pi') { setPiModel?.(value); localStorage.setItem('pi-model', value); } else { setCursorModel?.(value); localStorage.setItem('cursor-model', value); } }; diff --git a/src/components/project-dashboard/view/ProjectDashboard.tsx b/src/components/project-dashboard/view/ProjectDashboard.tsx index 008e2cb6..98a3b2f8 100644 --- a/src/components/project-dashboard/view/ProjectDashboard.tsx +++ b/src/components/project-dashboard/view/ProjectDashboard.tsx @@ -196,6 +196,7 @@ function getProjectSessions(project: Project): ProjectSession[] { ...(project.openrouterSessions ?? []), ...(project.localSessions ?? []), ...(project.nanoSessions ?? []), + ...(project.piSessions ?? []), ]; } diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts index 4fa0eac0..ff34f831 100644 --- a/src/components/sidebar/utils/utils.ts +++ b/src/components/sidebar/utils/utils.ts @@ -155,13 +155,18 @@ export const getAllSessions = ( __projectName: project.name, })); + const piSessions = (project.piSessions || []).map((session) => ({ + ...session, + __provider: 'pi' as const, + })); + const nanoSessions = (project.nanoSessions || []).map((session) => ({ ...session, __provider: 'nano' as const, __projectName: project.name, })); - return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...openrouterSessions, ...localSessions, ...nanoSessions].sort( + return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...openrouterSessions, ...localSessions, ...nanoSessions, ...piSessions].sort( (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), ); }; diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index dcd7511f..65dcf1d4 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -106,7 +106,8 @@ const projectsHaveChanges = ( serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) || serialize(nextProject.openrouterSessions) !== serialize(prevProject.openrouterSessions) || serialize(nextProject.localSessions) !== serialize(prevProject.localSessions) || - serialize(nextProject.nanoSessions) !== serialize(prevProject.nanoSessions) + serialize(nextProject.nanoSessions) !== serialize(prevProject.nanoSessions) || + serialize(nextProject.piSessions) !== serialize(prevProject.piSessions) ); }); }; @@ -120,6 +121,7 @@ const getProjectSessions = (project: Project): ProjectSession[] => { ...(project.openrouterSessions ?? []), ...(project.localSessions ?? []), ...(project.nanoSessions ?? []), + ...(project.piSessions ?? []), ]; }; @@ -183,6 +185,7 @@ const applySessionTagsToProject = ( const nextOpenrouterSessions = applySessionTagsToList(project.openrouterSessions, detail, 'openrouter'); const nextLocalSessions = applySessionTagsToList(project.localSessions, detail, 'local'); const nextNanoSessions = applySessionTagsToList(project.nanoSessions, detail, 'nano'); + const nextPiSessions = applySessionTagsToList(project.piSessions, detail, 'pi'); if ( nextClaudeSessions === project.sessions && @@ -191,7 +194,8 @@ const applySessionTagsToProject = ( nextGeminiSessions === project.geminiSessions && nextOpenrouterSessions === project.openrouterSessions && nextLocalSessions === project.localSessions && - nextNanoSessions === project.nanoSessions + nextNanoSessions === project.nanoSessions && + nextPiSessions === project.piSessions ) { return project; } @@ -205,6 +209,7 @@ const applySessionTagsToProject = ( openrouterSessions: nextOpenrouterSessions, localSessions: nextLocalSessions, nanoSessions: nextNanoSessions, + piSessions: nextPiSessions, }; }; @@ -455,6 +460,7 @@ export function useProjectsState({ openrouterSessions: updateSessionList(project.openrouterSessions, 'openrouter'), localSessions: updateSessionList(project.localSessions, 'local'), nanoSessions: updateSessionList(project.nanoSessions, 'nano'), + piSessions: updateSessionList(project.piSessions, 'pi'), }; if (createdProjectName && project.name === createdProjectName && createdProvider) { @@ -686,6 +692,10 @@ export function useProjectsState({ break; } + const piSession = project.piSessions?.find((session) => session.id === targetSessionId); + if (piSession) { + matchedSession = { ...piSession, __provider: 'pi' }; + } const nanoSession = project.nanoSessions?.find((session) => session.id === targetSessionId); if (nanoSession) { matchedProject = project; @@ -933,6 +943,7 @@ export function useProjectsState({ openrouterSessions: filterOut(project.openrouterSessions), localSessions: filterOut(project.localSessions), nanoSessions: filterOut(project.nanoSessions), + piSessions: filterOut(project.piSessions), sessionMeta: { ...project.sessionMeta, total: Math.max(0, (project.sessionMeta?.total as number | undefined ?? 0) - 1), diff --git a/src/types/app.ts b/src/types/app.ts index 63dae9da..23a07190 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,4 +1,4 @@ -export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'openrouter' | 'local' | 'nano'; +export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'openrouter' | 'local' | 'nano' | 'pi'; export type SessionMode = 'research' | 'workspace_qa'; @@ -80,6 +80,7 @@ export interface Project { openrouterSessions?: ProjectSession[]; localSessions?: ProjectSession[]; nanoSessions?: ProjectSession[]; + piSessions?: ProjectSession[]; sessionMeta?: ProjectSessionMeta; taskmaster?: ProjectTaskmasterInfo; [key: string]: unknown;