Skip to content

Commit 877b0c7

Browse files
authored
feat(ai): Pi provider via RPC subprocess (#377)
* refactor(ai): extract BaseSession and buildEffectivePrompt Pull shared session lifecycle (~80 lines duplicated across Claude and Codex providers) into BaseSession: query guard, abort, ID resolution, generation counter. Extract first-query prompt prepending into buildEffectivePrompt() in context.ts. Both providers now extend BaseSession. Behavior-preserving — all 54 existing tests pass unchanged. For provenance purposes, this commit was AI assisted. * feat(ai): add Pi provider via RPC subprocess Spawns `pi --mode rpc` and communicates via JSONL over stdio. No Pi SDK bundled — user must have the pi CLI installed. Provider auto-discovers available models at startup via get_available_models. Includes: PiProcess (JSONL client), PiSDKProvider, PiSDKSession extending BaseSession, mapPiEvent for streaming, Pi icon in provider UI, and 12 new tests (9 event mapping + 3 buildEffectivePrompt). For provenance purposes, this commit was AI assisted. * fix(ai): harden Pi provider error paths - Surface startup errors when Pi process dies (bad config, missing API keys) instead of hanging the chat indefinitely - Map tool-call errors as non-terminal tool_result instead of AIErrorMessage, so Pi can continue reasoning after a failed tool call - Fix subprocess leak in fetchModels() by moving proc.kill() to finally block For provenance purposes, this commit was AI assisted. * fix(ai): detect Pi prompt rejections and process crashes - Use sendAndWait for prompt command so RPC-level rejections (expired credentials, invalid session) surface as errors instead of hanging - Emit process_exited instead of synthetic agent_end when Pi subprocess dies, so crashes show as errors instead of silent empty successes For provenance purposes, this commit was AI assisted. * docs(ai): add Pi to AI features guide, code review docs, and installation For provenance purposes, this commit was AI assisted. * style(ai): apply biome formatting to new files For provenance purposes, this commit was AI assisted.
1 parent b675e86 commit 877b0c7

15 files changed

Lines changed: 918 additions & 135 deletions

File tree

apps/marketing/src/content/docs/commands/code-review.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ Plannotator supports multiple AI providers. Providers are auto-detected based on
8383

8484
- **Claude** — Requires the `claude` CLI ([Claude Code](https://docs.anthropic.com/en/docs/claude-code))
8585
- **Codex** — Requires the `codex` CLI ([OpenAI Codex](https://github.com/openai/codex))
86+
- **Pi** — Requires the `pi` CLI ([Pi](https://github.com/mariozechner/pi-coding-agent))
8687

87-
Both providers can be available simultaneously. Plannotator does not manage API keys — you must be authenticated with each CLI independently (`claude` uses `~/.claude/` credentials, `codex` uses `OPENAI_API_KEY`).
88+
All providers can be available simultaneously. Plannotator does not manage API keys — you must be authenticated with each CLI independently (`claude` uses `~/.claude/` credentials, `codex` uses `OPENAI_API_KEY`, `pi` uses its own local configuration).
8889

8990
### Choosing a provider
9091

apps/marketing/src/content/docs/getting-started/installation.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: "Installation"
3-
description: "How to install Plannotator for Claude Code, OpenCode, and other agent hosts."
3+
description: "How to install Plannotator for Claude Code, OpenCode, Pi, and other agent hosts."
44
sidebar:
55
order: 1
66
section: "Getting Started"
@@ -109,3 +109,21 @@ Install the binary, then use it directly:
109109
!plannotator review # Code review for current changes
110110
!plannotator annotate file.md # Annotate a markdown file
111111
```
112+
113+
## Pi
114+
115+
Install the Pi extension:
116+
117+
```bash
118+
pi install npm:@plannotator/pi-extension
119+
```
120+
121+
Or try it without installing:
122+
123+
```bash
124+
pi -e npm:@plannotator/pi-extension
125+
```
126+
127+
Start plan mode with `pi --plan`, or toggle mid-session with `/plannotator` or `Ctrl+Alt+P`. The extension provides file-based plan review, code review (`/plannotator-review`), markdown annotation (`/plannotator-annotate`), bash safety gating during planning, and progress tracking during execution.
128+
129+
See [Plannotator Meets Pi](/blog/plannotator-meets-pi) for the full walkthrough.

apps/marketing/src/content/docs/guides/ai-features.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ Requires the `codex` CLI installed and authenticated. The AI operates in a sandb
3333
- GPT-5.2 Codex
3434
- GPT-5.2
3535

36+
### Pi (via RPC subprocess)
37+
38+
Requires the `pi` CLI installed and configured. Plannotator spawns `pi --mode rpc` and communicates over JSONL/stdio. Models are discovered dynamically from your Pi installation — whatever models you've configured in Pi are available here.
39+
40+
No API keys are managed by Plannotator — Pi uses its own local configuration.
41+
3642
## Configuration
3743

3844
Provider and model selection is available in **Settings > AI**. These persist via cookies across sessions.
@@ -47,6 +53,8 @@ A session is created lazily on your first question. Until then, no resources are
4753

4854
**Codex sessions** inject the review context as a system prompt prefix. The AI has Codex's built-in capabilities plus the diff. Codex sessions are always standalone — fork support is not available.
4955

56+
**Pi sessions** inject the review context as a system prompt prefix, similar to Codex. Pi uses its full default toolset (read, bash, edit, write). Pi sessions are always standalone — fork and resume are not available.
57+
5058
**Diff context handling:** Large diffs are truncated at roughly 40k characters to stay within context limits. However, when you select specific lines and ask a question, the selected code is always sent alongside the question regardless of truncation.
5159

5260
## Permission requests
@@ -55,20 +63,22 @@ When using Claude, the AI may request permission to use tools like Read, Glob, G
5563

5664
Codex sessions run in a sandboxed read-only mode, so permission requests do not apply.
5765

66+
Pi does not expose a permission approval gate over RPC, so tool execution is handled entirely by Pi's own runtime.
67+
5868
## Reasoning effort
5969

6070
Codex supports a reasoning effort setting with four levels: **Low**, **Medium**, **High**, and **Max**. This is available in the config bar at the bottom of the AI sidebar. Higher effort means slower but more thorough responses.
6171

62-
This setting only applies to Codex — Claude does not expose a reasoning effort control.
72+
This setting only applies to Codex — Claude and Pi do not expose a reasoning effort control.
6373

6474
## Available settings
6575

6676
| Setting | Description | Provider |
6777
|---------|-------------|----------|
68-
| Provider | Claude or Codex | Both |
69-
| Model | Model selection per provider | Both |
78+
| Provider | Claude, Codex, or Pi | All |
79+
| Model | Model selection per provider | All |
7080
| Reasoning effort | Low / Medium / High / Max | Codex only |
7181
| Default tools | Read, Glob, Grep, WebSearch | Claude only |
7282
| Sandbox mode | Read-only | Codex only |
7383
| Permission mode | Default | Claude only |
74-
| Max turns | 99 | Both |
84+
| Max turns | 99 | Claude, Codex |

bun.lock

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ai/ai.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,3 +904,153 @@ describe("Multi-provider endpoints", () => {
904904
expect(data.sessionId).toBeDefined();
905905
});
906906
});
907+
908+
// ---------------------------------------------------------------------------
909+
// buildEffectivePrompt
910+
// ---------------------------------------------------------------------------
911+
912+
import { buildEffectivePrompt } from "./context.ts";
913+
914+
describe("buildEffectivePrompt", () => {
915+
test("prepends preamble on first query", () => {
916+
const result = buildEffectivePrompt("What is this?", "System context here", false);
917+
expect(result).toBe("System context here\n\n---\n\nUser question: What is this?");
918+
});
919+
920+
test("returns bare prompt on subsequent queries", () => {
921+
const result = buildEffectivePrompt("What is this?", "System context here", true);
922+
expect(result).toBe("What is this?");
923+
});
924+
925+
test("returns bare prompt when preamble is null", () => {
926+
const result = buildEffectivePrompt("What is this?", null, false);
927+
expect(result).toBe("What is this?");
928+
});
929+
});
930+
931+
// ---------------------------------------------------------------------------
932+
// mapPiEvent
933+
// ---------------------------------------------------------------------------
934+
935+
import { mapPiEvent } from "./providers/pi-sdk.ts";
936+
937+
describe("mapPiEvent", () => {
938+
const SESSION_ID = "pi-session-123";
939+
940+
test("text_delta from message_update", () => {
941+
const result = mapPiEvent({
942+
type: "message_update",
943+
assistantMessageEvent: { type: "text_delta", delta: "Hello", contentIndex: 0, partial: {} },
944+
}, SESSION_ID);
945+
expect(result).toEqual([{ type: "text_delta", delta: "Hello" }]);
946+
});
947+
948+
test("toolcall_end from message_update", () => {
949+
const result = mapPiEvent({
950+
type: "message_update",
951+
assistantMessageEvent: {
952+
type: "toolcall_end",
953+
contentIndex: 0,
954+
toolCall: { type: "toolCall", id: "tc_1", name: "read", arguments: { path: "/foo" } },
955+
partial: {},
956+
},
957+
}, SESSION_ID);
958+
expect(result).toEqual([{
959+
type: "tool_use",
960+
toolName: "read",
961+
toolInput: { path: "/foo" },
962+
toolUseId: "tc_1",
963+
}]);
964+
});
965+
966+
test("tool_execution_end maps to tool_result", () => {
967+
const result = mapPiEvent({
968+
type: "tool_execution_end",
969+
toolCallId: "tc_1",
970+
toolName: "read",
971+
result: "file contents",
972+
isError: false,
973+
}, SESSION_ID);
974+
expect(result).toEqual([{
975+
type: "tool_result",
976+
toolUseId: "tc_1",
977+
result: "file contents",
978+
}]);
979+
});
980+
981+
test("tool_execution_end with error maps to tool_result with [Error] prefix", () => {
982+
const result = mapPiEvent({
983+
type: "tool_execution_end",
984+
toolCallId: "tc_1",
985+
toolName: "read",
986+
result: "not found",
987+
isError: true,
988+
}, SESSION_ID);
989+
expect(result).toEqual([{
990+
type: "tool_result",
991+
toolUseId: "tc_1",
992+
result: "[Error] not found",
993+
}]);
994+
});
995+
996+
test("agent_end maps to result", () => {
997+
const result = mapPiEvent({
998+
type: "agent_end",
999+
messages: [],
1000+
}, SESSION_ID);
1001+
expect(result).toEqual([{
1002+
type: "result",
1003+
sessionId: SESSION_ID,
1004+
success: true,
1005+
}]);
1006+
});
1007+
1008+
test("process_exited maps to error", () => {
1009+
const result = mapPiEvent({ type: "process_exited" }, SESSION_ID);
1010+
expect(result).toEqual([{
1011+
type: "error",
1012+
error: "Pi process exited unexpectedly.",
1013+
code: "pi_process_exit",
1014+
}]);
1015+
});
1016+
1017+
test("ignored events return empty", () => {
1018+
expect(mapPiEvent({ type: "agent_start" }, SESSION_ID)).toEqual([]);
1019+
expect(mapPiEvent({ type: "turn_start" }, SESSION_ID)).toEqual([]);
1020+
expect(mapPiEvent({ type: "turn_end", message: {}, toolResults: [] }, SESSION_ID)).toEqual([]);
1021+
expect(mapPiEvent({ type: "message_start", message: {} }, SESSION_ID)).toEqual([]);
1022+
expect(mapPiEvent({ type: "message_end", message: {} }, SESSION_ID)).toEqual([]);
1023+
expect(mapPiEvent({ type: "tool_execution_start", toolCallId: "x", toolName: "y", args: {} }, SESSION_ID)).toEqual([]);
1024+
});
1025+
1026+
test("message_update with thinking events returns empty", () => {
1027+
const result = mapPiEvent({
1028+
type: "message_update",
1029+
assistantMessageEvent: { type: "thinking_delta", delta: "hmm", contentIndex: 0, partial: {} },
1030+
}, SESSION_ID);
1031+
expect(result).toEqual([]);
1032+
});
1033+
1034+
test("message_update with done returns empty", () => {
1035+
const result = mapPiEvent({
1036+
type: "message_update",
1037+
assistantMessageEvent: { type: "done", reason: "stop", message: {} },
1038+
}, SESSION_ID);
1039+
expect(result).toEqual([]);
1040+
});
1041+
1042+
test("tool_execution_end with object result stringifies it", () => {
1043+
const result = mapPiEvent({
1044+
type: "tool_execution_end",
1045+
toolCallId: "tc_2",
1046+
toolName: "ls",
1047+
result: { files: ["a.ts", "b.ts"] },
1048+
isError: false,
1049+
}, SESSION_ID);
1050+
expect(result).toEqual([{
1051+
type: "tool_result",
1052+
toolUseId: "tc_2",
1053+
result: JSON.stringify({ files: ["a.ts", "b.ts"] }),
1054+
}]);
1055+
});
1056+
});

packages/ai/base-session.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Shared session base class — extracts the common lifecycle, abort, and
3+
* ID-resolution logic that every AIProvider session needs.
4+
*
5+
* Concrete providers extend this and implement query().
6+
*/
7+
8+
import type { AIMessage, AISession } from "./types.ts";
9+
10+
export abstract class BaseSession implements AISession {
11+
readonly parentSessionId: string | null;
12+
onIdResolved?: (oldId: string, newId: string) => void;
13+
14+
protected _placeholderId: string;
15+
protected _resolvedId: string | null = null;
16+
protected _isActive = false;
17+
protected _currentAbort: AbortController | null = null;
18+
protected _queryGen = 0;
19+
protected _firstQuerySent = false;
20+
21+
constructor(opts: { parentSessionId: string | null; initialId?: string }) {
22+
this.parentSessionId = opts.parentSessionId;
23+
this._placeholderId = opts.initialId ?? crypto.randomUUID();
24+
}
25+
26+
get id(): string {
27+
return this._resolvedId ?? this._placeholderId;
28+
}
29+
30+
get isActive(): boolean {
31+
return this._isActive;
32+
}
33+
34+
// ---------------------------------------------------------------------------
35+
// Query lifecycle helpers — call from concrete query() implementations
36+
// ---------------------------------------------------------------------------
37+
38+
/** Error message returned when a query is already active. */
39+
static readonly BUSY_ERROR: AIMessage = {
40+
type: "error",
41+
error:
42+
"A query is already in progress. Abort the current query before sending a new one.",
43+
code: "session_busy",
44+
};
45+
46+
/**
47+
* Call at the start of query(). Returns the generation number and abort
48+
* signal, or null if the session is busy.
49+
*/
50+
protected startQuery(): { gen: number; signal: AbortSignal } | null {
51+
if (this._isActive) return null;
52+
53+
const gen = ++this._queryGen;
54+
this._isActive = true;
55+
this._currentAbort = new AbortController();
56+
return { gen, signal: this._currentAbort.signal };
57+
}
58+
59+
/**
60+
* Call in the finally block of query(). Only clears state if the
61+
* generation matches (prevents a stale finally from clobbering a newer query).
62+
*/
63+
protected endQuery(gen: number): void {
64+
if (this._queryGen === gen) {
65+
this._isActive = false;
66+
this._currentAbort = null;
67+
}
68+
}
69+
70+
/**
71+
* Call when the provider resolves the real session ID from the backend.
72+
* Fires the onIdResolved callback so the SessionManager can remap its key.
73+
*/
74+
protected resolveId(newId: string): void {
75+
if (this._resolvedId) return; // Already resolved
76+
const oldId = this._placeholderId;
77+
this._resolvedId = newId;
78+
this.onIdResolved?.(oldId, newId);
79+
}
80+
81+
/**
82+
* Abort the current in-flight query. Subclasses should call super.abort()
83+
* after any provider-specific cleanup.
84+
*/
85+
abort(): void {
86+
if (this._currentAbort) {
87+
this._currentAbort.abort();
88+
this._isActive = false;
89+
this._currentAbort = null;
90+
}
91+
}
92+
93+
abstract query(prompt: string): AsyncIterable<AIMessage>;
94+
}

packages/ai/context.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,22 @@ export function buildForkPreamble(ctx: AIContext): string {
101101
return lines.join("\n");
102102
}
103103

104+
/**
105+
* Build the effective prompt for a query, prepending a preamble on the first
106+
* message. Used by providers that inject context via the prompt itself (Codex,
107+
* Pi) rather than a separate system-prompt channel (Claude).
108+
*/
109+
export function buildEffectivePrompt(
110+
userPrompt: string,
111+
preamble: string | null,
112+
firstQuerySent: boolean,
113+
): string {
114+
if (!firstQuerySent && preamble) {
115+
return `${preamble}\n\n---\n\nUser question: ${userPrompt}`;
116+
}
117+
return userPrompt;
118+
}
119+
104120
// ---------------------------------------------------------------------------
105121
// Internals
106122
// ---------------------------------------------------------------------------

packages/ai/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export type {
7373
CreateSessionOptions,
7474
ClaudeAgentSDKConfig,
7575
CodexSDKConfig,
76+
PiSDKConfig,
7677
} from "./types.ts";
7778

7879
// Provider registry
@@ -83,7 +84,10 @@ export {
8384
} from "./provider.ts";
8485

8586
// Context builders
86-
export { buildSystemPrompt, buildForkPreamble } from "./context.ts";
87+
export { buildSystemPrompt, buildForkPreamble, buildEffectivePrompt } from "./context.ts";
88+
89+
// Base session
90+
export { BaseSession } from "./base-session.ts";
8791

8892
// Session manager
8993
export { SessionManager } from "./session-manager.ts";

0 commit comments

Comments
 (0)