diff --git a/AGENTS.md b/AGENTS.md
index e3b5771d797c..9555e058cb75 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# T3 Code
-T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients.
+T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, pi) and serves web, desktop, and mobile clients.
You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.
@@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste
- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
-- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, and Antigravity each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
+- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and pi each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.
- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.
diff --git a/README.md b/README.md
index 27b5dc491693..b95c4a1e19e3 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).
-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them.
+Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, and pi. If they're set up on your computer, T3 Code can control them.
## "Wait, what are you selling me?"
@@ -13,7 +13,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation
> [!WARNING]
-> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Antigravity. Install and authenticate at least one provider before use:
+> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, and pi. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
@@ -21,6 +21,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
> - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required.
+> - pi: install [pi](https://pi.dev), run `pi`, then `/login`
### Try it out (install-free)
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index 8ad497c9a79b..4a31a7c08f6e 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -23,6 +23,19 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+ if (props.provider === "pi") {
+ return (
+
+
+
+
+ );
+ }
+
if (props.provider === "claudeAgent") {
return (
diff --git a/apps/server/scripts/pi-mock-rpc.ts b/apps/server/scripts/pi-mock-rpc.ts
new file mode 100644
index 000000000000..1fda4e226949
--- /dev/null
+++ b/apps/server/scripts/pi-mock-rpc.ts
@@ -0,0 +1,444 @@
+#!/usr/bin/env node
+// @effect-diagnostics nodeBuiltinImport:off
+/**
+ * Minimal stand-in for `pi --mode rpc`, used by the pi adapter and provider
+ * tests. It speaks the same LF-delimited JSONL protocol and replays a small
+ * scripted turn: assistant text, one bash tool call gated by an
+ * `extension_ui_request`, then `agent_end` and `agent_settled`.
+ *
+ * Environment toggles:
+ * T3_PI_MOCK_NO_MODELS=1 report no authenticated models
+ * T3_PI_MOCK_REQUEST_LOG= append every received command as JSONL, preceded by
+ * one `mock.env` line with the T3 MCP env pi was given
+ * T3_PI_MOCK_HANG_PROMPT=1 never settle a prompt (for abort tests)
+ * T3_PI_MOCK_ASK_QUESTION=1 replace the bash tool with an ask_user-style question
+ * T3_PI_MOCK_SETTLE_ON_STEER=1 hold the first prompt open until a second `prompt`
+ * arrives, then settle the run once (for steer tests)
+ * T3_PI_MOCK_REJECT_PROMPT=1 reject every `prompt` command (for turn-failure tests)
+ * T3_PI_MOCK_SELF_RUN=1 settle the first prompt immediately, then start one run
+ * nobody asked for (what a background terminal exit or a
+ * finished subagent does), held open until a prompt arrives
+ *
+ * Like the real pi, a `prompt` that arrives while the agent loop runs is
+ * rejected unless it carries `streamingBehavior`.
+ */
+import * as NodeFS from "node:fs";
+
+const args = process.argv.slice(2);
+const requestLog = process.env.T3_PI_MOCK_REQUEST_LOG;
+const noModels = process.env.T3_PI_MOCK_NO_MODELS === "1";
+const hangPrompt = process.env.T3_PI_MOCK_HANG_PROMPT === "1";
+const askQuestion = process.env.T3_PI_MOCK_ASK_QUESTION === "1";
+const settleOnSteer = process.env.T3_PI_MOCK_SETTLE_ON_STEER === "1";
+const selfRun = process.env.T3_PI_MOCK_SELF_RUN === "1";
+const rejectPrompt = process.env.T3_PI_MOCK_REJECT_PROMPT === "1";
+
+if (args.includes("--version")) {
+ process.stdout.write("0.84.4\n");
+ process.exit(0);
+}
+
+if (args.includes("-p")) {
+ let prompt = "";
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", (chunk) => {
+ prompt += chunk;
+ });
+ process.stdin.on("end", () => {
+ const wantsTitle = /title/i.test(prompt);
+ const wantsBranch = /branch name|"branch"/i.test(prompt);
+ const wantsPr = /pull request|"title"[\s\S]*"body"/i.test(prompt);
+ const output =
+ wantsTitle && !wantsPr
+ ? { title: "Mock pi title" }
+ : wantsBranch && !wantsPr
+ ? { branch: "mock-pi-branch" }
+ : wantsPr && !/subject/i.test(prompt)
+ ? { title: "Mock PR title", body: "Mock PR body" }
+ : {
+ subject: "feat: mock pi commit",
+ body: "Body from mock pi.",
+ branch: "mock-branch",
+ };
+ process.stdout.write(`${JSON.stringify(output)}\n`);
+ process.exit(0);
+ });
+} else {
+ runRpc();
+}
+
+function runRpc() {
+ if (requestLog) {
+ NodeFS.appendFileSync(
+ requestLog,
+ `${JSON.stringify({
+ type: "mock.env",
+ mcpUrl: process.env.T3_MCP_URL ?? null,
+ mcpToken: process.env.T3_MCP_BEARER_TOKEN ?? null,
+ })}\n`,
+ );
+ }
+ const sessionFile = process.env.T3_PI_MOCK_SESSION_FILE ?? "/tmp/pi-mock-session.jsonl";
+ const models = noModels
+ ? []
+ : [
+ {
+ id: "claude-sonnet-4-5",
+ name: "Claude Sonnet 4.5",
+ provider: "anthropic",
+ reasoning: true,
+ thinkingLevelMap: { xhigh: null, max: null },
+ contextWindow: 200000,
+ input: ["text", "image"],
+ },
+ {
+ id: "gpt-5",
+ name: "GPT-5",
+ provider: "openai",
+ reasoning: true,
+ contextWindow: 272000,
+ input: ["text"],
+ },
+ ];
+ let model = models[0] ?? null;
+ let thinkingLevel = "medium";
+ let streaming = false;
+ let sessionName = args.includes("--name") ? args[args.indexOf("--name") + 1] : undefined;
+ const send = (payload: Record) => {
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
+ };
+ const respond = (id: string | undefined, command: string, data?: unknown) =>
+ send({
+ ...(id ? { id } : {}),
+ type: "response",
+ command,
+ success: true,
+ ...(data !== undefined ? { data } : {}),
+ });
+ let pendingUi: { id: string; resolve: (value: string | undefined) => void } | undefined;
+ let pendingSteer: (() => void) | undefined;
+ let uiCounter = 0;
+ const askUi = (request: Record) =>
+ new Promise((resolve) => {
+ const id = `ui-${++uiCounter}`;
+ pendingUi = { id, resolve };
+ send({ type: "extension_ui_request", id, ...request });
+ });
+
+ const runQuestion = async () => {
+ const toolCallId = "call_ask_1";
+ const question = "Which color?";
+ const options = [{ label: "Red", description: "Warm" }, { label: "Blue" }];
+ send({
+ type: "tool_execution_start",
+ toolCallId,
+ toolName: "ask_user",
+ args: { question, options },
+ });
+ const choice = await askUi({
+ method: "select",
+ title: JSON.stringify({ t3: "t3-question", question, options }),
+ options: [...options.map((option) => option.label), "__t3_other__"],
+ });
+ let text: string;
+ if (choice === undefined) {
+ text = "User dismissed the question without answering.";
+ } else if (choice === "__t3_other__") {
+ const custom = await askUi({
+ method: "input",
+ title: JSON.stringify({ t3: "t3-question-custom", question }),
+ placeholder: "Your answer",
+ });
+ text = custom
+ ? `User wrote their own answer: ${custom}`
+ : "User dismissed the question without answering.";
+ } else {
+ text = `User selected option ${options.findIndex((option) => option.label === choice) + 1}: ${choice}`;
+ }
+ send({
+ type: "tool_execution_end",
+ toolCallId,
+ toolName: "ask_user",
+ result: { content: [{ type: "text", text }], details: {} },
+ isError: false,
+ });
+ };
+
+ let selfRunStarted = false;
+ /** A run pi starts on its own, held open until a prompt steers into it. */
+ const startSelfRun = async () => {
+ selfRunStarted = true;
+ streaming = true;
+ send({ type: "agent_start" });
+ send({ type: "turn_start" });
+ send({ type: "message_start", message: { role: "assistant", content: [] } });
+ send({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_start", contentIndex: 0 },
+ });
+ send({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "Background work" },
+ });
+ send({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "Background work" },
+ });
+ await new Promise((resolve) => {
+ pendingSteer = resolve;
+ });
+ send({
+ type: "message_end",
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: "Background work" }],
+ stopReason: "stop",
+ },
+ });
+ send({ type: "turn_end", message: {}, toolResults: [] });
+ send({ type: "agent_end", messages: [], willRetry: false });
+ send({ type: "agent_settled" });
+ streaming = false;
+ };
+
+ const runPrompt = async (message: string) => {
+ streaming = true;
+ send({ type: "agent_start" });
+ send({ type: "turn_start" });
+ send({ type: "message_start", message: { role: "assistant", content: [] } });
+ send({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_start", contentIndex: 0 },
+ });
+ send({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: `Echo: ${message}` },
+ });
+ send({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_end", contentIndex: 0, content: `Echo: ${message}` },
+ });
+ send({
+ type: "message_end",
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: `Echo: ${message}` }],
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 },
+ stopReason: "toolUse",
+ },
+ });
+ if (hangPrompt) return;
+
+ if (selfRun) {
+ send({ type: "turn_end", message: {}, toolResults: [] });
+ send({ type: "agent_end", messages: [], willRetry: false });
+ send({ type: "agent_settled" });
+ streaming = false;
+ if (!selfRunStarted) void startSelfRun();
+ return;
+ }
+
+ if (settleOnSteer) {
+ await new Promise((resolve) => {
+ pendingSteer = resolve;
+ });
+ send({ type: "turn_end", message: {}, toolResults: [] });
+ send({ type: "agent_end", messages: [], willRetry: false });
+ send({ type: "agent_settled" });
+ streaming = false;
+ return;
+ }
+
+ if (askQuestion) {
+ await runQuestion();
+ send({ type: "turn_end", message: {}, toolResults: [] });
+ send({ type: "agent_end", messages: [], willRetry: false });
+ send({ type: "agent_settled" });
+ streaming = false;
+ return;
+ }
+
+ const toolCallId = "call_mock_1";
+ const toolArgs = { command: "echo hi" };
+ const choice = await askUi({
+ method: "select",
+ title: JSON.stringify({ t3: "t3-approval", toolCallId, toolName: "bash", input: toolArgs }),
+ options: ["accept", "acceptForSession", "decline"],
+ });
+ send({ type: "tool_execution_start", toolCallId, toolName: "bash", args: toolArgs });
+ if (choice === "accept" || choice === "acceptForSession") {
+ send({
+ type: "tool_execution_update",
+ toolCallId,
+ toolName: "bash",
+ args: toolArgs,
+ partialResult: { content: [{ type: "text", text: "hi" }] },
+ });
+ send({
+ type: "tool_execution_end",
+ toolCallId,
+ toolName: "bash",
+ result: { content: [{ type: "text", text: "hi\n" }], details: {} },
+ isError: false,
+ });
+ } else {
+ send({
+ type: "tool_execution_end",
+ toolCallId,
+ toolName: "bash",
+ result: { content: [{ type: "text", text: "Declined by the user in T3 Code." }] },
+ isError: true,
+ });
+ }
+ send({ type: "turn_end", message: {}, toolResults: [] });
+ send({ type: "agent_end", messages: [], willRetry: false });
+ send({ type: "agent_settled" });
+ streaming = false;
+ };
+
+ let buffer = "";
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", (chunk: string) => {
+ buffer += chunk;
+ let index = buffer.indexOf("\n");
+ while (index !== -1) {
+ const line = buffer.slice(0, index).replace(/\r$/, "");
+ buffer = buffer.slice(index + 1);
+ index = buffer.indexOf("\n");
+ if (!line.trim()) continue;
+ const command = JSON.parse(line) as Record;
+ if (requestLog) NodeFS.appendFileSync(requestLog, `${JSON.stringify(command)}\n`);
+ const id = typeof command.id === "string" ? command.id : undefined;
+ switch (command.type) {
+ case "get_state":
+ respond(id, "get_state", {
+ model,
+ thinkingLevel,
+ isStreaming: streaming,
+ isCompacting: false,
+ steeringMode: "one-at-a-time",
+ followUpMode: "one-at-a-time",
+ sessionFile,
+ sessionId: "mock-session-id",
+ ...(sessionName ? { sessionName } : {}),
+ autoCompactionEnabled: true,
+ messageCount: 0,
+ pendingMessageCount: 0,
+ });
+ break;
+ case "get_available_models":
+ respond(id, "get_available_models", { models });
+ break;
+ case "get_available_thinking_levels":
+ respond(id, "get_available_thinking_levels", {
+ levels: ["off", "low", "medium", "high"],
+ });
+ break;
+ case "get_commands":
+ respond(id, "get_commands", {
+ commands: [
+ {
+ name: "fix-tests",
+ description: "Fix failing tests",
+ source: "prompt",
+ sourceInfo: { path: "/p/fix-tests.md", scope: "project" },
+ },
+ {
+ name: "skill:brave-search",
+ description: "Web search",
+ source: "skill",
+ sourceInfo: { path: "/u/skills/brave-search/SKILL.md", scope: "user" },
+ },
+ { name: "llama", description: "Manage llama.cpp", source: "extension" },
+ ],
+ });
+ break;
+ case "set_model": {
+ const next = models.find(
+ (m) => m.provider === command.provider && m.id === command.modelId,
+ );
+ if (!next) {
+ send({
+ ...(id ? { id } : {}),
+ type: "response",
+ command: "set_model",
+ success: false,
+ error: `Model not found: ${String(command.provider)}/${String(command.modelId)}`,
+ });
+ break;
+ }
+ model = next;
+ respond(id, "set_model", model);
+ break;
+ }
+ case "set_thinking_level":
+ thinkingLevel = String(command.level);
+ respond(id, "set_thinking_level");
+ break;
+ case "set_session_name":
+ sessionName = String(command.name);
+ respond(id, "set_session_name");
+ break;
+ case "prompt":
+ if (rejectPrompt) {
+ send({
+ ...(id ? { id } : {}),
+ type: "response",
+ command: "prompt",
+ success: false,
+ error: "pi refused the prompt.",
+ });
+ break;
+ }
+ if (streaming && typeof command.streamingBehavior !== "string") {
+ send({
+ ...(id ? { id } : {}),
+ type: "response",
+ command: "prompt",
+ success: false,
+ error:
+ "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
+ });
+ break;
+ }
+ respond(id, "prompt");
+ if (streaming && pendingSteer) {
+ const resolve = pendingSteer;
+ pendingSteer = undefined;
+ resolve();
+ break;
+ }
+ void runPrompt(String(command.message));
+ break;
+ case "abort":
+ respond(id, "abort");
+ if (streaming) {
+ streaming = false;
+ send({
+ type: "message_end",
+ message: { role: "assistant", content: [], stopReason: "aborted" },
+ });
+ send({ type: "agent_end", messages: [], willRetry: false });
+ send({ type: "agent_settled" });
+ }
+ break;
+ case "extension_ui_response":
+ if (pendingUi && pendingUi.id === command.id) {
+ const resolve = pendingUi.resolve;
+ pendingUi = undefined;
+ resolve(command.cancelled === true ? undefined : (command.value as string | undefined));
+ }
+ break;
+ default:
+ send({
+ ...(id ? { id } : {}),
+ type: "response",
+ command: String(command.type),
+ success: false,
+ error: `Unknown command: ${String(command.type)}`,
+ });
+ }
+ }
+ });
+ process.stdin.on("end", () => process.exit(0));
+}
diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts
new file mode 100644
index 000000000000..2efe4c7c734d
--- /dev/null
+++ b/apps/server/src/provider/Drivers/PiDriver.ts
@@ -0,0 +1,191 @@
+/**
+ * PiDriver — `ProviderDriver` for pi (`pi --mode rpc`).
+ *
+ * Each instance materializes the T3 extension into the state dir once, then
+ * every session loads it with `-e`. Model catalog, skills, and slash commands
+ * come from a short ephemeral RPC probe; chats and text generation spawn the
+ * user's `pi` binary with their own `~/.pi/agent` credentials.
+ *
+ * @module provider/Drivers/PiDriver
+ */
+import { PiSettings, ProviderDriverKind } from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { HttpClient } from "effect/unstable/http";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
+import { ServerConfig } from "../../config.ts";
+import { ServerSettingsService } from "../../serverSettings.ts";
+import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts";
+import { ProviderDriverError } from "../Errors.ts";
+import { makePiAdapter } from "../Layers/PiAdapter.ts";
+import {
+ buildInitialPiProviderSnapshot,
+ checkPiProviderStatus,
+ enrichPiSnapshot,
+ probePiCommandsForCwd,
+} from "../Layers/PiProvider.ts";
+import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
+import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
+import { materializePiExtension } from "../pi/piExtension.ts";
+import {
+ defaultProviderContinuationIdentity,
+ type ProviderDriver,
+ type ProviderInstance,
+} from "../ProviderDriver.ts";
+import { withInstanceIdentity } from "./instanceIdentity.ts";
+import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
+import {
+ haveProviderSnapshotSettingsChanged,
+ makeProviderSnapshotSettingsSource,
+ type ProviderSnapshotSettings,
+} from "../providerUpdateSettings.ts";
+
+const decodePiSettings = Schema.decodeSync(PiSettings);
+
+const DRIVER_KIND = ProviderDriverKind.make("pi");
+// pi is usually installed by mise, npm, or a curl script; `pi update` covers
+// them all, so T3 leaves upgrades to the user.
+const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({
+ provider: DRIVER_KIND,
+ packageName: null,
+});
+
+export type PiDriverEnv =
+ | BackgroundPolicy.BackgroundPolicy
+ | ChildProcessSpawner.ChildProcessSpawner
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | HttpClient.HttpClient
+ | Path.Path
+ | ProviderEventLoggers
+ | ServerConfig
+ | ServerSettingsService;
+
+export const PiDriver: ProviderDriver = {
+ driverKind: DRIVER_KIND,
+ metadata: {
+ displayName: "pi",
+ supportsMultipleInstances: true,
+ },
+ configSchema: PiSettings,
+ defaultConfig: (): PiSettings => decodePiSettings({}),
+ create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const httpClient = yield* HttpClient.HttpClient;
+ const serverSettings = yield* ServerSettingsService;
+ const { cwd, stateDir } = yield* ServerConfig;
+ const eventLoggers = yield* ProviderEventLoggers;
+ const processEnv = mergeProviderInstanceEnvironment(environment);
+ const continuationIdentity = defaultProviderContinuationIdentity({
+ driverKind: DRIVER_KIND,
+ instanceId,
+ });
+ const stampIdentity = withInstanceIdentity({
+ instanceId,
+ driverKind: DRIVER_KIND,
+ displayName,
+ accentColor,
+ continuationGroupKey: continuationIdentity.continuationKey,
+ });
+ const effectiveConfig = { ...config, enabled } satisfies PiSettings;
+
+ const extensionPath = yield* materializePiExtension(stateDir).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: `Failed to write the T3 pi extension: ${cause.message}`,
+ cause,
+ }),
+ ),
+ );
+
+ const adapter = yield* makePiAdapter(effectiveConfig, {
+ environment: processEnv,
+ ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
+ instanceId,
+ extensionPath,
+ });
+ const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv);
+
+ const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv, cwd).pipe(
+ Effect.map(stampIdentity),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+
+ const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
+ const snapshot = yield* makeManagedServerProvider>({
+ resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES),
+ getSettings: snapshotSettings.getSettings,
+ streamSettings: snapshotSettings.streamSettings,
+ haveSettingsChanged: haveProviderSnapshotSettingsChanged,
+ initialSnapshot: (settings) =>
+ buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
+ checkProvider,
+ enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
+ enrichPiSnapshot({
+ snapshot: currentSnapshot,
+ maintenanceCapabilities: MAINTENANCE_CAPABILITIES,
+ enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
+ publishSnapshot,
+ httpClient,
+ }),
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: `Failed to build pi snapshot: ${cause.message ?? String(cause)}`,
+ cause,
+ }),
+ ),
+ );
+
+ const snapshotForCwd = (workspaceCwd: string) =>
+ !effectiveConfig.enabled
+ ? snapshot.getSnapshot
+ : Effect.all([
+ snapshot.getSnapshot,
+ probePiCommandsForCwd(effectiveConfig, processEnv, workspaceCwd).pipe(
+ Effect.timeoutOrElse({
+ duration: "20 seconds",
+ orElse: () =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: `Timed out discovering pi commands for '${workspaceCwd}'`,
+ }),
+ }),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ ),
+ ]).pipe(
+ Effect.map(([machineSnapshot, workspace]) => ({
+ ...machineSnapshot,
+ slashCommands: workspace.slashCommands,
+ skills: workspace.skills,
+ })),
+ );
+
+ return {
+ instanceId,
+ driverKind: DRIVER_KIND,
+ continuationIdentity,
+ displayName,
+ accentColor,
+ enabled,
+ snapshot,
+ snapshotForCwd,
+ adapter,
+ textGeneration,
+ } satisfies ProviderInstance;
+ }),
+};
diff --git a/apps/server/src/provider/Layers/PiAdapter.test.ts b/apps/server/src/provider/Layers/PiAdapter.test.ts
new file mode 100644
index 000000000000..b0dc84649d96
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiAdapter.test.ts
@@ -0,0 +1,647 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeFSP from "node:fs/promises";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+
+import {
+ ApprovalRequestId,
+ EnvironmentId,
+ PiSettings,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ProviderRuntimeEvent,
+ ThreadId,
+} from "@t3tools/contracts";
+
+import { ServerConfig } from "../../config.ts";
+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import { makePiAdapter } from "./PiAdapter.ts";
+
+const decodePiSettings = Schema.decodeSync(PiSettings);
+const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
+const mockPath = NodePath.join(__dirname, "../../../scripts/pi-mock-rpc.ts");
+
+async function makeMockPiWrapper(extraEnv?: Record) {
+ const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-adapter-mock-"));
+ const wrapperPath = NodePath.join(dir, "fake-pi.sh");
+ const envExports = Object.entries(extraEnv ?? {})
+ .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
+ .join("\n");
+ await NodeFSP.writeFile(
+ wrapperPath,
+ `#!/bin/sh\n${envExports}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockPath)} "$@"\n`,
+ "utf8",
+ );
+ await NodeFSP.chmod(wrapperPath, 0o755);
+ return { wrapperPath, dir };
+}
+
+async function makeArgsLoggingPiWrapper(dir: string, requestLog: string, argsLog: string) {
+ const wrapperPath = NodePath.join(dir, "fake-pi-args.sh");
+ await NodeFSP.writeFile(
+ wrapperPath,
+ `#!/bin/sh\nexport T3_PI_MOCK_REQUEST_LOG=${JSON.stringify(requestLog)}\nprintf '%s\\n' "$@" > ${JSON.stringify(argsLog)}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockPath)} "$@"\n`,
+ "utf8",
+ );
+ await NodeFSP.chmod(wrapperPath, 0o755);
+ return wrapperPath;
+}
+
+async function readJsonLines(filePath: string) {
+ const raw = await NodeFSP.readFile(filePath, "utf8").catch(() => "");
+ return raw
+ .split("\n")
+ .filter((line) => line.trim().length > 0)
+ .map((line) => JSON.parse(line) as Record);
+}
+
+const piAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-pi-adapter-test-",
+}).pipe(Layer.provideMerge(NodeServices.layer));
+
+const makeTestAdapter = (binaryPath: string, extensionPath: string) =>
+ makePiAdapter(decodePiSettings({ binaryPath }), {
+ instanceId: ProviderInstanceId.make("pi"),
+ extensionPath,
+ }).pipe(Effect.orDie);
+
+it.layer(piAdapterTestLayer)("PiAdapterLive", (it) => {
+ it.effect("starts a session, streams a turn, and answers the T3 approval request", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-mock-thread");
+ const requestLog = NodePath.join(
+ yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-log-"))),
+ "requests.jsonl",
+ );
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_REQUEST_LOG: requestLog }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const turnCompleted = yield* Deferred.make();
+ const requestOpened = yield* Deferred.make();
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ runtimeEvents.push(event);
+ if (event.type === "request.opened" && event.requestId) {
+ yield* Deferred.succeed(requestOpened, ApprovalRequestId.make(event.requestId));
+ }
+ if (event.type === "turn.completed") {
+ yield* Deferred.succeed(turnCompleted, undefined);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ const session = yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ title: "Mock thread",
+ runtimeMode: "approval-required",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("pi"),
+ model: "openai/gpt-5",
+ options: [{ id: "thinkingLevel", value: "high" }],
+ },
+ });
+ assert.strictEqual(session.provider, "pi");
+ assert.strictEqual(session.model, "openai/gpt-5");
+ assert.deepStrictEqual(session.resumeCursor, {
+ schemaVersion: 1,
+ sessionFile: "/tmp/pi-mock-session.jsonl",
+ });
+
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "hello pi", attachments: [] })
+ .pipe(Effect.forkChild);
+
+ const requestId = yield* Deferred.await(requestOpened);
+ const opened = runtimeEvents.find((event) => event.type === "request.opened");
+ assert.strictEqual(opened?.type, "request.opened");
+ if (opened?.type === "request.opened") {
+ assert.strictEqual(opened.payload.requestType, "exec_command_approval");
+ assert.strictEqual(opened.payload.detail, "echo hi");
+ }
+ yield* adapter.respondToRequest(threadId, requestId, "accept");
+
+ const result = yield* Fiber.join(sendTurnFiber);
+ yield* Deferred.await(turnCompleted);
+ yield* Fiber.interrupt(eventsFiber);
+ assert.strictEqual(result.threadId, threadId);
+
+ const types = runtimeEvents.map((event) => event.type);
+ assert.includeMembers(types, [
+ "session.started",
+ "session.state.changed",
+ "thread.started",
+ "turn.started",
+ "item.started",
+ "content.delta",
+ "thread.token-usage.updated",
+ "request.opened",
+ "request.resolved",
+ "item.completed",
+ "turn.completed",
+ ] as const);
+ const delta = runtimeEvents.find((event) => event.type === "content.delta");
+ assert.strictEqual(delta?.type, "content.delta");
+ if (delta?.type === "content.delta") {
+ assert.strictEqual(delta.payload.delta, "Echo: hello pi");
+ }
+ const tool = runtimeEvents.find(
+ (event) =>
+ event.type === "item.completed" && event.payload.itemType === "command_execution",
+ );
+ assert.isDefined(tool);
+ if (tool?.type === "item.completed") {
+ assert.strictEqual(tool.payload.status, "completed");
+ }
+
+ const requests = yield* Effect.promise(() => readJsonLines(requestLog));
+ const setModel = requests.find((request) => request.type === "set_model");
+ assert.deepStrictEqual(
+ { provider: setModel?.provider, modelId: setModel?.modelId },
+ { provider: "openai", modelId: "gpt-5" },
+ );
+ assert.isTrue(
+ requests.some(
+ (request) => request.type === "set_thinking_level" && request.level === "high",
+ ),
+ );
+ const prompt = requests.find((request) => request.type === "prompt");
+ assert.strictEqual(prompt?.message, "hello pi");
+ const uiResponse = requests.find((request) => request.type === "extension_ui_response");
+ assert.deepStrictEqual(
+ { id: uiResponse?.id, value: uiResponse?.value },
+ { id: "ui-1", value: "accept" },
+ );
+
+ yield* adapter.stopSession(threadId);
+ assert.isFalse(yield* adapter.hasSession(threadId));
+ }),
+ );
+
+ it.effect("hands the thread's MCP credential to pi as environment variables", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-mcp-thread");
+ const requestLog = NodePath.join(
+ yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-log-"))),
+ "requests.jsonl",
+ );
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_REQUEST_LOG: requestLog }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+ McpProviderSession.setMcpProviderSession({
+ environmentId: EnvironmentId.make("env-1"),
+ threadId,
+ providerSessionId: "session-1",
+ providerInstanceId: ProviderInstanceId.make("pi"),
+ endpoint: "http://127.0.0.1:4321/mcp",
+ authorizationHeader: "Bearer secret-token",
+ });
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)),
+ );
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const requests = yield* Effect.promise(() => readJsonLines(requestLog));
+ const env = requests.find((request) => request.type === "mock.env");
+ assert.deepStrictEqual(
+ { mcpUrl: env?.mcpUrl, mcpToken: env?.mcpToken },
+ { mcpUrl: "http://127.0.0.1:4321/mcp", mcpToken: "secret-token" },
+ );
+ yield* adapter.stopSession(threadId);
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("declining an approval marks the tool item failed and still completes the turn", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-decline-thread");
+ const { wrapperPath } = yield* Effect.promise(() => makeMockPiWrapper());
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const requestOpened = yield* Deferred.make();
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ runtimeEvents.push(event);
+ if (event.type === "request.opened" && event.requestId) {
+ yield* Deferred.succeed(requestOpened, ApprovalRequestId.make(event.requestId));
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "approval-required",
+ });
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "run something", attachments: [] })
+ .pipe(Effect.forkChild);
+ const requestId = yield* Deferred.await(requestOpened);
+ yield* adapter.respondToRequest(threadId, requestId, "decline");
+ yield* Fiber.join(sendTurnFiber);
+ yield* Fiber.interrupt(eventsFiber);
+
+ const tool = runtimeEvents.find(
+ (event) =>
+ event.type === "item.completed" && event.payload.itemType === "command_execution",
+ );
+ assert.strictEqual(tool?.type, "item.completed");
+ if (tool?.type === "item.completed") {
+ assert.strictEqual(tool.payload.status, "failed");
+ }
+ const resolved = runtimeEvents.find((event) => event.type === "request.resolved");
+ assert.strictEqual(resolved?.type, "request.resolved");
+ if (resolved?.type === "request.resolved") {
+ assert.strictEqual(resolved.payload.decision, "decline");
+ }
+ assert.isTrue(runtimeEvents.some((event) => event.type === "turn.completed"));
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("shows ask_user questions as user-input requests and answers with an option", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-question-thread");
+ const requestLog = NodePath.join(
+ yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-log-"))),
+ "requests.jsonl",
+ );
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_ASK_QUESTION: "1", T3_PI_MOCK_REQUEST_LOG: requestLog }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const requested = yield* Deferred.make<{
+ requestId: ApprovalRequestId;
+ questionId: string;
+ }>();
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ runtimeEvents.push(event);
+ if (event.type === "user-input.requested" && event.requestId) {
+ yield* Deferred.succeed(requested, {
+ requestId: ApprovalRequestId.make(event.requestId),
+ questionId: event.payload.questions[0]?.id ?? "",
+ });
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "ask me", attachments: [] })
+ .pipe(Effect.forkChild);
+ const { requestId, questionId } = yield* Deferred.await(requested);
+ const opened = runtimeEvents.find((event) => event.type === "user-input.requested");
+ assert.strictEqual(opened?.type, "user-input.requested");
+ if (opened?.type === "user-input.requested") {
+ const question = opened.payload.questions[0];
+ assert.strictEqual(question?.question, "Which color?");
+ assert.deepStrictEqual(
+ question?.options.map((option) => option.value),
+ ["Red", "Blue"],
+ );
+ assert.isTrue(question?.allowCustomAnswer);
+ }
+ yield* adapter.respondToUserInput(threadId, requestId, { [questionId]: "Blue" });
+ yield* Fiber.join(sendTurnFiber);
+ yield* Fiber.interrupt(eventsFiber);
+
+ const tool = runtimeEvents.find(
+ (event) =>
+ event.type === "item.completed" && event.payload.itemType === "dynamic_tool_call",
+ );
+ assert.strictEqual(tool?.type, "item.completed");
+ if (tool?.type === "item.completed") {
+ const data = tool.payload.data as { rawOutput?: { content?: string } };
+ assert.strictEqual(data.rawOutput?.content, "User selected option 2: Blue");
+ }
+ assert.isTrue(runtimeEvents.some((event) => event.type === "user-input.resolved"));
+ const requests = yield* Effect.promise(() => readJsonLines(requestLog));
+ const uiResponses = requests.filter((request) => request.type === "extension_ui_response");
+ assert.deepStrictEqual(
+ uiResponses.map((request) => request.value),
+ ["Blue"],
+ );
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("delivers a custom answer through the follow-up input dialog", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-question-custom-thread");
+ const requestLog = NodePath.join(
+ yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-log-"))),
+ "requests.jsonl",
+ );
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_ASK_QUESTION: "1", T3_PI_MOCK_REQUEST_LOG: requestLog }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const requested = yield* Deferred.make<{
+ requestId: ApprovalRequestId;
+ questionId: string;
+ }>();
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ runtimeEvents.push(event);
+ if (event.type === "user-input.requested" && event.requestId) {
+ yield* Deferred.succeed(requested, {
+ requestId: ApprovalRequestId.make(event.requestId),
+ questionId: event.payload.questions[0]?.id ?? "",
+ });
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "ask me", attachments: [] })
+ .pipe(Effect.forkChild);
+ const { requestId, questionId } = yield* Deferred.await(requested);
+ yield* adapter.respondToUserInput(threadId, requestId, { [questionId]: "Teal, actually" });
+ yield* Fiber.join(sendTurnFiber);
+ yield* Fiber.interrupt(eventsFiber);
+
+ const tool = runtimeEvents.find(
+ (event) =>
+ event.type === "item.completed" && event.payload.itemType === "dynamic_tool_call",
+ );
+ assert.strictEqual(tool?.type, "item.completed");
+ if (tool?.type === "item.completed") {
+ const data = tool.payload.data as { rawOutput?: { content?: string } };
+ assert.strictEqual(data.rawOutput?.content, "User wrote their own answer: Teal, actually");
+ }
+ assert.strictEqual(
+ runtimeEvents.filter((event) => event.type === "user-input.requested").length,
+ 1,
+ );
+ const requests = yield* Effect.promise(() => readJsonLines(requestLog));
+ const uiResponses = requests.filter((request) => request.type === "extension_ui_response");
+ assert.deepStrictEqual(
+ uiResponses.map((request) => request.value),
+ ["__t3_other__", "Teal, actually"],
+ );
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("interruptTurn aborts a hung prompt and settles the turn", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-abort-thread");
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_HANG_PROMPT: "1" }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+ const firstDelta = yield* Deferred.make();
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ event.type === "content.delta" ? Deferred.succeed(firstDelta, undefined) : Effect.void,
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "hang", attachments: [] })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(firstDelta);
+ yield* adapter.interruptTurn(threadId);
+ const result = yield* Fiber.join(sendTurnFiber);
+ assert.strictEqual(result.threadId, threadId);
+ yield* Fiber.interrupt(eventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("a steer joins the running turn and the turn completes once pi settles", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-steer-thread");
+ const requestLog = NodePath.join(
+ yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-log-"))),
+ "requests.jsonl",
+ );
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_SETTLE_ON_STEER: "1", T3_PI_MOCK_REQUEST_LOG: requestLog }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const firstDelta = yield* Deferred.make();
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ runtimeEvents.push(event);
+ if (event.type === "content.delta") {
+ yield* Deferred.succeed(firstDelta, undefined);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const firstTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "first", attachments: [] })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(firstDelta);
+ const steerTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "second", attachments: [] })
+ .pipe(Effect.forkChild);
+
+ const first = yield* Fiber.join(firstTurnFiber);
+ const steer = yield* Fiber.join(steerTurnFiber);
+ yield* Fiber.interrupt(eventsFiber);
+
+ assert.strictEqual(steer.turnId, first.turnId);
+ assert.strictEqual(runtimeEvents.filter((event) => event.type === "turn.started").length, 1);
+ const completed = runtimeEvents.filter((event) => event.type === "turn.completed");
+ assert.strictEqual(completed.length, 1);
+ assert.strictEqual(completed[0]?.type, "turn.completed");
+ if (completed[0]?.type === "turn.completed") {
+ assert.strictEqual(completed[0].payload.state, "completed");
+ }
+
+ const requests = yield* Effect.promise(() => readJsonLines(requestLog));
+ const prompts = requests.filter((request) => request.type === "prompt");
+ assert.deepStrictEqual(
+ prompts.map((request) => [request.message, request.streamingBehavior]),
+ [
+ ["first", undefined],
+ ["second", "steer"],
+ ],
+ );
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("steers into a run pi started on its own and shows it as a turn", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-self-run-thread");
+ const requestLog = NodePath.join(
+ yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-log-"))),
+ "requests.jsonl",
+ );
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_SELF_RUN: "1", T3_PI_MOCK_REQUEST_LOG: requestLog }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const syntheticTurnStarted = yield* Deferred.make();
+ let turnStarts = 0;
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ runtimeEvents.push(event);
+ if (event.type === "turn.started" && ++turnStarts === 2) {
+ yield* Deferred.succeed(syntheticTurnStarted, undefined);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const first = yield* adapter.sendTurn({ threadId, input: "first", attachments: [] });
+ // pi picks work back up with no prompt from us: the adapter opens a turn
+ // for it so the thread reads as running.
+ yield* Deferred.await(syntheticTurnStarted);
+ const second = yield* adapter.sendTurn({ threadId, input: "second", attachments: [] });
+ yield* Fiber.interrupt(eventsFiber);
+
+ assert.notStrictEqual(second.turnId, first.turnId);
+ const started = runtimeEvents
+ .filter((event) => event.type === "turn.started")
+ .map((event) => String(event.turnId));
+ const completed = runtimeEvents
+ .filter((event) => event.type === "turn.completed")
+ .map((event) => String(event.turnId));
+ assert.deepStrictEqual(started, [String(first.turnId), started[1], String(second.turnId)]);
+ assert.deepStrictEqual(completed, started);
+
+ const requests = yield* Effect.promise(() => readJsonLines(requestLog));
+ const prompts = requests.filter((request) => request.type === "prompt");
+ assert.deepStrictEqual(
+ prompts.map((request) => [request.message, request.streamingBehavior]),
+ [
+ ["first", undefined],
+ ["second", "steer"],
+ ],
+ );
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("ends the turn as failed when pi rejects the prompt", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-rejected-prompt-thread");
+ const { wrapperPath } = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_REJECT_PROMPT: "1" }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, "/tmp/t3-code.ts");
+
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.sync(() => {
+ runtimeEvents.push(event);
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const outcome = yield* Effect.exit(
+ adapter.sendTurn({ threadId, input: "hello", attachments: [] }),
+ );
+ yield* Fiber.interrupt(eventsFiber);
+
+ assert.isTrue(Exit.isFailure(outcome));
+ const started = runtimeEvents.filter((event) => event.type === "turn.started");
+ const completed = runtimeEvents.filter((event) => event.type === "turn.completed");
+ assert.strictEqual(started.length, 1);
+ assert.strictEqual(completed.length, 1);
+ const failure = completed[0];
+ assert.strictEqual(failure?.type, "turn.completed");
+ if (failure?.type === "turn.completed") {
+ assert.strictEqual(failure.turnId, started[0]?.turnId);
+ assert.strictEqual(failure.payload.state, "failed");
+ assert.strictEqual(failure.payload.errorMessage, "pi refused the prompt.");
+ }
+ // The failed turn must not stay installed as the session's active one.
+ const sessions = yield* adapter.listSessions();
+ assert.strictEqual(
+ sessions.find((session) => session.threadId === threadId)?.activeTurnId,
+ undefined,
+ );
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("resumes with --session when a resume cursor is provided", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("pi-resume-thread");
+ const { dir } = yield* Effect.promise(() => makeMockPiWrapper());
+ const requestLog = NodePath.join(dir, "requests.jsonl");
+ const argsLog = NodePath.join(dir, "args.txt");
+ const loggingWrapper = yield* Effect.promise(() =>
+ makeArgsLoggingPiWrapper(dir, requestLog, argsLog),
+ );
+ const adapter = yield* makeTestAdapter(loggingWrapper, "/tmp/t3-code.ts");
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("pi"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ resumeCursor: { schemaVersion: 1, sessionFile: "/tmp/previous.jsonl" },
+ });
+ const args = yield* Effect.promise(() => NodeFSP.readFile(argsLog, "utf8"));
+ const argv = args.split("\n");
+ assert.strictEqual(argv[argv.indexOf("--session") + 1], "/tmp/previous.jsonl");
+ assert.strictEqual(argv[argv.indexOf("-e") + 1], "/tmp/t3-code.ts");
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+});
diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts
new file mode 100644
index 000000000000..b7cae6f666ae
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiAdapter.ts
@@ -0,0 +1,1095 @@
+/**
+ * PiAdapter — pi (`pi --mode rpc`) behind the generic `ProviderAdapterShape`.
+ *
+ * One pi process per thread. Turns are `prompt` commands; a `prompt` sent
+ * while pi's agent loop runs becomes a steer, including when that loop is work
+ * pi started on its own (a background terminal exiting, a subagent finishing),
+ * which shows up as a synthetic turn. Approvals arrive as
+ * `extension_ui_request` (method `select`) from the T3 extension and are
+ * answered with `extension_ui_response`. The pi session file is the resume
+ * cursor so a restarted server can `--session ` back into history.
+ *
+ * @module provider/Layers/PiAdapter
+ */
+import {
+ ApprovalRequestId,
+ EventId,
+ PI_DEFAULT_MODEL,
+ type PiSettings,
+ type ProviderApprovalDecision,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ProviderRuntimeEvent,
+ type ProviderSession,
+ type ProviderUserInputAnswers,
+ RuntimeItemId,
+ RuntimeRequestId,
+ type ThreadId,
+ TurnId,
+} from "@t3tools/contracts";
+import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as PubSub from "effect/PubSub";
+import * as Scope from "effect/Scope";
+import * as Semaphore from "effect/Semaphore";
+import * as Stream from "effect/Stream";
+import * as SynchronizedRef from "effect/SynchronizedRef";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import { resolveAttachmentPath } from "../../attachmentStore.ts";
+import { ServerConfig } from "../../config.ts";
+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import {
+ ProviderAdapterProcessError,
+ ProviderAdapterRequestError,
+ ProviderAdapterSessionNotFoundError,
+ ProviderAdapterValidationError,
+} from "../Errors.ts";
+import type { PiAdapterShape } from "../Services/PiAdapter.ts";
+import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts";
+import { type PiRpcClient, spawnPiRpcClient } from "../pi/PiRpcClient.ts";
+import {
+ isPiThinkingLevel,
+ parsePiModelSlug,
+ type PiImageContent,
+ type PiModel,
+ type PiRpcEvent,
+ type PiSessionState,
+ piModelSlug,
+} from "../pi/PiRpcProtocol.ts";
+import { makePiMappingState, mapPiEvent, type PiMappingState } from "../pi/PiRuntimeEvents.ts";
+import {
+ parseT3PiApprovalEnvelope,
+ parseT3PiQuestionEnvelope,
+ T3_PI_MCP_TOKEN_ENV,
+ T3_PI_QUESTION_CUSTOM_MARKER,
+ T3_PI_QUESTION_OTHER_VALUE,
+ T3_PI_MCP_URL_ENV,
+ T3_PI_RUNTIME_MODE_ENV,
+} from "../pi/piExtension.ts";
+import { buildPiEnvironment, PI_REASONING_OPTION_ID, piLaunchArgv } from "./PiProvider.ts";
+
+const PROVIDER = ProviderDriverKind.make("pi");
+const PI_RESUME_VERSION = 1 as const;
+const SUPPORTED_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
+
+export interface PiAdapterLiveOptions {
+ readonly environment?: NodeJS.ProcessEnv;
+ readonly nativeEventLogger?: EventNdjsonLogger;
+ readonly instanceId?: ProviderInstanceId;
+ /** Absolute path of the materialized T3 extension, passed to pi with `-e`. */
+ readonly extensionPath: string;
+}
+
+interface PendingApproval {
+ readonly uiRequestId: string;
+ readonly toolCallId: string;
+ readonly decision: Deferred.Deferred;
+}
+
+interface PendingUserInput {
+ readonly uiRequestId: string;
+ readonly optionLabels: ReadonlyArray;
+ readonly answers: Deferred.Deferred;
+}
+
+interface PiSessionContext {
+ readonly threadId: ThreadId;
+ session: ProviderSession;
+ readonly scope: Scope.Closeable;
+ readonly client: PiRpcClient;
+ readonly pendingApprovals: Map;
+ readonly pendingUserInputs: Map;
+ /** Free-text answer held for the extension's follow-up `input` dialog. */
+ pendingCustomAnswer: { readonly question: string; readonly text: string } | undefined;
+ readonly turns: Array<{ id: TurnId; items: Array }>;
+ readonly mapping: PiMappingState;
+ activeTurnId: TurnId | undefined;
+ currentModel: PiModel | null;
+ currentThinkingLevel: string | undefined;
+ autoCompactionEnabled: boolean | undefined;
+ /** The `prompt` commands in flight; only the last one settles the turn. */
+ promptsInFlight: number;
+ /**
+ * True between pi's `agent_start` and `agent_settled`. pi also runs without
+ * a prompt from us (background terminal exits, subagent results), so this is
+ * the only reliable answer to "is pi busy right now".
+ */
+ agentRunning: boolean;
+ /** Turn opened for a run pi started on its own; the next prompt closes it. */
+ syntheticTurnId: TurnId | undefined;
+ /** Shared by every prompt in flight: pi settles a steered run once. */
+ turnSettled: Deferred.Deferred | undefined;
+ /** Last assistant `stopReason` seen during the active turn. */
+ lastStopReason: string | undefined;
+ stopped: boolean;
+}
+
+interface PiResumeCursor {
+ readonly schemaVersion: typeof PI_RESUME_VERSION;
+ readonly sessionFile: string;
+}
+
+function parsePiResume(raw: unknown): PiResumeCursor | undefined {
+ if (typeof raw !== "object" || raw === null) return undefined;
+ const record = raw as { schemaVersion?: unknown; sessionFile?: unknown };
+ return record.schemaVersion === PI_RESUME_VERSION && typeof record.sessionFile === "string"
+ ? { schemaVersion: PI_RESUME_VERSION, sessionFile: record.sessionFile }
+ : undefined;
+}
+
+export function makePiAdapter(piSettings: PiSettings, options: PiAdapterLiveOptions) {
+ return Effect.gen(function* () {
+ const boundInstanceId = options.instanceId ?? ProviderInstanceId.make("pi");
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const serverConfig = yield* Effect.service(ServerConfig);
+ const crypto = yield* Crypto.Crypto;
+ const nativeEventLogger = options.nativeEventLogger;
+
+ // `watchExit` closes the session scope, so it cannot run inside it.
+ const adapterScope = yield* Effect.scope;
+ const sessions = new Map();
+ const threadLocksRef = yield* SynchronizedRef.make(new Map());
+ const runtimeEventPubSub = yield* PubSub.unbounded();
+
+ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+ const randomUUIDv4 = crypto.randomUUIDv4.pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "crypto/randomUUIDv4",
+ detail: "Failed to generate pi runtime identifier.",
+ cause,
+ }),
+ ),
+ );
+ const makeEventStamp = () =>
+ Effect.all({ eventId: Effect.map(randomUUIDv4, EventId.make), createdAt: nowIso });
+ const offerRuntimeEvent = (event: ProviderRuntimeEvent) =>
+ PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid);
+
+ const getThreadSemaphore = (threadId: string) =>
+ SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
+ const existing = current.get(threadId);
+ if (existing) return Effect.succeed([existing, current] as const);
+ return Semaphore.make(1).pipe(
+ Effect.map((semaphore) => {
+ const next = new Map(current);
+ next.set(threadId, semaphore);
+ return [semaphore, next] as const;
+ }),
+ );
+ });
+ const withThreadLock = (threadId: string, effect: Effect.Effect ) =>
+ Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
+
+ const logNative = (threadId: ThreadId, method: string, payload: unknown) =>
+ Effect.gen(function* () {
+ if (!nativeEventLogger) return;
+ const observedAt = yield* nowIso;
+ yield* nativeEventLogger.write(
+ {
+ observedAt,
+ event: {
+ id: yield* randomUUIDv4,
+ kind: "notification",
+ provider: PROVIDER,
+ createdAt: observedAt,
+ method,
+ threadId,
+ payload,
+ },
+ },
+ threadId,
+ );
+ });
+
+ const requireSession = (
+ threadId: ThreadId,
+ ): Effect.Effect => {
+ const ctx = sessions.get(threadId);
+ if (!ctx || ctx.stopped) {
+ return Effect.fail(
+ new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }),
+ );
+ }
+ return Effect.succeed(ctx);
+ };
+
+ const settlePendingApprovalsAsCancelled = (ctx: PiSessionContext) =>
+ Effect.forEach(
+ Array.from(ctx.pendingApprovals.values()),
+ (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.asVoid),
+ { discard: true },
+ ).pipe(
+ Effect.andThen(
+ Effect.forEach(
+ Array.from(ctx.pendingUserInputs.values()),
+ (pending) => Deferred.succeed(pending.answers, undefined).pipe(Effect.asVoid),
+ { discard: true },
+ ),
+ ),
+ );
+
+ const stopSessionInternal = (ctx: PiSessionContext) =>
+ Effect.gen(function* () {
+ if (ctx.stopped) return;
+ ctx.stopped = true;
+ yield* settlePendingApprovalsAsCancelled(ctx);
+ if (ctx.turnSettled) {
+ yield* Deferred.succeed(ctx.turnSettled, undefined);
+ }
+ yield* Effect.ignore(Scope.close(ctx.scope, Exit.void));
+ sessions.delete(ctx.threadId);
+ yield* offerRuntimeEvent({
+ type: "session.exited",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ payload: { exitKind: "graceful" },
+ });
+ });
+
+ /** Apply a T3 model selection to the running pi session. Sentinel keeps pi's own choice. */
+ const applyModelSelection = (
+ ctx: PiSessionContext,
+ modelSelection: { readonly model: string; readonly options?: unknown } | undefined,
+ ) =>
+ Effect.gen(function* () {
+ if (!modelSelection) return;
+ const requestedSlug = modelSelection.model.trim();
+ const currentSlug = ctx.currentModel ? piModelSlug(ctx.currentModel) : undefined;
+ if (requestedSlug && requestedSlug !== PI_DEFAULT_MODEL && requestedSlug !== currentSlug) {
+ const parsed = parsePiModelSlug(requestedSlug);
+ if (!parsed) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "set_model",
+ issue: `pi model ids look like 'provider/model', got '${requestedSlug}'.`,
+ });
+ }
+ const response = yield* ctx.client.request({
+ type: "set_model",
+ provider: parsed.provider,
+ modelId: parsed.modelId,
+ });
+ ctx.currentModel = (response.data as PiModel | undefined) ?? null;
+ }
+ const requestedLevel = getModelSelectionStringOptionValue(
+ modelSelection as never,
+ PI_REASONING_OPTION_ID,
+ );
+ if (
+ requestedLevel &&
+ isPiThinkingLevel(requestedLevel) &&
+ requestedLevel !== ctx.currentThinkingLevel
+ ) {
+ yield* ctx.client.request({ type: "set_thinking_level", level: requestedLevel });
+ ctx.currentThinkingLevel = requestedLevel;
+ }
+ });
+
+ /**
+ * A `t3-question` select from the user's `ask_user` extension. Shown as a
+ * T3 question card; an answer that is not one of the offered labels is
+ * held and delivered through the extension's follow-up `input` dialog.
+ */
+ const handleQuestionRequest = (
+ ctx: PiSessionContext,
+ event: Extract,
+ envelope: Extract, { t3: "t3-question" }>,
+ ) =>
+ Effect.gen(function* () {
+ const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
+ const runtimeRequestId = RuntimeRequestId.make(requestId);
+ const answers = yield* Deferred.make();
+ const optionLabels = envelope.options.map((option) => option.label);
+ ctx.pendingUserInputs.set(requestId, { uiRequestId: event.id, optionLabels, answers });
+ const questionId = event.id;
+ yield* offerRuntimeEvent({
+ type: "user-input.requested",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: {
+ questions: [
+ {
+ id: questionId,
+ header: "Question",
+ question: envelope.question,
+ options: envelope.options.map((option) => ({
+ label: option.label,
+ description: option.description ?? option.label,
+ value: option.label,
+ })),
+ allowCustomAnswer: true,
+ multiSelect: false,
+ },
+ ],
+ },
+ raw: { source: "pi.rpc", method: "extension_ui_request", payload: event },
+ });
+
+ yield* Effect.gen(function* () {
+ const resolved = yield* Deferred.await(answers);
+ ctx.pendingUserInputs.delete(requestId);
+ const rawAnswer = resolved?.[questionId];
+ const answer =
+ typeof rawAnswer === "string"
+ ? rawAnswer.trim()
+ : Array.isArray(rawAnswer) && typeof rawAnswer[0] === "string"
+ ? rawAnswer[0].trim()
+ : "";
+ if (resolved === undefined || answer.length === 0) {
+ yield* ctx.client
+ .notify({ type: "extension_ui_response", id: event.id, cancelled: true })
+ .pipe(Effect.ignore);
+ } else if (optionLabels.includes(answer)) {
+ yield* ctx.client
+ .notify({ type: "extension_ui_response", id: event.id, value: answer })
+ .pipe(Effect.ignore);
+ } else {
+ ctx.pendingCustomAnswer = { question: envelope.question, text: answer };
+ yield* ctx.client
+ .notify({
+ type: "extension_ui_response",
+ id: event.id,
+ value: T3_PI_QUESTION_OTHER_VALUE,
+ })
+ .pipe(Effect.ignore);
+ }
+ yield* offerRuntimeEvent({
+ type: "user-input.resolved",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: { answers: resolved ?? {} },
+ });
+ }).pipe(Effect.forkIn(ctx.scope));
+ });
+
+ const handleExtensionUiRequest = (
+ ctx: PiSessionContext,
+ event: Extract,
+ ) =>
+ Effect.gen(function* () {
+ const question = parseT3PiQuestionEnvelope(event.title);
+ if (question?.t3 === "t3-question" && event.method === "select") {
+ return yield* handleQuestionRequest(ctx, event, question);
+ }
+ if (question?.t3 === T3_PI_QUESTION_CUSTOM_MARKER && event.method === "input") {
+ const held = ctx.pendingCustomAnswer;
+ ctx.pendingCustomAnswer = undefined;
+ yield* ctx.client
+ .notify(
+ held && held.question === question.question
+ ? { type: "extension_ui_response", id: event.id, value: held.text }
+ : { type: "extension_ui_response", id: event.id, cancelled: true },
+ )
+ .pipe(Effect.ignore);
+ return;
+ }
+ const envelope = parseT3PiApprovalEnvelope(event.title);
+ if (event.method !== "select" || !envelope) {
+ // Another extension's dialog. Cancel it so pi does not hang waiting on us.
+ if (
+ event.method === "select" ||
+ event.method === "confirm" ||
+ event.method === "input" ||
+ event.method === "editor"
+ ) {
+ yield* ctx.client.notify({
+ type: "extension_ui_response",
+ id: event.id,
+ cancelled: true,
+ });
+ }
+ return;
+ }
+ const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
+ const runtimeRequestId = RuntimeRequestId.make(requestId);
+ const decision = yield* Deferred.make();
+ ctx.pendingApprovals.set(requestId, {
+ uiRequestId: event.id,
+ toolCallId: envelope.toolCallId,
+ decision,
+ });
+ const input = envelope.input as Record | undefined;
+ const command = typeof input?.command === "string" ? input.command : undefined;
+ const filePath = typeof input?.path === "string" ? input.path : undefined;
+ const isCommand = envelope.toolName === "bash" || envelope.toolName === "powershell";
+ const isFileChange = envelope.toolName === "edit" || envelope.toolName === "write";
+ yield* offerRuntimeEvent({
+ type: "request.opened",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ itemId: RuntimeItemId.make(envelope.toolCallId),
+ requestId: runtimeRequestId,
+ payload: {
+ requestType: isCommand
+ ? "exec_command_approval"
+ : isFileChange
+ ? "file_change_approval"
+ : "dynamic_tool_call",
+ detail: command ?? filePath ?? envelope.toolName,
+ options: [
+ { decision: "accept", label: "Allow" },
+ { decision: "acceptForSession", label: "Allow for this session" },
+ { decision: "decline", label: "Deny" },
+ ],
+ args: { toolName: envelope.toolName, input: envelope.input },
+ },
+ raw: { source: "pi.rpc", method: "extension_ui_request", payload: event },
+ });
+
+ // Resolve off the event loop so the stream keeps flowing while we wait.
+ yield* Effect.gen(function* () {
+ const resolved = yield* Deferred.await(decision);
+ ctx.pendingApprovals.delete(requestId);
+ const value =
+ resolved === "accept" || resolved === "acceptAlways"
+ ? "accept"
+ : resolved === "acceptForSession"
+ ? "acceptForSession"
+ : "decline";
+ yield* ctx.client
+ .notify({ type: "extension_ui_response", id: event.id, value })
+ .pipe(Effect.ignore);
+ yield* offerRuntimeEvent({
+ type: "request.resolved",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: {
+ requestType: isCommand
+ ? "exec_command_approval"
+ : isFileChange
+ ? "file_change_approval"
+ : "dynamic_tool_call",
+ decision: resolved,
+ },
+ });
+ if (resolved === "cancel") {
+ yield* ctx.client.request({ type: "abort" }).pipe(Effect.ignore);
+ }
+ }).pipe(Effect.forkIn(ctx.scope));
+ });
+
+ /** Ends `turnId`, clears the active-turn state, and reports how it ended. */
+ const completeTurn = (
+ ctx: PiSessionContext,
+ turnId: TurnId,
+ stopReason: string | undefined,
+ errorMessage?: string,
+ ) =>
+ Effect.gen(function* () {
+ ctx.activeTurnId = undefined;
+ ctx.syntheticTurnId = undefined;
+ ctx.turnSettled = undefined;
+ ctx.lastStopReason = undefined;
+ ctx.session = { ...ctx.session, activeTurnId: undefined, updatedAt: yield* nowIso };
+ yield* offerRuntimeEvent({
+ type: "turn.completed",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ turnId,
+ payload: ctx.stopped
+ ? { state: "interrupted", stopReason: "session_exited" }
+ : stopReason === "aborted"
+ ? { state: "cancelled", stopReason }
+ : stopReason === "error"
+ ? {
+ state: "failed",
+ stopReason,
+ errorMessage: errorMessage ?? "pi reported an error.",
+ }
+ : { state: "completed", stopReason: stopReason ?? null },
+ });
+ });
+
+ /**
+ * Opens a turn for work pi started on its own, so the thread shows as
+ * running instead of hanging the output off the turn that just finished.
+ */
+ const startSyntheticTurn = (ctx: PiSessionContext) =>
+ Effect.gen(function* () {
+ const turnId = TurnId.make(yield* randomUUIDv4);
+ ctx.activeTurnId = turnId;
+ ctx.syntheticTurnId = turnId;
+ ctx.lastStopReason = undefined;
+ ctx.session = { ...ctx.session, activeTurnId: turnId, updatedAt: yield* nowIso };
+ yield* offerRuntimeEvent({
+ type: "turn.started",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ turnId,
+ payload: {
+ ...(ctx.currentModel ? { model: piModelSlug(ctx.currentModel) } : {}),
+ ...(ctx.currentThinkingLevel ? { effort: ctx.currentThinkingLevel } : {}),
+ },
+ raw: { source: "pi.rpc", method: "pi/synthetic-turn-start", payload: {} },
+ });
+ });
+
+ const consumeEvents = (ctx: PiSessionContext) =>
+ Stream.fromQueue(ctx.client.events).pipe(
+ Stream.runForEach((event) =>
+ Effect.gen(function* () {
+ yield* logNative(ctx.threadId, event.type, event);
+ if (event.type === "extension_ui_request" && "id" in event && "method" in event) {
+ yield* handleExtensionUiRequest(
+ ctx,
+ event as Extract,
+ );
+ return;
+ }
+ if (event.type === "agent_start") {
+ ctx.agentRunning = true;
+ if (ctx.promptsInFlight === 0 && !ctx.activeTurnId) {
+ yield* startSyntheticTurn(ctx);
+ }
+ return;
+ }
+ // `agent_end` can still be followed by retries, compaction, or
+ // queued steers; only `agent_settled` means the run is over.
+ if (event.type === "agent_settled") {
+ ctx.agentRunning = false;
+ if (ctx.turnSettled) {
+ yield* Deferred.succeed(ctx.turnSettled, undefined);
+ } else if (ctx.syntheticTurnId) {
+ yield* completeTurn(ctx, ctx.syntheticTurnId, ctx.lastStopReason);
+ }
+ return;
+ }
+ if (event.type === "message_end" && "message" in event) {
+ const message = event.message as { role?: unknown; stopReason?: unknown } | undefined;
+ if (message?.role === "assistant" && typeof message.stopReason === "string") {
+ ctx.lastStopReason = message.stopReason;
+ }
+ }
+ // One UUID per pi event; mapped runtime events derive unique ids
+ // by suffix so the mapper stays synchronous and pure.
+ const createdAt = yield* nowIso;
+ const baseId = yield* randomUUIDv4;
+ let sequence = 0;
+ const mapped = mapPiEvent({
+ event,
+ stamp: () => ({
+ eventId: EventId.make(sequence++ === 0 ? baseId : `${baseId}-${sequence}`),
+ createdAt,
+ }),
+ ctx: {
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ turnId: ctx.activeTurnId,
+ },
+ state: ctx.mapping,
+ contextWindow: ctx.currentModel?.contextWindow,
+ autoCompactionEnabled: ctx.autoCompactionEnabled,
+ });
+ for (const runtimeEvent of mapped) {
+ yield* offerRuntimeEvent(runtimeEvent);
+ }
+ }),
+ ),
+ Effect.catch((cause) => Effect.logError("Failed to process pi runtime event.", { cause })),
+ );
+
+ const consumeStderr = (ctx: PiSessionContext) =>
+ Stream.fromQueue(ctx.client.stderrLines).pipe(
+ Stream.runForEach((line) => logNative(ctx.threadId, "process/stderr", { line })),
+ Effect.ignore,
+ );
+
+ const watchExit = (ctx: PiSessionContext) =>
+ Deferred.await(ctx.client.exited).pipe(
+ Effect.flatMap((code) =>
+ Effect.gen(function* () {
+ if (ctx.stopped) return;
+ ctx.stopped = true;
+ ctx.agentRunning = false;
+ yield* settlePendingApprovalsAsCancelled(ctx);
+ if (ctx.turnSettled) {
+ yield* Deferred.succeed(ctx.turnSettled, undefined);
+ } else if (ctx.syntheticTurnId) {
+ yield* completeTurn(ctx, ctx.syntheticTurnId, undefined);
+ }
+ sessions.delete(ctx.threadId);
+ ctx.session = {
+ ...ctx.session,
+ status: code === 0 ? "closed" : "error",
+ activeTurnId: undefined,
+ updatedAt: yield* nowIso,
+ ...(code === 0 ? {} : { lastError: `pi exited with code ${code}.` }),
+ };
+ yield* offerRuntimeEvent({
+ type: "session.exited",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: ctx.threadId,
+ payload: {
+ exitKind: code === 0 ? "graceful" : "error",
+ ...(code === 0 ? {} : { reason: `pi exited with code ${code}.` }),
+ },
+ });
+ // `stopSession` closes the scope on its way out; a pi process that
+ // exits on its own has to reach the same place, or the client
+ // finalizer and the stderr reader stay parked on dead queues.
+ yield* Effect.ignore(Scope.close(ctx.scope, Exit.void));
+ }),
+ ),
+ );
+
+ const startSession: PiAdapterShape["startSession"] = (input) =>
+ withThreadLock(
+ input.threadId,
+ Effect.gen(function* () {
+ if (input.provider !== undefined && input.provider !== PROVIDER) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "startSession",
+ issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
+ });
+ }
+ if (!input.cwd?.trim()) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "startSession",
+ issue: "cwd is required and must be non-empty.",
+ });
+ }
+ const cwd = path.resolve(input.cwd.trim());
+ const modelSelection =
+ input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
+ const existing = sessions.get(input.threadId);
+ if (existing && !existing.stopped) {
+ yield* stopSessionInternal(existing);
+ }
+
+ const sessionScope = yield* Scope.make("sequential");
+ let sessionScopeTransferred = false;
+ yield* Effect.addFinalizer(() =>
+ sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
+ );
+
+ const syntheticItemSeed = yield* randomUUIDv4;
+ let syntheticItemCounter = 0;
+ const resume = parsePiResume(input.resumeCursor);
+ const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
+ const env: NodeJS.ProcessEnv = {
+ ...buildPiEnvironment(piSettings, options.environment ?? process.env),
+ [T3_PI_RUNTIME_MODE_ENV]: input.runtimeMode,
+ ...(mcpSession
+ ? {
+ [T3_PI_MCP_URL_ENV]: mcpSession.endpoint,
+ [T3_PI_MCP_TOKEN_ENV]: mcpSession.authorizationHeader.replace(/^Bearer\s+/i, ""),
+ }
+ : {}),
+ };
+ const args = [
+ "--mode",
+ "rpc",
+ "-e",
+ options.extensionPath,
+ ...(resume ? ["--session", resume.sessionFile] : []),
+ ...(input.title?.trim() && !resume ? ["--name", input.title.trim()] : []),
+ ...piLaunchArgv(piSettings),
+ ];
+
+ const client = yield* spawnPiRpcClient({
+ binaryPath: piSettings.binaryPath || "pi",
+ args,
+ cwd,
+ env,
+ threadId: input.threadId,
+ }).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
+ Effect.provideService(Scope.Scope, sessionScope),
+ );
+
+ const ctx: PiSessionContext = {
+ threadId: input.threadId,
+ session: {
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ status: "connecting",
+ runtimeMode: input.runtimeMode,
+ cwd,
+ threadId: input.threadId,
+ createdAt: "",
+ updatedAt: "",
+ },
+ scope: sessionScope,
+ client,
+ pendingApprovals: new Map(),
+ pendingUserInputs: new Map(),
+ pendingCustomAnswer: undefined,
+ turns: [],
+ mapping: makePiMappingState(() => `pi-${syntheticItemSeed}-${++syntheticItemCounter}`),
+ activeTurnId: undefined,
+ currentModel: null,
+ currentThinkingLevel: undefined,
+ autoCompactionEnabled: undefined,
+ promptsInFlight: 0,
+ agentRunning: false,
+ syntheticTurnId: undefined,
+ turnSettled: undefined,
+ lastStopReason: undefined,
+ stopped: false,
+ };
+
+ yield* consumeEvents(ctx).pipe(Effect.forkIn(sessionScope));
+ yield* consumeStderr(ctx).pipe(Effect.forkIn(sessionScope));
+ yield* watchExit(ctx).pipe(Effect.forkIn(adapterScope));
+
+ const state = (yield* client.request({ type: "get_state" }, { timeoutMs: 30_000 }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId: input.threadId,
+ detail: `pi did not start: ${cause.detail}`,
+ cause,
+ }),
+ ),
+ )).data as PiSessionState;
+ ctx.currentModel = state.model;
+ ctx.currentThinkingLevel = state.thinkingLevel;
+ ctx.autoCompactionEnabled = state.autoCompactionEnabled;
+ yield* applyModelSelection(ctx, modelSelection);
+
+ const now = yield* nowIso;
+ const session: ProviderSession = {
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ status: "ready",
+ runtimeMode: input.runtimeMode,
+ cwd,
+ model:
+ modelSelection?.model ??
+ (ctx.currentModel ? piModelSlug(ctx.currentModel) : undefined),
+ threadId: input.threadId,
+ ...(state.sessionFile
+ ? {
+ resumeCursor: {
+ schemaVersion: PI_RESUME_VERSION,
+ sessionFile: state.sessionFile,
+ } satisfies PiResumeCursor,
+ }
+ : {}),
+ createdAt: now,
+ updatedAt: now,
+ };
+ ctx.session = session;
+ sessions.set(input.threadId, ctx);
+ sessionScopeTransferred = true;
+
+ yield* offerRuntimeEvent({
+ type: "session.started",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: input.threadId,
+ payload: { resume: session.resumeCursor },
+ });
+ yield* offerRuntimeEvent({
+ type: "session.state.changed",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: input.threadId,
+ payload: { state: "ready", reason: "pi session ready" },
+ });
+ yield* offerRuntimeEvent({
+ type: "thread.started",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: input.threadId,
+ payload: { providerThreadId: state.sessionId },
+ });
+ return session;
+ }).pipe(Effect.scoped),
+ );
+
+ const sendTurn: PiAdapterShape["sendTurn"] = (input) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(input.threadId);
+ const steering = ctx.promptsInFlight > 0;
+ const turnId =
+ (steering ? ctx.activeTurnId : undefined) ?? TurnId.make(yield* randomUUIDv4);
+ ctx.promptsInFlight += 1;
+
+ return yield* Effect.gen(function* () {
+ const modelSelection =
+ input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
+ yield* applyModelSelection(ctx, modelSelection);
+
+ const text = input.input?.trim() ?? "";
+ const images: Array = [];
+ for (const attachment of input.attachments ?? []) {
+ // pi ingests images only. Generic files reach the agent through
+ // the path line ProviderService puts in the prompt.
+ if (
+ attachment.type !== "image" ||
+ !SUPPORTED_IMAGE_MIME_TYPES.has(attachment.mimeType)
+ ) {
+ continue;
+ }
+ const attachmentPath = resolveAttachmentPath({
+ attachmentsDir: serverConfig.attachmentsDir,
+ attachment,
+ });
+ if (!attachmentPath) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "prompt",
+ detail: `Invalid attachment id '${attachment.id}'.`,
+ });
+ }
+ const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "prompt",
+ detail: cause.message,
+ cause,
+ }),
+ ),
+ );
+ images.push({
+ type: "image",
+ data: Buffer.from(bytes).toString("base64"),
+ mimeType: attachment.mimeType,
+ });
+ }
+ if (!text && images.length === 0) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "sendTurn",
+ issue: "Turn requires non-empty text or attachments.",
+ });
+ }
+
+ // A run pi started on its own is still displayed as a turn. The
+ // user's message closes it and opens its own turn, the way the
+ // Claude adapter treats its synthetic turns.
+ if (ctx.syntheticTurnId) {
+ yield* completeTurn(ctx, ctx.syntheticTurnId, undefined);
+ }
+
+ ctx.activeTurnId = turnId;
+ // pi runs a steer inside the same agent loop and emits one
+ // `agent_settled` for the whole run, so every prompt fiber waits on
+ // the same signal. Replacing it would orphan the first prompt.
+ const settled =
+ steering && ctx.turnSettled ? ctx.turnSettled : yield* Deferred.make();
+ ctx.turnSettled = settled;
+ ctx.session = { ...ctx.session, activeTurnId: turnId, updatedAt: yield* nowIso };
+
+ if (!steering) {
+ ctx.lastStopReason = undefined;
+ yield* offerRuntimeEvent({
+ type: "turn.started",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: input.threadId,
+ turnId,
+ payload: {
+ ...(ctx.currentModel ? { model: piModelSlug(ctx.currentModel) } : {}),
+ ...(ctx.currentThinkingLevel ? { effort: ctx.currentThinkingLevel } : {}),
+ },
+ });
+ }
+
+ // pi rejects a bare `prompt` while its agent loop runs, and the loop
+ // may be running work we never asked for. `agent_start` can also
+ // still be in flight when we write, so a rejection retries as a
+ // steer rather than losing the message.
+ const prompt = {
+ type: "prompt" as const,
+ message: text || "See the attached image.",
+ ...(images.length > 0 ? { images } : {}),
+ };
+ const piBusy = steering || ctx.agentRunning;
+ yield* ctx.client
+ .request({ ...prompt, ...(piBusy ? { streamingBehavior: "steer" as const } : {}) })
+ .pipe(
+ Effect.catchIf(
+ (error) => !piBusy && /already processing/i.test(error.detail),
+ () => ctx.client.request({ ...prompt, streamingBehavior: "steer" as const }),
+ ),
+ // A rejected prompt would otherwise leave this turn installed as
+ // the active one with nothing left to settle it, and the next
+ // turn would inherit its settle signal. A rejected steer belongs
+ // to the turn that is still running, so it tears down nothing.
+ Effect.tapError((error) =>
+ steering
+ ? Effect.void
+ : Effect.gen(function* () {
+ yield* completeTurn(ctx, turnId, "error", error.detail);
+ if (ctx.agentRunning && !ctx.stopped) {
+ yield* startSyntheticTurn(ctx);
+ }
+ }),
+ ),
+ );
+
+ const turnRecord = ctx.turns.find((turn) => turn.id === turnId);
+ const item = { prompt: text, imageCount: images.length };
+ if (turnRecord) turnRecord.items.push(item);
+ else ctx.turns.push({ id: turnId, items: [item] });
+
+ // Wait until pi settles (agent_settled) or the session dies.
+ if (!ctx.stopped) {
+ yield* Deferred.await(settled);
+ }
+
+ if (ctx.promptsInFlight === 1) {
+ yield* completeTurn(ctx, turnId, ctx.lastStopReason);
+ // pi settled our turn and went straight back to work of its own.
+ // The `agent_start` branch could not open a turn for it while this
+ // prompt was still in flight, so it happens here instead.
+ if (ctx.agentRunning && !ctx.stopped) {
+ yield* startSyntheticTurn(ctx);
+ }
+ }
+
+ return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor };
+ }).pipe(
+ Effect.ensuring(
+ Effect.sync(() => {
+ ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1);
+ }),
+ ),
+ );
+ });
+
+ const interruptTurn: PiAdapterShape["interruptTurn"] = (threadId) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ yield* settlePendingApprovalsAsCancelled(ctx);
+ yield* ctx.client.request({ type: "abort" }).pipe(Effect.ignore);
+ });
+
+ const respondToRequest: PiAdapterShape["respondToRequest"] = (threadId, requestId, decision) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ const pending = ctx.pendingApprovals.get(requestId);
+ if (!pending) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "extension_ui_response",
+ detail: `Unknown pending approval request: ${requestId}`,
+ });
+ }
+ yield* Deferred.succeed(pending.decision, decision);
+ });
+
+ const respondToUserInput: PiAdapterShape["respondToUserInput"] = (
+ threadId,
+ requestId,
+ answers,
+ ) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ const pending = ctx.pendingUserInputs.get(requestId);
+ if (!pending) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "extension_ui_response",
+ detail: `Unknown pending user-input request: ${requestId}`,
+ });
+ }
+ yield* Deferred.succeed(pending.answers, answers);
+ });
+
+ const readThread: PiAdapterShape["readThread"] = (threadId) =>
+ Effect.map(requireSession(threadId), (ctx) => ({ threadId, turns: ctx.turns }));
+
+ const rollbackThread: PiAdapterShape["rollbackThread"] = (threadId, numTurns) =>
+ Effect.gen(function* () {
+ const ctx = yield* requireSession(threadId);
+ if (!Number.isInteger(numTurns) || numTurns < 1) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "rollbackThread",
+ issue: "numTurns must be an integer >= 1.",
+ });
+ }
+ ctx.turns.splice(Math.max(0, ctx.turns.length - numTurns));
+ return { threadId, turns: ctx.turns };
+ });
+
+ const stopSession: PiAdapterShape["stopSession"] = (threadId) =>
+ withThreadLock(threadId, Effect.flatMap(requireSession(threadId), stopSessionInternal));
+
+ const listSessions: PiAdapterShape["listSessions"] = () =>
+ Effect.sync(() => Array.from(sessions.values(), (ctx) => ({ ...ctx.session })));
+
+ const hasSession: PiAdapterShape["hasSession"] = (threadId) =>
+ Effect.sync(() => {
+ const ctx = sessions.get(threadId);
+ return ctx !== undefined && !ctx.stopped;
+ });
+
+ const stopAll: PiAdapterShape["stopAll"] = () =>
+ Effect.forEach(sessions.values(), stopSessionInternal, { discard: true });
+
+ yield* Effect.addFinalizer(() =>
+ Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe(
+ Effect.catch((cause) =>
+ Effect.logError("Failed to emit pi session shutdown event.", { cause }),
+ ),
+ Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)),
+ ),
+ );
+
+ return {
+ provider: PROVIDER,
+ capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false },
+ startSession,
+ sendTurn,
+ interruptTurn,
+ readThread,
+ rollbackThread,
+ respondToRequest,
+ respondToUserInput,
+ stopSession,
+ listSessions,
+ hasSession,
+ stopAll,
+ streamEvents: Stream.fromPubSub(runtimeEventPubSub),
+ } satisfies PiAdapterShape;
+ });
+}
diff --git a/apps/server/src/provider/Layers/PiProvider.test.ts b/apps/server/src/provider/Layers/PiProvider.test.ts
new file mode 100644
index 000000000000..6388454c1a66
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiProvider.test.ts
@@ -0,0 +1,183 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeFSP from "node:fs/promises";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+
+import { PI_DEFAULT_MODEL, PiSettings } from "@t3tools/contracts";
+
+import {
+ buildPiCommandsFromRpc,
+ buildPiEnvironment,
+ buildPiModelCapabilities,
+ buildPiModelsFromRpc,
+ checkPiProviderStatus,
+ piThinkingLevelsForModel,
+} from "./PiProvider.ts";
+
+const decodePiSettings = Schema.decodeSync(PiSettings);
+const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
+const mockPath = NodePath.join(__dirname, "../../../scripts/pi-mock-rpc.ts");
+
+async function makeMockPiWrapper(extraEnv?: Record) {
+ const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-mock-"));
+ const wrapperPath = NodePath.join(dir, "fake-pi.sh");
+ const envExports = Object.entries(extraEnv ?? {})
+ .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
+ .join("\n");
+ await NodeFSP.writeFile(
+ wrapperPath,
+ `#!/bin/sh\n${envExports}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockPath)} "$@"\n`,
+ "utf8",
+ );
+ await NodeFSP.chmod(wrapperPath, 0o755);
+ return wrapperPath;
+}
+
+it("exposes thinking levels from reasoning and thinkingLevelMap holes", () => {
+ assert.deepStrictEqual(
+ piThinkingLevelsForModel({ id: "m", name: "m", provider: "p", reasoning: false }),
+ [],
+ );
+ assert.deepStrictEqual(
+ piThinkingLevelsForModel({
+ id: "m",
+ name: "m",
+ provider: "p",
+ reasoning: true,
+ thinkingLevelMap: { minimal: null, low: null, high: "high", max: "max" },
+ }),
+ ["off", "medium", "high", "max"],
+ );
+ const capabilities = buildPiModelCapabilities({
+ id: "m",
+ name: "m",
+ provider: "p",
+ reasoning: true,
+ });
+ const descriptor = capabilities.optionDescriptors?.[0];
+ assert.strictEqual(descriptor?.id, "thinkingLevel");
+ assert.strictEqual(descriptor?.type, "select");
+ if (descriptor?.type === "select") {
+ assert.strictEqual(descriptor.currentValue, "medium");
+ assert.deepStrictEqual(
+ descriptor.options.map((option) => option.id),
+ ["off", "minimal", "low", "medium", "high"],
+ );
+ }
+});
+
+it("builds the model list with the sentinel first and pi's current model as default", () => {
+ const models = buildPiModelsFromRpc(
+ [
+ { id: "a", name: "A", provider: "anthropic" },
+ { id: "a", name: "A again", provider: "anthropic" },
+ { id: "b", name: "B", provider: "openai" },
+ ],
+ { id: "b", name: "B", provider: "openai" },
+ );
+ assert.deepStrictEqual(
+ models.map((model) => model.slug),
+ [PI_DEFAULT_MODEL, "anthropic/a", "openai/b"],
+ );
+ assert.isTrue(models[2]?.isDefault);
+ assert.strictEqual(models[1]?.subProvider, "anthropic");
+});
+
+it("splits pi commands into skills and slash commands", () => {
+ const result = buildPiCommandsFromRpc([
+ {
+ name: "fix-tests",
+ description: "Fix",
+ source: "prompt",
+ sourceInfo: { path: "/p.md", scope: "project" },
+ },
+ {
+ name: "skill:brave",
+ description: "Search",
+ source: "skill",
+ sourceInfo: { path: "/s/SKILL.md", scope: "user" },
+ },
+ { name: "llama", source: "extension" },
+ { name: "skill:nopath", source: "skill" },
+ ]);
+ assert.deepStrictEqual(
+ result.slashCommands.map((command) => command.name),
+ ["fix-tests", "llama"],
+ );
+ assert.strictEqual(result.skills.length, 1);
+ assert.strictEqual(result.skills[0]?.name, "brave");
+ assert.strictEqual(result.skills[0]?.scope, "user");
+});
+
+it("sets PI_CODING_AGENT_DIR only when an agent dir is configured", () => {
+ assert.isUndefined(buildPiEnvironment({ agentDir: "" }, { PATH: "/bin" }).PI_CODING_AGENT_DIR);
+ assert.strictEqual(
+ buildPiEnvironment({ agentDir: "/custom" }, { PATH: "/bin" }).PI_CODING_AGENT_DIR,
+ "/custom",
+ );
+});
+
+it.effect("reports a disabled provider without spawning anything", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: false, binaryPath: "/definitely/missing/pi" }),
+ );
+ assert.strictEqual(snapshot.status, "disabled");
+ assert.isFalse(snapshot.installed);
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect("reports a missing binary as not installed", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: true, binaryPath: "/definitely/missing/pi" }),
+ );
+ assert.strictEqual(snapshot.status, "error");
+ assert.isFalse(snapshot.installed);
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect("discovers version, models, skills, and commands from the RPC probe", () =>
+ Effect.gen(function* () {
+ const binaryPath = yield* Effect.promise(() => makeMockPiWrapper());
+ const snapshot = yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: true, binaryPath }),
+ process.env,
+ process.cwd(),
+ );
+ assert.strictEqual(snapshot.status, "ready");
+ assert.strictEqual(snapshot.version, "0.84.4");
+ assert.strictEqual(snapshot.auth.status, "authenticated");
+ assert.deepStrictEqual(
+ snapshot.models.map((model) => model.slug),
+ [PI_DEFAULT_MODEL, "anthropic/claude-sonnet-4-5", "openai/gpt-5"],
+ );
+ assert.isTrue(snapshot.models[1]?.isDefault);
+ assert.deepStrictEqual(
+ snapshot.slashCommands.map((command) => command.name),
+ ["fix-tests", "llama"],
+ );
+ assert.strictEqual(snapshot.skills[0]?.name, "brave-search");
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect("reports unauthenticated when pi has no models with credentials", () =>
+ Effect.gen(function* () {
+ const binaryPath = yield* Effect.promise(() =>
+ makeMockPiWrapper({ T3_PI_MOCK_NO_MODELS: "1" }),
+ );
+ const snapshot = yield* checkPiProviderStatus(decodePiSettings({ enabled: true, binaryPath }));
+ assert.strictEqual(snapshot.status, "error");
+ assert.strictEqual(snapshot.auth.status, "unauthenticated");
+ assert.deepStrictEqual(
+ snapshot.models.map((model) => model.slug),
+ [PI_DEFAULT_MODEL],
+ );
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts
new file mode 100644
index 000000000000..50ecd3fa76af
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiProvider.ts
@@ -0,0 +1,446 @@
+/**
+ * PiProvider — health check, model catalog, and command discovery for the pi
+ * driver. Every probe runs the user's `pi` binary; nothing here opens a
+ * session that could bill a model call.
+ *
+ * @module provider/Layers/PiProvider
+ */
+import {
+ type CustomModelSetting,
+ type ModelCapabilities,
+ PI_DEFAULT_MODEL,
+ type PiSettings,
+ type ServerProvider,
+ type ServerProviderAuth,
+ type ServerProviderModel,
+ type ServerProviderSkill,
+ type ServerProviderSlashCommand,
+} from "@t3tools/contracts";
+import { causeErrorTag } from "@t3tools/shared/observability";
+import { tokenizeCliArgs } from "@t3tools/shared/cliArgs";
+import { createModelCapabilities } from "@t3tools/shared/model";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Option from "effect/Option";
+import * as Result from "effect/Result";
+import type * as Scope from "effect/Scope";
+import { HttpClient } from "effect/unstable/http";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+import {
+ type ProviderAdapterProcessError,
+ type ProviderAdapterRequestError,
+ ProviderDriverError,
+} from "../Errors.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ type ProviderMaintenanceCapabilities,
+} from "../providerMaintenance.ts";
+import {
+ buildSelectOptionDescriptor,
+ buildServerProvider,
+ isCommandMissingCause,
+ parseGenericCliVersion,
+ providerModelsFromSettings,
+ type ServerProviderDraft,
+ spawnAndCollect,
+} from "../providerSnapshot.ts";
+import { spawnPiRpcClient } from "../pi/PiRpcClient.ts";
+import {
+ PI_THINKING_LEVELS,
+ type PiCommandInfo,
+ type PiModel,
+ piModelSlug,
+ type PiSessionState,
+ type PiThinkingLevel,
+} from "../pi/PiRpcProtocol.ts";
+
+export const PI_PRESENTATION = {
+ displayName: "pi",
+ badgeLabel: "Early Access",
+ showInteractionModeToggle: false,
+} as const;
+
+export const PI_REASONING_OPTION_ID = "thinkingLevel";
+export const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
+
+const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] });
+const VERSION_PROBE_TIMEOUT_MS = 6_000;
+const RPC_PROBE_TIMEOUT_MS = 20_000;
+
+const PI_BUILT_IN_MODELS: ReadonlyArray = [
+ {
+ slug: PI_DEFAULT_MODEL,
+ name: "pi default",
+ isCustom: false,
+ capabilities: EMPTY_CAPABILITIES,
+ },
+];
+
+function piModelsFromSettings(
+ customModels: ReadonlyArray | undefined,
+ builtInModels: ReadonlyArray = PI_BUILT_IN_MODELS,
+): ReadonlyArray {
+ return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
+}
+
+/** Thinking levels a pi model exposes, honoring `thinkingLevelMap` holes. */
+export function piThinkingLevelsForModel(model: PiModel): ReadonlyArray {
+ if (!model.reasoning) return [];
+ const map = model.thinkingLevelMap ?? {};
+ return PI_THINKING_LEVELS.filter((level) => {
+ const mapped = map[level];
+ if (mapped === null) return false;
+ // Extended levels are opt-in and need an explicit mapping.
+ if ((level === "xhigh" || level === "max") && mapped === undefined) return false;
+ return true;
+ });
+}
+
+export function buildPiModelCapabilities(model: PiModel): ModelCapabilities {
+ const levels = piThinkingLevelsForModel(model);
+ if (levels.length === 0) return EMPTY_CAPABILITIES;
+ return createModelCapabilities({
+ optionDescriptors: [
+ buildSelectOptionDescriptor({
+ id: PI_REASONING_OPTION_ID,
+ label: "Thinking",
+ options: levels.map((level) => ({
+ value: level,
+ label: level,
+ ...(level === "medium" ? { isDefault: true } : {}),
+ })),
+ }),
+ ],
+ });
+}
+
+export function buildPiModelsFromRpc(
+ models: ReadonlyArray,
+ currentModel: PiModel | null | undefined,
+): ReadonlyArray {
+ const currentSlug = currentModel ? piModelSlug(currentModel) : undefined;
+ const seen = new Set();
+ const discovered: Array = [];
+ for (const model of models) {
+ const slug = piModelSlug(model);
+ if (!model.id.trim() || !model.provider.trim() || seen.has(slug)) continue;
+ seen.add(slug);
+ discovered.push({
+ slug,
+ name: model.name.trim() || model.id,
+ subProvider: model.provider,
+ isCustom: false,
+ ...(slug === currentSlug ? { isDefault: true } : {}),
+ capabilities: buildPiModelCapabilities(model),
+ });
+ }
+ return [...PI_BUILT_IN_MODELS, ...discovered];
+}
+
+export function buildPiCommandsFromRpc(commands: ReadonlyArray): {
+ readonly slashCommands: ReadonlyArray;
+ readonly skills: ReadonlyArray;
+} {
+ const slashCommands: Array = [];
+ const skills: Array = [];
+ const seen = new Set();
+ for (const command of commands) {
+ const name = command.name.trim();
+ if (!name || seen.has(name)) continue;
+ seen.add(name);
+ const description = command.description?.trim() || undefined;
+ if (command.source === "skill") {
+ const skillName = name.startsWith("skill:") ? name.slice("skill:".length) : name;
+ const path = command.sourceInfo?.path?.trim();
+ if (!skillName || !path) continue;
+ skills.push({
+ name: skillName,
+ path,
+ enabled: true,
+ ...(description ? { description } : {}),
+ ...(command.sourceInfo?.scope ? { scope: command.sourceInfo.scope } : {}),
+ });
+ continue;
+ }
+ slashCommands.push({ name, ...(description ? { description } : {}) });
+ }
+ return { slashCommands, skills };
+}
+
+export function buildInitialPiProviderSnapshot(
+ piSettings: PiSettings,
+): Effect.Effect {
+ return Effect.gen(function* () {
+ const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
+ const models = piModelsFromSettings(piSettings.customModels);
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: piSettings.enabled,
+ checkedAt,
+ models,
+ probe: {
+ installed: piSettings.enabled,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: piSettings.enabled
+ ? "Checking pi availability..."
+ : "pi is disabled in T3 Code settings.",
+ },
+ });
+ });
+}
+
+/** Environment for every pi child: instance env plus the optional agent dir override. */
+export function buildPiEnvironment(
+ piSettings: Pick,
+ environment: NodeJS.ProcessEnv,
+): NodeJS.ProcessEnv {
+ const agentDir = piSettings.agentDir.trim();
+ return agentDir ? { ...environment, [PI_AGENT_DIR_ENV]: agentDir } : { ...environment };
+}
+
+export function piLaunchArgv(piSettings: Pick): ReadonlyArray {
+ return tokenizeCliArgs(piSettings.launchArgs);
+}
+
+const runPiCliCommand = (
+ piSettings: PiSettings,
+ args: ReadonlyArray,
+ environment: NodeJS.ProcessEnv,
+) =>
+ Effect.gen(function* () {
+ const command = piSettings.binaryPath || "pi";
+ const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
+ return yield* spawnAndCollect(
+ command,
+ ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ env: environment,
+ shell: spawnCommand.shell,
+ }),
+ );
+ });
+
+interface PiRpcProbeResult {
+ readonly state: PiSessionState;
+ readonly models: ReadonlyArray;
+ readonly commands: ReadonlyArray;
+}
+
+/**
+ * One short-lived ephemeral RPC session: reads state, the authenticated model
+ * list, and the commands pi discovers for `cwd`. No prompt is ever sent.
+ */
+export const probePiRpc = Effect.fn("probePiRpc")(function* (
+ piSettings: PiSettings,
+ environment: NodeJS.ProcessEnv,
+ cwd: string,
+): Effect.fn.Return<
+ PiRpcProbeResult,
+ ProviderAdapterProcessError | ProviderAdapterRequestError,
+ ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
+> {
+ const client = yield* spawnPiRpcClient({
+ binaryPath: piSettings.binaryPath || "pi",
+ args: ["--mode", "rpc", "--no-session", ...piLaunchArgv(piSettings)],
+ cwd,
+ env: buildPiEnvironment(piSettings, environment),
+ threadId: "provider-probe",
+ });
+ const state = (yield* client.request({ type: "get_state" })).data as PiSessionState;
+ const modelsResponse = yield* client.request({ type: "get_available_models" });
+ const models = ((modelsResponse.data as { models?: ReadonlyArray } | undefined)
+ ?.models ?? []) as ReadonlyArray;
+ const commandsResponse = yield* client.request({ type: "get_commands" });
+ const commands = ((
+ commandsResponse.data as { commands?: ReadonlyArray } | undefined
+ )?.commands ?? []) as ReadonlyArray;
+ return { state, models, commands };
+});
+
+export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* (
+ piSettings: PiSettings,
+ environment: NodeJS.ProcessEnv = process.env,
+ cwd: string = process.cwd(),
+): Effect.fn.Return {
+ const checkedAt = DateTime.formatIso(yield* DateTime.now);
+ const fallbackModels = piModelsFromSettings(piSettings.customModels);
+ const env = buildPiEnvironment(piSettings, environment);
+
+ if (!piSettings.enabled) {
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: false,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: false,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "pi is disabled in T3 Code settings.",
+ },
+ });
+ }
+
+ const versionResult = yield* runPiCliCommand(piSettings, ["--version"], env).pipe(
+ Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
+ Effect.result,
+ );
+
+ if (Result.isFailure(versionResult)) {
+ const error = versionResult.failure;
+ yield* Effect.logWarning("pi health check failed.", { errorTag: error._tag });
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: !isCommandMissingCause(error),
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: isCommandMissingCause(error)
+ ? "pi is not installed or not on PATH."
+ : "Failed to run the pi health check.",
+ },
+ });
+ }
+
+ if (Option.isNone(versionResult.success)) {
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: "pi timed out while running `pi --version`.",
+ },
+ });
+ }
+
+ const versionOutput = versionResult.success.value;
+ const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
+ if (versionOutput.code !== 0) {
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version,
+ status: "error",
+ auth: { status: "unknown" },
+ message: "pi is installed but failed to run.",
+ },
+ });
+ }
+
+ const probeExit = yield* probePiRpc(piSettings, environment, cwd).pipe(
+ Effect.scoped,
+ Effect.timeoutOption(RPC_PROBE_TIMEOUT_MS),
+ Effect.exit,
+ );
+ const probe = Exit.isSuccess(probeExit) ? Option.getOrUndefined(probeExit.value) : undefined;
+ if (!probe) {
+ yield* Effect.logWarning("pi RPC probe failed or timed out.", {
+ errorTag: Exit.isFailure(probeExit) ? causeErrorTag(probeExit.cause) : "Timeout",
+ });
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "pi is installed but did not answer in RPC mode. Model options may be incomplete.",
+ },
+ });
+ }
+
+ const discovered = buildPiModelsFromRpc(probe.models, probe.state.model);
+ const models = piModelsFromSettings(piSettings.customModels, discovered);
+ const { slashCommands, skills } = buildPiCommandsFromRpc(probe.commands);
+ const authenticated = probe.models.length > 0;
+ const auth: ServerProviderAuth = authenticated
+ ? { status: "authenticated", type: "cached_token", label: "pi credentials" }
+ : { status: "unauthenticated" };
+
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models,
+ slashCommands,
+ skills,
+ probe: {
+ installed: true,
+ version,
+ status: authenticated ? "ready" : "error",
+ auth,
+ ...(authenticated
+ ? {}
+ : { message: "pi has no model with credentials. Run `pi` and `/login` in a terminal." }),
+ },
+ });
+});
+
+/** Per-workspace commands and skills, folded into the machine snapshot by the driver. */
+export const probePiCommandsForCwd = Effect.fn("probePiCommandsForCwd")(function* (
+ piSettings: PiSettings,
+ environment: NodeJS.ProcessEnv,
+ cwd: string,
+): Effect.fn.Return<
+ {
+ readonly slashCommands: ReadonlyArray;
+ readonly skills: ReadonlyArray;
+ },
+ ProviderDriverError,
+ ChildProcessSpawner.ChildProcessSpawner
+> {
+ const probe = yield* probePiRpc(piSettings, environment, cwd).pipe(
+ Effect.scoped,
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: "pi",
+ instanceId: "pi",
+ detail: `Failed to discover pi commands for '${cwd}': ${cause.message}`,
+ cause,
+ }),
+ ),
+ );
+ return buildPiCommandsFromRpc(probe.commands);
+});
+
+export const enrichPiSnapshot = (input: {
+ readonly snapshot: ServerProvider;
+ readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
+ readonly enableProviderUpdateChecks?: boolean;
+ readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect;
+ readonly httpClient: HttpClient.HttpClient;
+}): Effect.Effect =>
+ enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, {
+ enableProviderUpdateChecks: input.enableProviderUpdateChecks,
+ }).pipe(
+ Effect.provideService(HttpClient.HttpClient, input.httpClient),
+ Effect.flatMap((enriched) => input.publishSnapshot(enriched)),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("pi version advisory enrichment failed", {
+ errorTag: causeErrorTag(cause),
+ }),
+ ),
+ Effect.asVoid,
+ );
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
index 988c89e1e679..eb032973baa5 100644
--- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
@@ -2617,6 +2617,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
"cursor",
"grok",
"opencode",
+ "pi",
]);
assert.strictEqual(cursorProvider?.enabled, false);
assert.strictEqual(cursorProvider?.status, "disabled");
diff --git a/apps/server/src/provider/Services/PiAdapter.ts b/apps/server/src/provider/Services/PiAdapter.ts
new file mode 100644
index 000000000000..19c01be436f4
--- /dev/null
+++ b/apps/server/src/provider/Services/PiAdapter.ts
@@ -0,0 +1,13 @@
+/**
+ * PiAdapter — shape type for the pi provider adapter.
+ *
+ * The driver model ({@link ../Drivers/PiDriver}) bundles one adapter per
+ * instance as a captured closure, so this module only retains the shape
+ * interface as a naming anchor for the driver bundle.
+ *
+ * @module PiAdapter
+ */
+import type { ProviderAdapterError } from "../Errors.ts";
+import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
+
+export interface PiAdapterShape extends ProviderAdapterShape {}
diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts
index 60e3402eed42..1da2040b8fe3 100644
--- a/apps/server/src/provider/builtInDrivers.ts
+++ b/apps/server/src/provider/builtInDrivers.ts
@@ -26,6 +26,7 @@ import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts";
+import { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts";
import type { AnyProviderDriver } from "./ProviderDriver.ts";
/**
@@ -39,7 +40,8 @@ export type BuiltInDriversEnv =
| CursorDriverEnv
| GrokDriverEnv
| OpenCodeDriverEnv
- | AntigravityDriverEnv;
+ | AntigravityDriverEnv
+ | PiDriverEnv;
/**
* Ordered list of built-in drivers. Order matters only for tie-breaking in
@@ -53,4 +55,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray;
+ readonly cwd: string;
+ readonly env: NodeJS.ProcessEnv;
+ readonly threadId: string;
+}
+
+export interface PiRpcClient {
+ readonly request: (
+ command: PiRpcCommand,
+ options?: { readonly timeoutMs?: number },
+ ) => Effect.Effect;
+ /** Fire-and-forget write, used for `extension_ui_response`. */
+ readonly notify: (command: PiRpcCommand) => Effect.Effect;
+ readonly events: Queue.Dequeue>;
+ readonly stderrLines: Queue.Dequeue>;
+ /** Resolves once the child exits, with its exit code. */
+ readonly exited: Deferred.Deferred;
+ readonly pid: number | undefined;
+}
+
+/** Split a UTF-8 chunk stream into LF-delimited lines, dropping a trailing CR. */
+export function splitJsonlLines(buffer: string, chunk: string): { lines: string[]; rest: string } {
+ const combined = buffer + chunk;
+ const parts = combined.split("\n");
+ const rest = parts.pop() ?? "";
+ const lines = parts.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
+ return { lines, rest };
+}
+
+export const spawnPiRpcClient = Effect.fn("spawnPiRpcClient")(function* (
+ input: PiRpcSpawnInput,
+): Effect.fn.Return<
+ PiRpcClient,
+ ProviderAdapterProcessError,
+ ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
+> {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const scope = yield* Effect.scope;
+ const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, input.args, {
+ env: input.env,
+ });
+ const child = yield* spawner
+ .spawn(
+ ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ cwd: input.cwd,
+ env: input.env,
+ extendEnv: false,
+ shell: spawnCommand.shell,
+ }),
+ )
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterProcessError({
+ provider: PROVIDER_LABEL,
+ threadId: input.threadId,
+ detail: `Failed to spawn pi (${input.binaryPath}): ${cause.message}`,
+ cause,
+ }),
+ ),
+ );
+
+ const events = yield* Queue.unbounded>();
+ const stderrLines = yield* Queue.unbounded>();
+ const exited = yield* Deferred.make();
+ const pending = new Map>();
+ const stdinQueue = yield* Queue.unbounded>();
+ const nextIdRef = yield* Ref.make(0);
+
+ const failAllPending = (detail: string) =>
+ Effect.forEach(
+ Array.from(pending.entries()),
+ ([id, deferred]) =>
+ Deferred.fail(
+ deferred,
+ new ProviderAdapterRequestError({
+ provider: PROVIDER_LABEL,
+ method: `rpc#${id}`,
+ detail,
+ }),
+ ).pipe(Effect.asVoid),
+ { discard: true },
+ ).pipe(Effect.tap(() => Effect.sync(() => pending.clear())));
+
+ yield* Stream.fromQueue(stdinQueue).pipe(Stream.run(child.stdin), Effect.forkIn(scope));
+
+ const handleLine = (line: string): Effect.Effect =>
+ Effect.gen(function* () {
+ if (line.trim().length === 0) return;
+ const decoded = decodeUnknownJsonStringExit(line);
+ if (!Exit.isSuccess(decoded)) {
+ yield* Queue.offer(stderrLines, `[pi stdout, not JSON] ${line.slice(0, 500)}`);
+ return;
+ }
+ const parsed = decoded.value;
+ if (isPiRpcResponse(parsed)) {
+ const id = parsed.id;
+ const deferred = id !== undefined ? pending.get(id) : undefined;
+ if (deferred) {
+ pending.delete(id!);
+ yield* Deferred.succeed(deferred, parsed);
+ }
+ return;
+ }
+ if (isPiRpcEvent(parsed)) {
+ yield* Queue.offer(events, parsed);
+ }
+ });
+
+ yield* child.stdout.pipe(
+ Stream.decodeText(),
+ Stream.runFoldEffect(
+ (): string => "",
+ (buffer: string, chunk: string) =>
+ Effect.gen(function* () {
+ const { lines, rest } = splitJsonlLines(buffer, chunk);
+ yield* Effect.forEach(lines, handleLine, { discard: true });
+ return rest;
+ }),
+ ),
+ Effect.flatMap((rest) => (rest.length > 0 ? handleLine(rest) : Effect.void)),
+ Effect.ignore,
+ Effect.forkIn(scope),
+ );
+
+ yield* child.stderr.pipe(
+ Stream.decodeText(),
+ Stream.runFoldEffect(
+ (): string => "",
+ (buffer: string, chunk: string) =>
+ Effect.gen(function* () {
+ const { lines, rest } = splitJsonlLines(buffer, chunk);
+ yield* Effect.forEach(
+ lines.filter((line) => line.trim().length > 0),
+ (line) => Queue.offer(stderrLines, line),
+ { discard: true },
+ );
+ return rest;
+ }),
+ ),
+ Effect.ignore,
+ Effect.forkIn(scope),
+ );
+
+ yield* child.exitCode.pipe(
+ Effect.map(Number),
+ Effect.orElseSucceed(() => -1),
+ Effect.flatMap((code) =>
+ Deferred.succeed(exited, code).pipe(
+ Effect.andThen(failAllPending(`pi exited with code ${code} before responding.`)),
+ Effect.andThen(Queue.end(events)),
+ ),
+ ),
+ Effect.forkIn(scope),
+ );
+
+ yield* Scope.addFinalizer(
+ scope,
+ Effect.gen(function* () {
+ yield* Queue.end(stdinQueue).pipe(Effect.ignore);
+ yield* failAllPending("pi session closed.");
+ yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore);
+ }),
+ );
+
+ const writeLine = (
+ payload: Record,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ if (yield* Deferred.isDone(exited)) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER_LABEL,
+ method: String(payload.type ?? "rpc"),
+ detail: "pi process has exited.",
+ });
+ }
+ yield* Queue.offer(stdinQueue, encoder.encode(`${encodeUnknownJsonString(payload)}\n`));
+ });
+
+ const request: PiRpcClient["request"] = (command, options) =>
+ Effect.gen(function* () {
+ const id = `t3-${yield* Ref.updateAndGet(nextIdRef, (n) => n + 1)}`;
+ const deferred = yield* Deferred.make();
+ pending.set(id, deferred);
+ yield* writeLine({ id, ...command }).pipe(
+ Effect.tapError(() => Effect.sync(() => pending.delete(id))),
+ );
+ const response = yield* Deferred.await(deferred).pipe(
+ Effect.timeoutOrElse({
+ duration: options?.timeoutMs ?? 60_000,
+ orElse: () =>
+ Effect.sync(() => pending.delete(id)).pipe(
+ Effect.andThen(
+ new ProviderAdapterRequestError({
+ provider: PROVIDER_LABEL,
+ method: command.type,
+ detail: `pi did not answer '${command.type}' in time.`,
+ }),
+ ),
+ ),
+ }),
+ );
+ if (!response.success) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER_LABEL,
+ method: command.type,
+ detail: response.error ?? `pi rejected '${command.type}'.`,
+ });
+ }
+ return response;
+ });
+
+ const notify: PiRpcClient["notify"] = (command) => writeLine({ ...command });
+
+ return {
+ request,
+ notify,
+ events,
+ stderrLines,
+ exited,
+ pid: child.pid,
+ } satisfies PiRpcClient;
+});
diff --git a/apps/server/src/provider/pi/PiRpcProtocol.ts b/apps/server/src/provider/pi/PiRpcProtocol.ts
new file mode 100644
index 000000000000..532059ce1c8c
--- /dev/null
+++ b/apps/server/src/provider/pi/PiRpcProtocol.ts
@@ -0,0 +1,261 @@
+/**
+ * PiRpcProtocol — the subset of pi's `--mode rpc` JSONL protocol that T3 Code
+ * speaks. Commands go to stdin, responses and events come back on stdout,
+ * one JSON object per LF-terminated line.
+ *
+ * Only fields T3 reads are modeled. Unknown fields pass through untouched so
+ * a newer pi does not break decoding.
+ *
+ * @see pi docs/rpc.md
+ * @module provider/pi/PiRpcProtocol
+ */
+
+export const PI_THINKING_LEVELS = [
+ "off",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+] as const;
+export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number];
+
+export function isPiThinkingLevel(value: unknown): value is PiThinkingLevel {
+ return typeof value === "string" && (PI_THINKING_LEVELS as ReadonlyArray).includes(value);
+}
+
+export interface PiImageContent {
+ readonly type: "image";
+ readonly data: string;
+ readonly mimeType: string;
+}
+
+export type PiRpcCommand =
+ | {
+ readonly type: "prompt";
+ readonly message: string;
+ readonly images?: ReadonlyArray;
+ readonly streamingBehavior?: "steer" | "followUp";
+ }
+ | { readonly type: "abort" }
+ | { readonly type: "get_state" }
+ | { readonly type: "get_available_models" }
+ | { readonly type: "get_available_thinking_levels" }
+ | { readonly type: "get_commands" }
+ | { readonly type: "get_session_stats" }
+ | { readonly type: "set_model"; readonly provider: string; readonly modelId: string }
+ | { readonly type: "set_thinking_level"; readonly level: PiThinkingLevel }
+ | { readonly type: "set_session_name"; readonly name: string }
+ | {
+ readonly type: "extension_ui_response";
+ readonly id: string;
+ readonly value?: string;
+ readonly confirmed?: boolean;
+ readonly cancelled?: boolean;
+ };
+
+export interface PiModel {
+ readonly id: string;
+ readonly name: string;
+ readonly provider: string;
+ readonly reasoning?: boolean;
+ readonly thinkingLevelMap?: Partial>;
+ readonly contextWindow?: number;
+ readonly input?: ReadonlyArray;
+}
+
+export interface PiSessionState {
+ readonly model: PiModel | null;
+ readonly thinkingLevel: PiThinkingLevel;
+ readonly isStreaming: boolean;
+ readonly sessionFile?: string;
+ readonly sessionId: string;
+ readonly sessionName?: string;
+ readonly autoCompactionEnabled?: boolean;
+ readonly messageCount?: number;
+}
+
+export interface PiCommandInfo {
+ readonly name: string;
+ readonly description?: string;
+ readonly source: "extension" | "prompt" | "skill";
+ readonly sourceInfo?: {
+ readonly path?: string;
+ readonly scope?: string;
+ readonly source?: string;
+ };
+}
+
+export interface PiUsage {
+ readonly input?: number;
+ readonly output?: number;
+ readonly cacheRead?: number;
+ readonly cacheWrite?: number;
+ readonly totalTokens?: number;
+}
+
+export type PiAssistantMessageEvent =
+ | { readonly type: "text_start"; readonly contentIndex: number }
+ | { readonly type: "text_delta"; readonly contentIndex: number; readonly delta: string }
+ | { readonly type: "text_end"; readonly contentIndex: number; readonly content?: string }
+ | { readonly type: "thinking_start"; readonly contentIndex: number }
+ | { readonly type: "thinking_delta"; readonly contentIndex: number; readonly delta: string }
+ | { readonly type: "thinking_end"; readonly contentIndex: number; readonly content?: string }
+ | {
+ readonly type: "toolcall_start";
+ readonly contentIndex: number;
+ readonly id?: string;
+ readonly toolName?: string;
+ }
+ | { readonly type: "toolcall_delta"; readonly contentIndex: number; readonly delta: string }
+ | { readonly type: "toolcall_end"; readonly contentIndex: number; readonly toolCall?: unknown };
+
+export interface PiAssistantMessage {
+ readonly role: "assistant";
+ readonly content?: ReadonlyArray>;
+ readonly usage?: PiUsage;
+ readonly stopReason?: "stop" | "length" | "toolUse" | "error" | "aborted";
+ readonly errorMessage?: string;
+ readonly model?: string;
+ readonly provider?: string;
+}
+
+export interface PiToolResultContent {
+ readonly content?: ReadonlyArray<{ readonly type: string; readonly text?: string }>;
+ readonly details?: unknown;
+}
+
+export type PiRpcEvent =
+ | { readonly type: "agent_start" }
+ | {
+ readonly type: "agent_end";
+ readonly messages?: ReadonlyArray;
+ readonly willRetry?: boolean;
+ }
+ | { readonly type: "agent_settled" }
+ | { readonly type: "turn_start" }
+ | {
+ readonly type: "turn_end";
+ readonly message?: unknown;
+ readonly toolResults?: ReadonlyArray;
+ }
+ | { readonly type: "message_start"; readonly message?: Record }
+ | {
+ readonly type: "message_update";
+ readonly usage?: PiUsage;
+ readonly assistantMessageEvent: PiAssistantMessageEvent;
+ }
+ | { readonly type: "message_end"; readonly message?: Record }
+ | {
+ readonly type: "tool_execution_start";
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly args?: unknown;
+ }
+ | {
+ readonly type: "tool_execution_update";
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly args?: unknown;
+ readonly partialResult?: PiToolResultContent;
+ }
+ | {
+ readonly type: "tool_execution_end";
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly result?: PiToolResultContent;
+ readonly isError?: boolean;
+ }
+ | {
+ readonly type: "queue_update";
+ readonly steering?: ReadonlyArray;
+ readonly followUp?: ReadonlyArray;
+ }
+ | { readonly type: "compaction_start"; readonly reason?: string }
+ | {
+ readonly type: "compaction_end";
+ readonly reason?: string;
+ readonly result?: {
+ readonly summary?: string;
+ readonly tokensBefore?: number;
+ readonly estimatedTokensAfter?: number;
+ } | null;
+ readonly aborted?: boolean;
+ readonly willRetry?: boolean;
+ readonly errorMessage?: string;
+ }
+ | {
+ readonly type: "auto_retry_start";
+ readonly attempt?: number;
+ readonly maxAttempts?: number;
+ readonly delayMs?: number;
+ readonly errorMessage?: string;
+ }
+ | {
+ readonly type: "auto_retry_end";
+ readonly success?: boolean;
+ readonly attempt?: number;
+ readonly finalError?: string;
+ }
+ | {
+ readonly type: "extension_error";
+ readonly extensionPath?: string;
+ readonly event?: string;
+ readonly error?: string;
+ }
+ | {
+ readonly type: "extension_ui_request";
+ readonly id: string;
+ readonly method: string;
+ readonly title?: string;
+ readonly message?: string;
+ readonly options?: ReadonlyArray;
+ readonly timeout?: number;
+ readonly notifyType?: string;
+ readonly statusText?: string;
+ }
+ | { readonly type: string };
+
+export interface PiRpcResponse {
+ readonly type: "response";
+ readonly id?: string;
+ readonly command: string;
+ readonly success: boolean;
+ readonly data?: unknown;
+ readonly error?: string;
+}
+
+export function isPiRpcResponse(value: unknown): value is PiRpcResponse {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ (value as { type?: unknown }).type === "response" &&
+ typeof (value as { command?: unknown }).command === "string" &&
+ typeof (value as { success?: unknown }).success === "boolean"
+ );
+}
+
+export function isPiRpcEvent(value: unknown): value is PiRpcEvent {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as { type?: unknown }).type === "string" &&
+ (value as { type: string }).type !== "response"
+ );
+}
+
+/** `provider/id` slug used throughout T3 for a pi model. */
+export function piModelSlug(model: Pick): string {
+ return `${model.provider}/${model.id}`;
+}
+
+/** Split a T3 slug back into pi's `{ provider, modelId }` pair. */
+export function parsePiModelSlug(slug: string): { provider: string; modelId: string } | undefined {
+ const trimmed = slug.trim();
+ const separator = trimmed.indexOf("/");
+ if (separator <= 0 || separator === trimmed.length - 1) {
+ return undefined;
+ }
+ return { provider: trimmed.slice(0, separator), modelId: trimmed.slice(separator + 1) };
+}
diff --git a/apps/server/src/provider/pi/PiRuntimeEvents.test.ts b/apps/server/src/provider/pi/PiRuntimeEvents.test.ts
new file mode 100644
index 000000000000..9eb9d0c37f69
--- /dev/null
+++ b/apps/server/src/provider/pi/PiRuntimeEvents.test.ts
@@ -0,0 +1,379 @@
+import { assert, it } from "@effect/vitest";
+import {
+ EventId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+ TurnId,
+} from "@t3tools/contracts";
+
+import { splitJsonlLines } from "./PiRpcClient.ts";
+import { parsePiModelSlug, piModelSlug } from "./PiRpcProtocol.ts";
+import {
+ makePiMappingState,
+ mapPiEvent,
+ piToolItemType,
+ piUsageToSnapshot,
+} from "./PiRuntimeEvents.ts";
+import { parseT3PiApprovalEnvelope, parseT3PiQuestionEnvelope } from "./piExtension.ts";
+
+const ctx = {
+ provider: ProviderDriverKind.make("pi"),
+ providerInstanceId: ProviderInstanceId.make("pi"),
+ threadId: ThreadId.make("thread-1"),
+ turnId: TurnId.make("turn-1"),
+};
+
+function makeMapper() {
+ let n = 0;
+ const state = makePiMappingState(() => `item-${++n}`);
+ let e = 0;
+ const stamp = () => ({
+ eventId: EventId.make(`event-${++e}`),
+ createdAt: "2026-01-01T00:00:00Z",
+ });
+ return (
+ event: Parameters[0]["event"],
+ options?: { readonly autoCompactionEnabled?: boolean },
+ ) =>
+ mapPiEvent({
+ event,
+ stamp,
+ ctx,
+ state,
+ contextWindow: 200_000,
+ ...(options?.autoCompactionEnabled !== undefined
+ ? { autoCompactionEnabled: options.autoCompactionEnabled }
+ : {}),
+ });
+}
+
+it("splits JSONL on LF only and strips a trailing CR", () => {
+ const first = splitJsonlLines("", '{"a":1}\r\n{"b":"x\u2028y"}\n{"c"');
+ assert.deepStrictEqual(first.lines, ['{"a":1}', '{"b":"x\u2028y"}']);
+ assert.strictEqual(first.rest, '{"c"');
+ const second = splitJsonlLines(first.rest, ":3}\n");
+ assert.deepStrictEqual(second.lines, ['{"c":3}']);
+ assert.strictEqual(second.rest, "");
+});
+
+it("round-trips provider/model slugs", () => {
+ assert.strictEqual(
+ piModelSlug({ provider: "anthropic", id: "claude-opus-4-5" }),
+ "anthropic/claude-opus-4-5",
+ );
+ assert.deepStrictEqual(parsePiModelSlug("openai/gpt-5"), {
+ provider: "openai",
+ modelId: "gpt-5",
+ });
+ assert.deepStrictEqual(parsePiModelSlug("my-proxy/vendor/model"), {
+ provider: "my-proxy",
+ modelId: "vendor/model",
+ });
+ assert.isUndefined(parsePiModelSlug("pi-default"));
+ assert.isUndefined(parsePiModelSlug("/x"));
+});
+
+it("classifies pi tool names into canonical item types", () => {
+ assert.strictEqual(piToolItemType("bash"), "command_execution");
+ assert.strictEqual(piToolItemType("PowerShell"), "command_execution");
+ assert.strictEqual(piToolItemType("edit"), "file_change");
+ assert.strictEqual(piToolItemType("write"), "file_change");
+ assert.strictEqual(piToolItemType("read"), "dynamic_tool_call");
+ assert.strictEqual(piToolItemType("t3_preview_snapshot"), "mcp_tool_call");
+ assert.strictEqual(piToolItemType("t3_"), "dynamic_tool_call");
+});
+
+it("maps bridged t3-code tools to mcp_tool_call items carrying server and tool", () => {
+ const map = makeMapper();
+ const started = map({
+ type: "tool_execution_start",
+ toolCallId: "call-mcp",
+ toolName: "t3_preview_snapshot",
+ args: { tabId: "tab-1" },
+ });
+ assert.strictEqual(started[0]?.type, "item.started");
+ if (started[0]?.type === "item.started") {
+ assert.strictEqual(started[0].payload.itemType, "mcp_tool_call");
+ assert.strictEqual(started[0].payload.title, "MCP tool call");
+ const data = started[0].payload.data as {
+ server?: string;
+ tool?: string;
+ toolName?: string;
+ input?: unknown;
+ };
+ assert.deepStrictEqual(
+ { server: data.server, tool: data.tool, toolName: data.toolName, input: data.input },
+ {
+ server: "t3-code",
+ tool: "preview_snapshot",
+ toolName: "t3_preview_snapshot",
+ input: { tabId: "tab-1" },
+ },
+ );
+ }
+});
+
+it("maps a streamed text block to item.started, content.delta, item.completed", () => {
+ const map = makeMapper();
+ const started = map({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_start", contentIndex: 0 },
+ });
+ assert.strictEqual(started.length, 1);
+ assert.strictEqual(started[0]?.type, "item.started");
+ assert.strictEqual(started[0]?.itemId, "item-1");
+
+ const delta = map({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "Hello" },
+ });
+ assert.strictEqual(delta[0]?.type, "content.delta");
+ assert.strictEqual(delta[0]?.itemId, "item-1");
+ if (delta[0]?.type === "content.delta") {
+ assert.strictEqual(delta[0].payload.streamKind, "assistant_text");
+ assert.strictEqual(delta[0].payload.delta, "Hello");
+ }
+
+ const thinking = map({
+ type: "message_update",
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 1, delta: "hmm" },
+ });
+ assert.strictEqual(thinking[0]?.type, "content.delta");
+ if (thinking[0]?.type === "content.delta") {
+ assert.strictEqual(thinking[0].payload.streamKind, "reasoning_text");
+ }
+
+ const ended = map({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_end", contentIndex: 0 },
+ });
+ assert.strictEqual(ended[0]?.type, "item.completed");
+ assert.strictEqual(ended[0]?.itemId, "item-1");
+});
+
+it("maps tool execution to command_execution items carrying command and output", () => {
+ const map = makeMapper();
+ const started = map({
+ type: "tool_execution_start",
+ toolCallId: "call-1",
+ toolName: "bash",
+ args: { command: "ls -la" },
+ });
+ assert.strictEqual(started[0]?.type, "item.started");
+ if (started[0]?.type === "item.started") {
+ assert.strictEqual(started[0].payload.itemType, "command_execution");
+ assert.strictEqual(started[0].payload.detail, "ls -la");
+ assert.deepStrictEqual((started[0].payload.data as { command?: string }).command, "ls -la");
+ }
+ const ended = map({
+ type: "tool_execution_end",
+ toolCallId: "call-1",
+ toolName: "bash",
+ result: { content: [{ type: "text", text: "total 0\n" }], details: {} },
+ isError: false,
+ });
+ assert.strictEqual(ended[0]?.type, "item.completed");
+ if (ended[0]?.type === "item.completed") {
+ assert.strictEqual(ended[0].payload.status, "completed");
+ assert.strictEqual(ended[0].payload.itemType, "command_execution");
+ const data = ended[0].payload.data as { rawOutput?: { content?: string } };
+ assert.strictEqual(data.rawOutput?.content, "total 0\n");
+ }
+ const failed = map({
+ type: "tool_execution_end",
+ toolCallId: "call-2",
+ toolName: "edit",
+ result: { content: [{ type: "text", text: "nope" }] },
+ isError: true,
+ });
+ assert.strictEqual(failed[0]?.type, "item.completed");
+ if (failed[0]?.type === "item.completed") {
+ assert.strictEqual(failed[0].payload.status, "failed");
+ assert.strictEqual(failed[0].payload.itemType, "file_change");
+ }
+});
+
+it("emits token usage and errors from message_end", () => {
+ const map = makeMapper();
+ const events = map({
+ type: "message_end",
+ message: {
+ role: "assistant",
+ usage: { input: 100, output: 20, cacheRead: 50, cacheWrite: 0, totalTokens: 170 },
+ stopReason: "error",
+ errorMessage: "boom",
+ },
+ });
+ const usage = events.find((event) => event.type === "thread.token-usage.updated");
+ assert.isDefined(usage);
+ if (usage?.type === "thread.token-usage.updated") {
+ assert.strictEqual(usage.payload.usage.usedTokens, 170);
+ assert.strictEqual(usage.payload.usage.cachedInputTokens, 50);
+ assert.strictEqual(usage.payload.usage.maxTokens, 200_000);
+ }
+ const error = events.find((event) => event.type === "runtime.error");
+ assert.isDefined(error);
+ if (error?.type === "runtime.error") {
+ assert.strictEqual(error.payload.message, "boom");
+ }
+ assert.isUndefined(piUsageToSnapshot({ input: 0, output: 0 }, { contextWindow: undefined }));
+});
+
+it("marks usage as auto-compacting when pi reports it enabled", () => {
+ const map = makeMapper();
+ const events = map(
+ {
+ type: "message_end",
+ message: { role: "assistant", usage: { input: 10, output: 5, totalTokens: 15 } },
+ },
+ { autoCompactionEnabled: true },
+ );
+ const usage = events.find((event) => event.type === "thread.token-usage.updated");
+ assert.isDefined(usage);
+ if (usage?.type === "thread.token-usage.updated") {
+ assert.strictEqual(usage.payload.usage.compactsAutomatically, true);
+ }
+ const plain = piUsageToSnapshot({ input: 10, output: 5 }, { contextWindow: 200_000 });
+ assert.isUndefined(plain?.compactsAutomatically);
+});
+
+it("closes the compaction item that compaction_start opened", () => {
+ const map = makeMapper();
+ const started = map({ type: "compaction_start", reason: "threshold" });
+ const ended = map({ type: "compaction_end", reason: "threshold", result: null, aborted: true });
+ assert.strictEqual(started[0]?.type, "item.started");
+ assert.strictEqual(ended[0]?.type, "item.completed");
+ assert.strictEqual(started[0]?.itemId, ended[0]?.itemId);
+ // The next compaction gets its own id rather than reusing the closed one.
+ const restarted = map({ type: "compaction_start", reason: "threshold" });
+ assert.notStrictEqual(restarted[0]?.itemId, started[0]?.itemId);
+});
+
+it("completes a compaction that ends without a result field", () => {
+ const map = makeMapper();
+ map({ type: "compaction_start", reason: "threshold" });
+ const events = map({ type: "compaction_end", reason: "threshold" });
+ assert.deepStrictEqual(
+ events.map((event) => event.type),
+ ["item.completed"],
+ );
+ assert.strictEqual(events[0]?.type, "item.completed");
+ if (events[0]?.type === "item.completed") {
+ assert.strictEqual(events[0].payload.status, "failed");
+ }
+});
+
+it("warns on auto_retry_start even when pi omits the attempt count", () => {
+ const map = makeMapper();
+ const events = map({ type: "auto_retry_start", errorMessage: "429 from the provider" });
+ assert.strictEqual(events[0]?.type, "runtime.warning");
+ if (events[0]?.type === "runtime.warning") {
+ assert.strictEqual(
+ events[0].payload.message,
+ "pi is retrying after a transient error (attempt 1 of 1).",
+ );
+ assert.strictEqual(events[0].payload.detail, "429 from the provider");
+ }
+});
+
+it("emits compacted state and post-compaction usage from compaction_end", () => {
+ const map = makeMapper();
+ const events = map({
+ type: "compaction_end",
+ reason: "threshold",
+ result: { summary: "Summary", tokensBefore: 150_000, estimatedTokensAfter: 32_000 },
+ aborted: false,
+ });
+ assert.deepStrictEqual(
+ events.map((event) => event.type),
+ ["item.completed", "thread.state.changed", "thread.token-usage.updated"],
+ );
+ const [item, compacted, usage] = events;
+ assert.strictEqual(item?.type, "item.completed");
+ if (item?.type === "item.completed") {
+ assert.strictEqual(item.payload.itemType, "context_compaction");
+ assert.strictEqual(item.payload.status, "completed");
+ }
+ assert.strictEqual(compacted?.type, "thread.state.changed");
+ if (compacted?.type === "thread.state.changed") {
+ assert.strictEqual(compacted.payload.state, "compacted");
+ assert.strictEqual(compacted.payload.beforeTokens, 150_000);
+ assert.strictEqual(compacted.payload.afterTokens, 32_000);
+ }
+ assert.strictEqual(usage?.type, "thread.token-usage.updated");
+ if (usage?.type === "thread.token-usage.updated") {
+ assert.strictEqual(usage.payload.usage.usedTokens, 32_000);
+ assert.strictEqual(usage.payload.usage.lastUsedTokens, 150_000);
+ assert.strictEqual(usage.payload.usage.maxTokens, 200_000);
+ }
+});
+
+it("emits only a failed item when compaction is aborted or fails", () => {
+ const map = makeMapper();
+ const aborted = map({ type: "compaction_end", result: null, aborted: true });
+ assert.deepStrictEqual(
+ aborted.map((event) => event.type),
+ ["item.completed"],
+ );
+ assert.strictEqual(aborted[0]?.type, "item.completed");
+ if (aborted[0]?.type === "item.completed") {
+ assert.strictEqual(aborted[0].payload.status, "failed");
+ }
+ const errored = map({
+ type: "compaction_end",
+ result: null,
+ aborted: false,
+ errorMessage: "quota exceeded",
+ });
+ assert.deepStrictEqual(
+ errored.map((event) => event.type),
+ ["item.completed"],
+ );
+ assert.strictEqual(errored[0]?.type, "item.completed");
+ if (errored[0]?.type === "item.completed") {
+ assert.strictEqual(errored[0].payload.detail, "quota exceeded");
+ }
+});
+
+it("parses only T3 approval envelopes from select titles", () => {
+ const envelope = parseT3PiApprovalEnvelope(
+ JSON.stringify({
+ t3: "t3-approval",
+ toolCallId: "c1",
+ toolName: "bash",
+ input: { command: "rm" },
+ }),
+ );
+ assert.strictEqual(envelope?.toolName, "bash");
+ assert.isUndefined(parseT3PiApprovalEnvelope("Allow dangerous command?"));
+ assert.isUndefined(parseT3PiApprovalEnvelope(JSON.stringify({ t3: "other" })));
+ assert.isUndefined(parseT3PiApprovalEnvelope("{not json"));
+});
+
+it("parses question envelopes and drops malformed options", () => {
+ const question = parseT3PiQuestionEnvelope(
+ JSON.stringify({
+ t3: "t3-question",
+ question: "Which?",
+ options: [{ label: "A", description: "first" }, { label: "" }, "junk", { label: "B" }],
+ }),
+ );
+ assert.strictEqual(question?.t3, "t3-question");
+ if (question?.t3 === "t3-question") {
+ assert.deepStrictEqual(question.options, [
+ { label: "A", description: "first" },
+ { label: "B" },
+ ]);
+ }
+ const custom = parseT3PiQuestionEnvelope(
+ JSON.stringify({ t3: "t3-question-custom", question: "Which?" }),
+ );
+ assert.strictEqual(custom?.t3, "t3-question-custom");
+ assert.isUndefined(
+ parseT3PiQuestionEnvelope(JSON.stringify({ t3: "t3-question", question: "x", options: [] })),
+ );
+ assert.isUndefined(
+ parseT3PiQuestionEnvelope(JSON.stringify({ t3: "t3-approval", question: "x" })),
+ );
+});
diff --git a/apps/server/src/provider/pi/PiRuntimeEvents.ts b/apps/server/src/provider/pi/PiRuntimeEvents.ts
new file mode 100644
index 000000000000..679285543d45
--- /dev/null
+++ b/apps/server/src/provider/pi/PiRuntimeEvents.ts
@@ -0,0 +1,546 @@
+/**
+ * PiRuntimeEvents — pure mappers from pi RPC events to T3's canonical
+ * `ProviderRuntimeEvent`s. No process or Effect dependencies so the adapter
+ * stays thin and the mapping stays unit-testable.
+ *
+ * @module provider/pi/PiRuntimeEvents
+ */
+import {
+ type CanonicalItemType,
+ type EventId,
+ type ProviderDriverKind,
+ type ProviderInstanceId,
+ type ProviderRuntimeEvent,
+ RuntimeItemId,
+ type ThreadId,
+ type ThreadTokenUsageSnapshot,
+ type ToolLifecycleItemType,
+ type TurnId,
+} from "@t3tools/contracts";
+
+import type { PiRpcEvent, PiToolResultContent, PiUsage } from "./PiRpcProtocol.ts";
+
+export interface PiEventStamp {
+ readonly eventId: EventId;
+ readonly createdAt: string;
+}
+
+export interface PiEventContext {
+ readonly provider: ProviderDriverKind;
+ readonly providerInstanceId: ProviderInstanceId;
+ readonly threadId: ThreadId;
+ readonly turnId: TurnId | undefined;
+}
+
+const COMMAND_TOOL_NAMES = new Set(["bash", "powershell"]);
+const FILE_CHANGE_TOOL_NAMES = new Set(["edit", "write"]);
+/** The T3 extension registers every `t3-code` MCP tool under this prefix. */
+const T3_BRIDGE_TOOL_PREFIX = "t3_";
+const T3_MCP_SERVER_NAME = "t3-code";
+
+/** The MCP tool name behind a bridged pi tool, or undefined for pi's own tools. */
+export function piBridgeToolName(toolName: string): string | undefined {
+ const trimmed = toolName.trim();
+ return trimmed.length > T3_BRIDGE_TOOL_PREFIX.length && trimmed.startsWith(T3_BRIDGE_TOOL_PREFIX)
+ ? trimmed.slice(T3_BRIDGE_TOOL_PREFIX.length)
+ : undefined;
+}
+
+/** pi's built-in tools carry stable names; anything else is a custom or extension tool. */
+export function piToolItemType(toolName: string): ToolLifecycleItemType {
+ const normalized = toolName.trim().toLowerCase();
+ if (COMMAND_TOOL_NAMES.has(normalized)) return "command_execution";
+ if (FILE_CHANGE_TOOL_NAMES.has(normalized)) return "file_change";
+ if (piBridgeToolName(toolName) !== undefined) return "mcp_tool_call";
+ return "dynamic_tool_call";
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function nonEmptyString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
+}
+
+export function piToolTitle(toolName: string, args: unknown): string {
+ const itemType = piToolItemType(toolName);
+ const input = isRecord(args) ? args : {};
+ switch (itemType) {
+ case "command_execution":
+ return "Command run";
+ case "file_change":
+ return nonEmptyString(input.path) ? `${toolName} ${String(input.path)}` : "File change";
+ case "mcp_tool_call":
+ return "MCP tool call";
+ default:
+ return toolName;
+ }
+}
+
+export function piToolDetail(toolName: string, args: unknown): string | undefined {
+ const input = isRecord(args) ? args : {};
+ switch (piToolItemType(toolName)) {
+ case "command_execution":
+ return nonEmptyString(input.command);
+ case "file_change":
+ return nonEmptyString(input.path);
+ default: {
+ const path = nonEmptyString(input.path);
+ if (path) return path;
+ const pattern = nonEmptyString(input.pattern);
+ if (pattern) return pattern;
+ return undefined;
+ }
+ }
+}
+
+export function piToolResultText(result: PiToolResultContent | undefined): string | undefined {
+ if (!result?.content) return undefined;
+ const text = result.content
+ .flatMap((entry) =>
+ entry.type === "text" && typeof entry.text === "string" ? [entry.text] : [],
+ )
+ .join("\n");
+ return text.length > 0 ? text : undefined;
+}
+
+function piToolData(input: {
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly args: unknown;
+ readonly outputText?: string | undefined;
+ readonly details?: unknown;
+}): Record {
+ const args = isRecord(input.args) ? input.args : {};
+ const bridgeTool = piBridgeToolName(input.toolName);
+ return {
+ toolName: input.toolName,
+ toolCallId: input.toolCallId,
+ // Clients resolve labels and icons from `server` + `tool`, like Codex.
+ ...(bridgeTool !== undefined ? { server: T3_MCP_SERVER_NAME, tool: bridgeTool } : {}),
+ input: args,
+ ...(piToolItemType(input.toolName) === "command_execution" && nonEmptyString(args.command)
+ ? { command: args.command }
+ : {}),
+ ...(input.outputText !== undefined ? { rawOutput: { content: input.outputText } } : {}),
+ ...(isRecord(input.details) && typeof input.details.patch === "string"
+ ? { patch: input.details.patch }
+ : {}),
+ };
+}
+
+function baseEvent(stamp: PiEventStamp, ctx: PiEventContext) {
+ return {
+ ...stamp,
+ provider: ctx.provider,
+ providerInstanceId: ctx.providerInstanceId,
+ threadId: ctx.threadId,
+ ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}),
+ };
+}
+
+function raw(method: string, payload: unknown) {
+ return { raw: { source: "pi.rpc" as const, method, payload } };
+}
+
+export interface PiContextInfo {
+ readonly contextWindow: number | undefined;
+ readonly autoCompactionEnabled?: boolean | undefined;
+}
+
+function contextSnapshotFields(
+ info: PiContextInfo,
+): Pick {
+ return {
+ ...(info.contextWindow !== undefined && info.contextWindow > 0
+ ? { maxTokens: info.contextWindow }
+ : {}),
+ ...(info.autoCompactionEnabled === true ? { compactsAutomatically: true } : {}),
+ };
+}
+
+function finiteNonNegative(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
+}
+
+export function piUsageToSnapshot(
+ usage: PiUsage | undefined,
+ info: PiContextInfo,
+): ThreadTokenUsageSnapshot | undefined {
+ if (!usage) return undefined;
+ const input = usage.input ?? 0;
+ const cacheRead = usage.cacheRead ?? 0;
+ const cacheWrite = usage.cacheWrite ?? 0;
+ const output = usage.output ?? 0;
+ const usedTokens = input + cacheRead + cacheWrite + output;
+ if (usedTokens <= 0 && (usage.totalTokens ?? 0) <= 0) return undefined;
+ return {
+ usedTokens: usage.totalTokens ?? usedTokens,
+ inputTokens: input + cacheRead + cacheWrite,
+ cachedInputTokens: cacheRead,
+ outputTokens: output,
+ lastInputTokens: input + cacheRead + cacheWrite,
+ lastCachedInputTokens: cacheRead,
+ lastOutputTokens: output,
+ ...contextSnapshotFields(info),
+ };
+}
+
+export interface PiMappingState {
+ /** Content index → synthetic item id for the assistant text block being streamed. */
+ readonly openTextItems: Map;
+ /** Tool call id → tool name and args for `tool_execution_*` correlation. */
+ readonly tools: Map;
+ /** Item id of the compaction in progress, so its end closes what its start opened. */
+ compactionItemId: string | undefined;
+ nextSyntheticId: () => string;
+}
+
+export function makePiMappingState(nextSyntheticId: () => string): PiMappingState {
+ return {
+ openTextItems: new Map(),
+ tools: new Map(),
+ compactionItemId: undefined,
+ nextSyntheticId,
+ };
+}
+
+/**
+ * Map one pi event to zero or more canonical runtime events. Turn lifecycle
+ * (`turn.started` / `turn.completed`) is owned by the adapter because it
+ * depends on prompt bookkeeping, not on the event stream alone.
+ */
+export function mapPiEvent(input: {
+ readonly event: PiRpcEvent;
+ readonly stamp: () => PiEventStamp;
+ readonly ctx: PiEventContext;
+ readonly state: PiMappingState;
+ readonly contextWindow: number | undefined;
+ readonly autoCompactionEnabled?: boolean | undefined;
+}): ReadonlyArray {
+ const { event, ctx, state } = input;
+ const stamp = input.stamp;
+ const contextInfo: PiContextInfo = {
+ contextWindow: input.contextWindow,
+ autoCompactionEnabled: input.autoCompactionEnabled,
+ };
+
+ switch (event.type) {
+ case "message_update": {
+ if (!("assistantMessageEvent" in event)) return [];
+ const delta = event.assistantMessageEvent;
+ switch (delta.type) {
+ case "text_start": {
+ const itemId = state.nextSyntheticId();
+ state.openTextItems.set(delta.contentIndex, itemId);
+ return [
+ {
+ type: "item.started",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(itemId),
+ payload: { itemType: "assistant_message", status: "inProgress" },
+ },
+ ];
+ }
+ case "text_delta": {
+ const itemId = state.openTextItems.get(delta.contentIndex);
+ if (delta.delta.length === 0) return [];
+ return [
+ {
+ type: "content.delta",
+ ...baseEvent(stamp(), ctx),
+ ...(itemId ? { itemId: RuntimeItemId.make(itemId) } : {}),
+ payload: {
+ streamKind: "assistant_text",
+ delta: delta.delta,
+ contentIndex: delta.contentIndex,
+ },
+ },
+ ];
+ }
+ case "text_end": {
+ const itemId = state.openTextItems.get(delta.contentIndex);
+ state.openTextItems.delete(delta.contentIndex);
+ if (!itemId) return [];
+ return [
+ {
+ type: "item.completed",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(itemId),
+ payload: { itemType: "assistant_message", status: "completed" },
+ },
+ ];
+ }
+ case "thinking_delta": {
+ if (delta.delta.length === 0) return [];
+ return [
+ {
+ type: "content.delta",
+ ...baseEvent(stamp(), ctx),
+ payload: {
+ streamKind: "reasoning_text",
+ delta: delta.delta,
+ contentIndex: delta.contentIndex,
+ },
+ },
+ ];
+ }
+ default:
+ return [];
+ }
+ }
+
+ case "tool_execution_start": {
+ if (!("toolCallId" in event) || !("toolName" in event)) return [];
+ state.tools.set(event.toolCallId, { toolName: event.toolName, args: event.args });
+ return [
+ {
+ type: "item.started",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(event.toolCallId),
+ payload: {
+ itemType: piToolItemType(event.toolName),
+ status: "inProgress",
+ title: piToolTitle(event.toolName, event.args),
+ ...(piToolDetail(event.toolName, event.args)
+ ? { detail: piToolDetail(event.toolName, event.args) }
+ : {}),
+ data: piToolData({
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ args: event.args,
+ }),
+ },
+ ...raw("tool_execution_start", event),
+ },
+ ];
+ }
+
+ case "tool_execution_update": {
+ if (!("toolCallId" in event) || !("toolName" in event)) return [];
+ const known = state.tools.get(event.toolCallId);
+ const args = event.args ?? known?.args;
+ const outputText = piToolResultText(event.partialResult);
+ const itemType = piToolItemType(event.toolName);
+ const events: Array = [
+ {
+ type: "item.updated",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(event.toolCallId),
+ payload: {
+ itemType,
+ status: "inProgress",
+ title: piToolTitle(event.toolName, args),
+ ...(piToolDetail(event.toolName, args)
+ ? { detail: piToolDetail(event.toolName, args) }
+ : {}),
+ data: piToolData({
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ args,
+ outputText,
+ }),
+ },
+ },
+ ];
+ return events;
+ }
+
+ case "tool_execution_end": {
+ if (!("toolCallId" in event) || !("toolName" in event)) return [];
+ const known = state.tools.get(event.toolCallId);
+ state.tools.delete(event.toolCallId);
+ const args = known?.args;
+ const outputText = piToolResultText(event.result);
+ const itemType: CanonicalItemType = piToolItemType(event.toolName);
+ return [
+ {
+ type: "item.completed",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(event.toolCallId),
+ payload: {
+ itemType,
+ status: event.isError === true ? "failed" : "completed",
+ title: piToolTitle(event.toolName, args),
+ ...(piToolDetail(event.toolName, args)
+ ? { detail: piToolDetail(event.toolName, args) }
+ : {}),
+ data: piToolData({
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ args,
+ outputText,
+ details: event.result?.details,
+ }),
+ },
+ ...raw("tool_execution_end", event),
+ },
+ ];
+ }
+
+ case "message_end": {
+ if (!("message" in event) || !isRecord(event.message)) return [];
+ const message = event.message;
+ if (message.role !== "assistant") return [];
+ const events: Array = [];
+ // Close any text block whose `text_end` never arrived.
+ for (const itemId of state.openTextItems.values()) {
+ events.push({
+ type: "item.completed",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(itemId),
+ payload: { itemType: "assistant_message", status: "completed" },
+ });
+ }
+ state.openTextItems.clear();
+ const usage = piUsageToSnapshot(
+ isRecord(message.usage) ? (message.usage as PiUsage) : undefined,
+ contextInfo,
+ );
+ if (usage) {
+ events.push({
+ type: "thread.token-usage.updated",
+ ...baseEvent(stamp(), ctx),
+ payload: { usage },
+ });
+ }
+ if (message.stopReason === "error") {
+ events.push({
+ type: "runtime.error",
+ ...baseEvent(stamp(), ctx),
+ payload: {
+ message: nonEmptyString(message.errorMessage) ?? "pi reported an error.",
+ class: "provider_error",
+ },
+ ...raw("message_end", message),
+ });
+ }
+ return events;
+ }
+
+ case "compaction_start": {
+ state.compactionItemId = state.nextSyntheticId();
+ return [
+ {
+ type: "item.started",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(state.compactionItemId),
+ payload: {
+ itemType: "context_compaction",
+ status: "inProgress",
+ title: "Compacting context",
+ },
+ ...raw("compaction_start", event),
+ },
+ ];
+ }
+
+ case "compaction_end": {
+ // pi omits `result` when the compaction was aborted or errored, which is
+ // exactly when the started item most needs closing.
+ const result = "result" in event ? event.result : undefined;
+ const aborted = "aborted" in event ? event.aborted : undefined;
+ const errorMessage = "errorMessage" in event ? event.errorMessage : undefined;
+ const failed = aborted === true || result === null || result === undefined;
+ const summary = result?.summary;
+ const itemId = state.compactionItemId ?? state.nextSyntheticId();
+ state.compactionItemId = undefined;
+ const events: Array = [
+ {
+ type: "item.completed",
+ ...baseEvent(stamp(), ctx),
+ itemId: RuntimeItemId.make(itemId),
+ payload: {
+ itemType: "context_compaction",
+ status: failed ? "failed" : "completed",
+ title: "Compacted context",
+ ...(nonEmptyString(errorMessage) ? { detail: errorMessage } : {}),
+ ...(summary ? { data: { summary } } : {}),
+ },
+ ...raw("compaction_end", event),
+ },
+ ];
+ if (failed) return events;
+ const beforeTokens = finiteNonNegative(result?.tokensBefore);
+ const afterTokens = finiteNonNegative(result?.estimatedTokensAfter);
+ events.push({
+ type: "thread.state.changed",
+ ...baseEvent(stamp(), ctx),
+ payload: {
+ state: "compacted",
+ ...(beforeTokens !== undefined ? { beforeTokens } : {}),
+ ...(afterTokens !== undefined ? { afterTokens } : {}),
+ },
+ ...raw("compaction_end", event),
+ });
+ // pi only reports a heuristic estimate until the next assistant reply;
+ // publishing it keeps the meter from showing the pre-compaction size.
+ if (afterTokens !== undefined) {
+ events.push({
+ type: "thread.token-usage.updated",
+ ...baseEvent(stamp(), ctx),
+ payload: {
+ usage: {
+ usedTokens: afterTokens,
+ ...(beforeTokens !== undefined ? { lastUsedTokens: beforeTokens } : {}),
+ ...contextSnapshotFields(contextInfo),
+ },
+ },
+ });
+ }
+ return events;
+ }
+
+ case "auto_retry_start": {
+ // `attempt` is optional in pi's payload; a retry is still a retry.
+ const attempt = ("attempt" in event ? event.attempt : undefined) ?? 1;
+ const max = ("maxAttempts" in event ? event.maxAttempts : undefined) ?? attempt;
+ const retryError = "errorMessage" in event ? event.errorMessage : undefined;
+ return [
+ {
+ type: "runtime.warning",
+ ...baseEvent(stamp(), ctx),
+ payload: {
+ message: `pi is retrying after a transient error (attempt ${attempt} of ${max}).`,
+ ...(nonEmptyString(retryError) ? { detail: retryError } : {}),
+ },
+ ...raw("auto_retry_start", event),
+ },
+ ];
+ }
+
+ case "auto_retry_end": {
+ if (!("success" in event) || event.success !== false) return [];
+ return [
+ {
+ type: "runtime.error",
+ ...baseEvent(stamp(), ctx),
+ payload: {
+ message: nonEmptyString(event.finalError) ?? "pi gave up retrying.",
+ class: "provider_error",
+ },
+ ...raw("auto_retry_end", event),
+ },
+ ];
+ }
+
+ case "extension_error": {
+ if (!("error" in event)) return [];
+ return [
+ {
+ type: "runtime.warning",
+ ...baseEvent(stamp(), ctx),
+ payload: {
+ message: `pi extension error: ${nonEmptyString(event.error) ?? "unknown"}`,
+ ...(nonEmptyString(event.extensionPath) ? { detail: event.extensionPath } : {}),
+ },
+ ...raw("extension_error", event),
+ },
+ ];
+ }
+
+ default:
+ return [];
+ }
+}
diff --git a/apps/server/src/provider/pi/piExtension.ts b/apps/server/src/provider/pi/piExtension.ts
new file mode 100644
index 000000000000..3522428e9cf0
--- /dev/null
+++ b/apps/server/src/provider/pi/piExtension.ts
@@ -0,0 +1,332 @@
+/**
+ * piExtension — the pi extension T3 Code loads into every pi session with
+ * `-e `. It is shipped as a string and materialized into the state
+ * directory at driver creation so the server bundle needs no asset step and
+ * the user's `~/.pi/agent` is never touched.
+ *
+ * Two jobs:
+ * 1. Approval gate. `tool_call` handlers consult `T3_PI_RUNTIME_MODE` and
+ * ask T3 through `ctx.ui.select` (an `extension_ui_request` on stdout in
+ * RPC mode) before running commands and file changes.
+ * 2. T3 MCP bridge. When `T3_MCP_URL` and `T3_MCP_BEARER_TOKEN` are set,
+ * every tool on T3's MCP server is registered as a pi tool that forwards
+ * `tools/call` over HTTP, so pi gets the preview toolkit like the other
+ * providers.
+ *
+ * The select title is a JSON envelope `{ t3: "approval", ... }` so the
+ * adapter can tell T3's own requests apart from any other extension's UI.
+ *
+ * @module provider/pi/piExtension
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE";
+export const T3_PI_MCP_URL_ENV = "T3_MCP_URL";
+export const T3_PI_MCP_TOKEN_ENV = "T3_MCP_BEARER_TOKEN";
+export const T3_PI_APPROVAL_MARKER = "t3-approval";
+/** Emitted by the user's `ask_user` extension through `ctx.ui.select` in RPC mode. */
+export const T3_PI_QUESTION_MARKER = "t3-question";
+/** Follow-up `ctx.ui.input` the extension raises after the user picks "write my own". */
+export const T3_PI_QUESTION_CUSTOM_MARKER = "t3-question-custom";
+/** Sentinel select value meaning "the user typed a custom answer". */
+export const T3_PI_QUESTION_OTHER_VALUE = "__t3_other__";
+export const T3_PI_EXTENSION_FILE_NAME = "t3-code.ts";
+/** Every `t3-code` MCP tool is registered in pi under this prefix. */
+export const T3_PI_MCP_TOOL_PREFIX = "t3_";
+/**
+ * Attached to `t3_preview_status` so pi appends them to its Guidelines section
+ * only while the bridge is up. Mirrors the Codex browser block: the steer away
+ * from other browsers must never appear when the tools are absent.
+ */
+export const T3_PI_BROWSER_GUIDELINES = [
+ "The t3_preview_* tools are the T3 Code collaborative browser shared with the user. Prefer them for browser navigation, inspection, interaction, screenshots, and recordings.",
+ "For browser work, call t3_preview_status first. If no automation-capable preview is attached, call t3_preview_open before concluding the browser is unavailable, then use t3_preview_navigate, t3_preview_snapshot, and the focused interaction tools. Prefer snapshot-provided locators over coordinates.",
+ "Do not switch to Chrome, standalone Playwright, or agent-browser merely because the preview is initially closed or a first t3_preview_* call fails. Use another browser only when the user asks for one or t3_preview_open returns an explicit unsupported or unavailable error.",
+] as const;
+
+export const T3_PI_APPROVAL_OPTIONS = ["accept", "acceptForSession", "decline"] as const;
+
+export interface T3PiApprovalEnvelope {
+ readonly t3: typeof T3_PI_APPROVAL_MARKER;
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly input: unknown;
+}
+
+export function parseT3PiApprovalEnvelope(title: unknown): T3PiApprovalEnvelope | undefined {
+ if (typeof title !== "string" || !title.startsWith("{")) return undefined;
+ try {
+ const parsed: unknown = JSON.parse(title);
+ if (
+ typeof parsed === "object" &&
+ parsed !== null &&
+ (parsed as { t3?: unknown }).t3 === T3_PI_APPROVAL_MARKER &&
+ typeof (parsed as { toolCallId?: unknown }).toolCallId === "string" &&
+ typeof (parsed as { toolName?: unknown }).toolName === "string"
+ ) {
+ return parsed as T3PiApprovalEnvelope;
+ }
+ } catch {
+ return undefined;
+ }
+ return undefined;
+}
+
+export interface T3PiQuestionOption {
+ readonly label: string;
+ readonly description?: string;
+}
+
+export type T3PiQuestionEnvelope =
+ | {
+ readonly t3: typeof T3_PI_QUESTION_MARKER;
+ readonly question: string;
+ readonly options: ReadonlyArray;
+ }
+ | { readonly t3: typeof T3_PI_QUESTION_CUSTOM_MARKER; readonly question: string };
+
+export function parseT3PiQuestionEnvelope(title: unknown): T3PiQuestionEnvelope | undefined {
+ if (typeof title !== "string" || !title.startsWith("{")) return undefined;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(title);
+ } catch {
+ return undefined;
+ }
+ if (typeof parsed !== "object" || parsed === null) return undefined;
+ const record = parsed as Record;
+ if (typeof record.question !== "string" || record.question.trim().length === 0) return undefined;
+ if (record.t3 === T3_PI_QUESTION_CUSTOM_MARKER) {
+ return { t3: T3_PI_QUESTION_CUSTOM_MARKER, question: record.question };
+ }
+ if (record.t3 !== T3_PI_QUESTION_MARKER || !Array.isArray(record.options)) return undefined;
+ const options: Array = [];
+ for (const entry of record.options) {
+ if (typeof entry !== "object" || entry === null) continue;
+ const option = entry as Record;
+ if (typeof option.label !== "string" || option.label.trim().length === 0) continue;
+ options.push({
+ label: option.label,
+ ...(typeof option.description === "string" && option.description.trim().length > 0
+ ? { description: option.description }
+ : {}),
+ });
+ }
+ if (options.length === 0) return undefined;
+ return { t3: T3_PI_QUESTION_MARKER, question: record.question, options };
+}
+
+export const T3_PI_EXTENSION_SOURCE = String.raw`// Generated by T3 Code. Do not edit; it is rewritten on every server start.
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+
+const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)};
+const MCP_URL_ENV = ${JSON.stringify(T3_PI_MCP_URL_ENV)};
+const MCP_TOKEN_ENV = ${JSON.stringify(T3_PI_MCP_TOKEN_ENV)};
+const MCP_TOOL_PREFIX = ${JSON.stringify(T3_PI_MCP_TOOL_PREFIX)};
+const BROWSER_GUIDELINES = ${JSON.stringify(T3_PI_BROWSER_GUIDELINES)};
+const APPROVAL_MARKER = ${JSON.stringify(T3_PI_APPROVAL_MARKER)};
+const OPTIONS = ${JSON.stringify(T3_PI_APPROVAL_OPTIONS)};
+const FILE_CHANGE_TOOLS = new Set(["edit", "write"]);
+const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
+
+type Json = Record;
+
+function stableKey(toolName: string, input: unknown): string {
+ try {
+ return toolName + ":" + JSON.stringify(input);
+ } catch {
+ return toolName + ":" + String(input);
+ }
+}
+
+function firstSentence(text: string): string {
+ const end = text.search(/\.\s/);
+ return end === -1 ? text : text.slice(0, end + 1);
+}
+
+function needsApproval(toolName: string): boolean {
+ const mode = process.env[RUNTIME_MODE_ENV] ?? "full-access";
+ if (mode === "full-access") return false;
+ if (READ_ONLY_TOOLS.has(toolName)) return false;
+ if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(toolName)) return false;
+ return true;
+}
+
+/** An MCP endpoint that accepts and never answers would park a tool call forever. */
+const MCP_TIMEOUT_MS = 30_000;
+
+async function mcpRequest(url: string, token: string, body: Json, sessionId: string | undefined) {
+ const headers: Record = {
+ "content-type": "application/json",
+ accept: "application/json, text/event-stream",
+ authorization: "Bearer " + token,
+ };
+ if (sessionId) headers["mcp-session-id"] = sessionId;
+ const response = await fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(MCP_TIMEOUT_MS),
+ });
+ const nextSessionId = response.headers.get("mcp-session-id") ?? sessionId;
+ if (response.status === 202 || response.status === 204) {
+ return { result: undefined, sessionId: nextSessionId };
+ }
+ if (!response.ok) {
+ throw new Error("T3 MCP request failed with status " + response.status);
+ }
+ const contentType = response.headers.get("content-type") ?? "";
+ const text = await response.text();
+ let message: Json | undefined;
+ if (contentType.includes("text/event-stream")) {
+ for (const line of text.split("\n")) {
+ if (!line.startsWith("data:")) continue;
+ const parsed = JSON.parse(line.slice(5).trim()) as Json;
+ if (parsed && "id" in parsed && parsed.id === body.id) {
+ message = parsed;
+ }
+ }
+ } else if (text.trim().length > 0) {
+ message = JSON.parse(text) as Json;
+ }
+ if (message && "error" in message && message.error) {
+ const error = message.error as { message?: string };
+ throw new Error(error.message ?? "T3 MCP request failed.");
+ }
+ return { result: message ? (message.result as Json | undefined) : undefined, sessionId: nextSessionId };
+}
+
+export default async function (pi: ExtensionAPI) {
+ const approvedForSession = new Set();
+
+ pi.on("tool_call", async (event, ctx) => {
+ if (!needsApproval(event.toolName)) return undefined;
+ const key = stableKey(event.toolName, event.input);
+ if (approvedForSession.has(key)) return undefined;
+ if (!ctx.hasUI) {
+ return { block: true, reason: "T3 Code could not ask for approval." };
+ }
+ const title = JSON.stringify({
+ t3: APPROVAL_MARKER,
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ input: event.input,
+ });
+ const choice = await ctx.ui.select(title, [...OPTIONS]);
+ if (choice === "acceptForSession") {
+ approvedForSession.add(key);
+ return undefined;
+ }
+ if (choice === "accept") return undefined;
+ return { block: true, reason: "Declined by the user in T3 Code." };
+ });
+
+ const url = process.env[MCP_URL_ENV];
+ const token = process.env[MCP_TOKEN_ENV];
+ if (!url || !token) return;
+
+ let sessionId: string | undefined;
+ let nextId = 1;
+ try {
+ const init = await mcpRequest(
+ url,
+ token,
+ {
+ jsonrpc: "2.0",
+ id: nextId++,
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "t3-code-pi", version: "0.0.0" },
+ },
+ },
+ undefined,
+ );
+ sessionId = init.sessionId;
+ await mcpRequest(url, token, { jsonrpc: "2.0", method: "notifications/initialized" }, sessionId);
+ const list = await mcpRequest(
+ url,
+ token,
+ { jsonrpc: "2.0", id: nextId++, method: "tools/list", params: {} },
+ sessionId,
+ );
+ sessionId = list.sessionId;
+ const tools = Array.isArray(list.result?.tools) ? (list.result!.tools as Array) : [];
+ for (const tool of tools) {
+ const name = typeof tool.name === "string" ? tool.name : undefined;
+ if (!name) continue;
+ const description = typeof tool.description === "string" ? tool.description : name;
+ pi.registerTool({
+ name: MCP_TOOL_PREFIX + name,
+ label: typeof tool.title === "string" ? tool.title : name,
+ description,
+ // One line in the system prompt's "Available tools"; the full
+ // description still ships with the tool schema.
+ promptSnippet: firstSentence(description),
+ ...(name === "preview_status" ? { promptGuidelines: BROWSER_GUIDELINES } : {}),
+ // The MCP server owns validation; pass the JSON schema through as-is.
+ parameters: (tool.inputSchema ?? { type: "object", properties: {} }) as never,
+ async execute(_toolCallId, params) {
+ const call = await mcpRequest(
+ url,
+ token,
+ {
+ jsonrpc: "2.0",
+ id: nextId++,
+ method: "tools/call",
+ params: { name, arguments: params ?? {} },
+ },
+ sessionId,
+ );
+ sessionId = call.sessionId;
+ const result = call.result ?? {};
+ const content = Array.isArray(result.content)
+ ? (result.content as Array).map((entry) =>
+ entry.type === "image" && typeof entry.data === "string"
+ ? { type: "image", data: entry.data, mimeType: String(entry.mimeType ?? "image/png") }
+ : { type: "text", text: typeof entry.text === "string" ? entry.text : JSON.stringify(entry) },
+ )
+ : [{ type: "text", text: JSON.stringify(result) }];
+ if (result.isError === true) {
+ throw new Error(
+ content.map((entry) => ("text" in entry ? entry.text : "")).join("\n") || "T3 tool failed.",
+ );
+ }
+ return { content: content as never, details: result.structuredContent ?? {} };
+ },
+ });
+ }
+ } catch (error) {
+ // The preview toolkit is optional; pi keeps working without it.
+ console.error("[t3-code] T3 MCP bridge unavailable:", error instanceof Error ? error.message : error);
+ }
+}
+`;
+
+export function resolvePiExtensionDir(stateDir: string, path: Path.Path): string {
+ return path.join(stateDir, "providers", "pi", "extensions");
+}
+
+/**
+ * Write the extension into the state dir. Idempotent: identical content is
+ * left alone so pi's module cache and file watchers stay quiet.
+ */
+export const materializePiExtension = Effect.fn("materializePiExtension")(function* (
+ stateDir: string,
+) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const directory = resolvePiExtensionDir(stateDir, path);
+ const filePath = path.join(directory, T3_PI_EXTENSION_FILE_NAME);
+ yield* fileSystem.makeDirectory(directory, { recursive: true, mode: 0o700 });
+ const existing = yield* fileSystem
+ .readFileString(filePath)
+ .pipe(Effect.orElseSucceed(() => undefined));
+ if (existing !== T3_PI_EXTENSION_SOURCE) {
+ yield* fileSystem.writeFileString(filePath, T3_PI_EXTENSION_SOURCE, { mode: 0o600 });
+ }
+ return filePath;
+});
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index 5d2571e72dc3..138390264c98 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -603,6 +603,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
assert.isFalse(settings.providers.grok.enabled);
assert.isTrue(settings.providers.opencode.enabled);
assert.isFalse(settings.providers.cursor.enabled);
+ assert.isFalse(settings.providers.pi.enabled);
assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096");
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
@@ -1045,6 +1046,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
serverUrl: "http://127.0.0.1:4096",
serverPassword: "secret-password",
},
+ pi: {
+ enabled: false,
+ },
},
backgroundActivity: {
schemaVersion: 1,
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index fe72147dfa0c..11270c479de4 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -260,6 +260,7 @@ const PersistedOptionalProviderSettings = Schema.Struct({
cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
+ pi: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
}),
),
});
@@ -287,7 +288,8 @@ function restoreUsedProviders(
instance.enabled === undefined &&
(instance.driver === "cursor" ||
instance.driver === "grok" ||
- instance.driver === "opencode") &&
+ instance.driver === "opencode" ||
+ instance.driver === "pi") &&
usedProviderInstances.has(instanceId)
? { ...instance, enabled: true }
: instance,
@@ -310,6 +312,10 @@ function restoreUsedProviders(
...settings.providers.opencode,
enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"),
},
+ pi: {
+ ...settings.providers.pi,
+ enabled: persisted.providers?.pi?.enabled ?? usedProviders.has("pi"),
+ },
},
providerInstances,
};
@@ -363,6 +369,7 @@ const PERSISTED_SERVER_SETTINGS_DEFAULTS = {
cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined },
grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined },
opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined },
+ pi: { ...DEFAULT_SERVER_SETTINGS.providers.pi, enabled: undefined },
},
};
@@ -472,13 +479,13 @@ const make = Effect.gen(function* () {
provider_name AS "providerName",
provider_instance_id AS "providerInstanceId"
FROM projection_thread_sessions
- WHERE provider_name IN ('cursor', 'grok', 'opencode')
+ WHERE provider_name IN ('cursor', 'grok', 'opencode', 'pi')
UNION
SELECT DISTINCT
provider_name AS "providerName",
provider_instance_id AS "providerInstanceId"
FROM provider_session_runtime
- WHERE provider_name IN ('cursor', 'grok', 'opencode')
+ WHERE provider_name IN ('cursor', 'grok', 'opencode', 'pi')
`.pipe(
Effect.mapError(
(cause) =>
diff --git a/apps/server/src/textGeneration/PiTextGeneration.test.ts b/apps/server/src/textGeneration/PiTextGeneration.test.ts
new file mode 100644
index 000000000000..570f24655720
--- /dev/null
+++ b/apps/server/src/textGeneration/PiTextGeneration.test.ts
@@ -0,0 +1,89 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import { createModelSelection } from "@t3tools/shared/model";
+import { PI_DEFAULT_MODEL, PiSettings, ProviderInstanceId } from "@t3tools/contracts";
+
+import { makePiTextGeneration, piPrintModeArgs } from "./PiTextGeneration.ts";
+
+const decodePiSettings = Schema.decodeSync(PiSettings);
+const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
+const mockPath = NodePath.join(__dirname, "../../scripts/pi-mock-rpc.ts");
+
+function makeFakePi(dir: string, argsLog: string): string {
+ const binaryPath = NodePath.join(dir, "pi");
+ NodeFS.writeFileSync(
+ binaryPath,
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argsLog)}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockPath)} "$@"\n`,
+ "utf8",
+ );
+ NodeFS.chmodSync(binaryPath, 0o755);
+ return binaryPath;
+}
+
+it("builds print-mode args with tools, extensions, and sessions off", () => {
+ const args = piPrintModeArgs(
+ createModelSelection(ProviderInstanceId.make("pi"), "anthropic/claude-haiku-4-5", [
+ { id: "thinkingLevel", value: "low" },
+ ]),
+ );
+ assert.deepStrictEqual(args, [
+ "-p",
+ "--no-tools",
+ "--no-extensions",
+ "--no-session",
+ "--no-approve",
+ "--no-context-files",
+ "--model",
+ "anthropic/claude-haiku-4-5",
+ "--thinking",
+ "low",
+ ]);
+ const sentinel = piPrintModeArgs(
+ createModelSelection(ProviderInstanceId.make("pi"), PI_DEFAULT_MODEL),
+ );
+ assert.isFalse(sentinel.includes("--model"));
+ // Generated titles and branch names must not read the user's AGENTS.md.
+ assert.isTrue(sentinel.includes("--no-context-files"));
+});
+
+it.effect("runs pi in print mode and parses structured commit output", () =>
+ Effect.gen(function* () {
+ const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-pi-text-"));
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true })),
+ );
+ const argsLog = NodePath.join(dir, "args.txt");
+ const binaryPath = makeFakePi(dir, argsLog);
+ const textGeneration = yield* makePiTextGeneration(decodePiSettings({ binaryPath }));
+
+ const generated = yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feature/pi",
+ stagedSummary: "M apps/server/src/provider/Drivers/PiDriver.ts",
+ stagedPatch: "diff --git a/x b/x",
+ modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "openai/gpt-5"),
+ });
+ assert.strictEqual(generated.subject, "feat: mock pi commit");
+ assert.strictEqual(generated.body, "Body from mock pi.");
+
+ const argv = NodeFS.readFileSync(argsLog, "utf8").split("\n");
+ assert.include(argv, "-p");
+ assert.include(argv, "--no-tools");
+ assert.strictEqual(argv[argv.indexOf("--model") + 1], "openai/gpt-5");
+
+ const title = yield* textGeneration.generateThreadTitle({
+ cwd: process.cwd(),
+ message: "Please add a pi provider",
+ modelSelection: createModelSelection(ProviderInstanceId.make("pi"), PI_DEFAULT_MODEL),
+ });
+ assert.strictEqual(title.title, "Mock pi title");
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts
new file mode 100644
index 000000000000..4833b08d4e68
--- /dev/null
+++ b/apps/server/src/textGeneration/PiTextGeneration.ts
@@ -0,0 +1,246 @@
+/**
+ * PiTextGeneration — titles, branch names, commit and PR text through
+ * `pi -p` (print mode). Tools, extensions, and session persistence are all
+ * off, and project-local resources are ignored (`--no-approve`), so a helper
+ * call cannot touch the workspace.
+ *
+ * @module textGeneration/PiTextGeneration
+ */
+import { PI_DEFAULT_MODEL, type PiSettings, TextGenerationError } from "@t3tools/contracts";
+import type { ModelSelection } from "@t3tools/contracts";
+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
+import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
+import { extractJsonObject } from "@t3tools/shared/schemaJson";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+import { collectStreamAsString } from "../provider/providerSnapshot.ts";
+import { isPiThinkingLevel } from "../provider/pi/PiRpcProtocol.ts";
+import { buildPiEnvironment, PI_REASONING_OPTION_ID } from "../provider/Layers/PiProvider.ts";
+import * as TextGeneration from "./TextGeneration.ts";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+ buildThreadTitlePrompt,
+} from "./TextGenerationPrompts.ts";
+import {
+ normalizeCliError,
+ sanitizeCommitSubject,
+ sanitizePrTitle,
+ sanitizeThreadTitle,
+} from "./TextGenerationUtils.ts";
+
+const PI_TIMEOUT_MS = 180_000;
+const isTextGenerationError = Schema.is(TextGenerationError);
+
+export function piPrintModeArgs(modelSelection: ModelSelection): ReadonlyArray {
+ const model = modelSelection.model.trim();
+ const level = getModelSelectionStringOptionValue(modelSelection, PI_REASONING_OPTION_ID);
+ return [
+ "-p",
+ "--no-tools",
+ "--no-extensions",
+ "--no-session",
+ "--no-approve",
+ // `--no-approve` only distrusts project-local pi files. Titles and branch
+ // names are generated in the user's repo, so keep AGENTS.md out too.
+ "--no-context-files",
+ ...(model && model !== PI_DEFAULT_MODEL ? ["--model", model] : []),
+ ...(level && isPiThinkingLevel(level) ? ["--thinking", level] : []),
+ ];
+}
+
+export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* (
+ piSettings: PiSettings,
+ environment?: NodeJS.ProcessEnv,
+) {
+ const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const piEnvironment = buildPiEnvironment(piSettings, environment ?? process.env);
+ const binary = piSettings.binaryPath || "pi";
+
+ const runPiJson = ({
+ operation,
+ cwd,
+ prompt,
+ outputSchemaJson,
+ modelSelection,
+ }: {
+ operation:
+ | "generateCommitMessage"
+ | "generatePrContent"
+ | "generateBranchName"
+ | "generateThreadTitle";
+ cwd: string;
+ prompt: string;
+ outputSchemaJson: S;
+ modelSelection: ModelSelection;
+ }): Effect.Effect =>
+ Effect.gen(function* () {
+ const spawnCommand = yield* resolveSpawnCommand(binary, piPrintModeArgs(modelSelection), {
+ env: piEnvironment,
+ });
+ const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ env: piEnvironment,
+ cwd,
+ shell: spawnCommand.shell,
+ stdin: { stream: Stream.encodeText(Stream.make(prompt)) },
+ });
+ const child = yield* commandSpawner
+ .spawn(command)
+ .pipe(
+ Effect.mapError((cause) =>
+ normalizeCliError("pi", operation, cause, "Failed to spawn pi."),
+ ),
+ );
+ const [stdout, stderr, exitCode] = yield* Effect.all(
+ [
+ collectStreamAsString(child.stdout).pipe(
+ Effect.mapError((cause) =>
+ normalizeCliError("pi", operation, cause, "Failed to read pi output."),
+ ),
+ ),
+ collectStreamAsString(child.stderr).pipe(
+ Effect.mapError((cause) =>
+ normalizeCliError("pi", operation, cause, "Failed to read pi output."),
+ ),
+ ),
+ child.exitCode.pipe(
+ Effect.mapError((cause) =>
+ normalizeCliError("pi", operation, cause, "Failed to read pi exit code."),
+ ),
+ ),
+ ],
+ { concurrency: "unbounded" },
+ );
+ if (exitCode !== 0) {
+ const detail = stderr.trim() || stdout.trim();
+ return yield* new TextGenerationError({
+ operation,
+ detail: detail ? `pi failed: ${detail}` : `pi exited with code ${exitCode}.`,
+ });
+ }
+ const trimmed = stdout.trim();
+ if (!trimmed) {
+ return yield* new TextGenerationError({ operation, detail: "pi returned empty output." });
+ }
+ const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));
+ return yield* decodeOutput(extractJsonObject(trimmed)).pipe(
+ Effect.catchTags({
+ SchemaError: (cause) =>
+ Effect.fail(
+ new TextGenerationError({
+ operation,
+ detail: "pi returned invalid structured output.",
+ cause,
+ }),
+ ),
+ }),
+ );
+ }).pipe(
+ Effect.scoped,
+ Effect.timeoutOption(PI_TIMEOUT_MS),
+ Effect.flatMap(
+ Option.match({
+ onNone: () =>
+ Effect.fail(new TextGenerationError({ operation, detail: "pi request timed out." })),
+ onSome: (value) => Effect.succeed(value),
+ }),
+ ),
+ Effect.mapError((cause) =>
+ isTextGenerationError(cause)
+ ? cause
+ : new TextGenerationError({ operation, detail: "pi text generation failed.", cause }),
+ ),
+ );
+
+ const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
+ Effect.fn("PiTextGeneration.generateCommitMessage")(function* (input) {
+ const { prompt, outputSchema } = buildCommitMessagePrompt({
+ branch: input.branch,
+ stagedSummary: input.stagedSummary,
+ stagedPatch: input.stagedPatch,
+ includeBranch: input.includeBranch === true,
+ policy: input.policy,
+ });
+ const generated = yield* runPiJson({
+ operation: "generateCommitMessage",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return {
+ subject: sanitizeCommitSubject(generated.subject),
+ body: generated.body.trim(),
+ ...("branch" in generated && typeof generated.branch === "string"
+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
+ : {}),
+ };
+ });
+
+ const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
+ Effect.fn("PiTextGeneration.generatePrContent")(function* (input) {
+ const { prompt, outputSchema } = buildPrContentPrompt({
+ baseBranch: input.baseBranch,
+ headBranch: input.headBranch,
+ commitSummary: input.commitSummary,
+ diffSummary: input.diffSummary,
+ diffPatch: input.diffPatch,
+ policy: input.policy,
+ changeRequestTemplate: input.changeRequestTemplate,
+ });
+ const generated = yield* runPiJson({
+ operation: "generatePrContent",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return { title: sanitizePrTitle(generated.title), body: generated.body.trim() };
+ });
+
+ const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
+ Effect.fn("PiTextGeneration.generateBranchName")(function* (input) {
+ const { prompt, outputSchema } = buildBranchNamePrompt({
+ message: input.message,
+ attachments: input.attachments,
+ });
+ const generated = yield* runPiJson({
+ operation: "generateBranchName",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return { branch: sanitizeBranchFragment(generated.branch) };
+ });
+
+ const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
+ Effect.fn("PiTextGeneration.generateThreadTitle")(function* (input) {
+ const { prompt, outputSchema } = buildThreadTitlePrompt({
+ message: input.message,
+ previousTitle: input.previousTitle,
+ attachments: input.attachments,
+ });
+ const generated = yield* runPiJson({
+ operation: "generateThreadTitle",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+ return { title: sanitizeThreadTitle(generated.title) };
+ });
+
+ return {
+ generateCommitMessage,
+ generatePrContent,
+ generateBranchName,
+ generateThreadTitle,
+ } satisfies TextGeneration.TextGeneration["Service"];
+});
diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts
index cc9b2e3926f3..d3fea53c68c3 100644
--- a/apps/server/src/textGeneration/TextGeneration.ts
+++ b/apps/server/src/textGeneration/TextGeneration.ts
@@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance
import type { ProviderInstance } from "../provider/ProviderDriver.ts";
import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
-export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
+export type TextGenerationProvider =
+ | "codex"
+ | "claudeAgent"
+ | "cursor"
+ | "grok"
+ | "opencode"
+ | "pi";
export interface CommitMessageGenerationInput {
cwd: string;
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index 199d0ba834d0..5d5824f56ddf 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -219,6 +219,22 @@ export const GrokIcon: Icon = ({ className, ...props }) => (
);
+/** Official pi mark from pi.dev: blocky "P" with a separate square "i" dot. */
+export const PiIcon: Icon = ({ className, ...props }) => (
+
+
+
+
+);
+
export const TraeIcon: Icon = (props) => (
{/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */}
diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts
index db0e5ca222f3..3ade4c2b4191 100644
--- a/apps/web/src/components/chat/providerIconUtils.ts
+++ b/apps/web/src/components/chat/providerIconUtils.ts
@@ -7,6 +7,7 @@ import {
Icon,
OpenAI,
OpenCodeIcon,
+ PiIcon,
} from "../Icons";
export const PROVIDER_ICON_BY_PROVIDER: Partial> = {
@@ -16,6 +17,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
[ProviderDriverKind.make("antigravity")]: AntigravityIcon,
+ [ProviderDriverKind.make("pi")]: PiIcon,
};
export type ModelEsque = {
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts
index 4bf4da3919ba..96af69baab26 100644
--- a/apps/web/src/components/settings/providerDriverMeta.ts
+++ b/apps/web/src/components/settings/providerDriverMeta.ts
@@ -5,6 +5,7 @@ import {
CursorSettings,
GrokSettings,
OpenCodeSettings,
+ PiSettings,
ProviderDriverKind,
} from "@t3tools/contracts";
import type * as Schema from "effect/Schema";
@@ -16,6 +17,7 @@ import {
type Icon,
OpenAI,
OpenCodeIcon,
+ PiIcon,
} from "../Icons";
type ProviderSettingsSchema = {
@@ -82,6 +84,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [
icon: AntigravityIcon,
settingsSchema: AntigravitySettings,
},
+ {
+ value: ProviderDriverKind.make("pi"),
+ label: "pi",
+ icon: PiIcon,
+ badgeLabel: "Early Access",
+ settingsSchema: PiSettings,
+ },
];
const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index be06a37f308e..2890b42830b7 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -222,7 +222,7 @@ export const SETTINGS_SEARCH_ITEMS = [
id: "provider-update-checks",
title: "Provider update checks",
to: "/settings/general",
- searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"],
+ searchTerms: ["installed cli versions newer available codex claude cursor grok opencode pi"],
},
{
id: "continue-threads-after-server-update",
@@ -330,7 +330,7 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Providers",
to: "/settings/providers",
searchTerms: [
- "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact",
+ "agents cli codex claude cursor grok opencode antigravity pi google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact",
],
},
{
diff --git a/docs/internals/providers.md b/docs/internals/providers.md
index ec40c49810dc..288a36ca4f57 100644
--- a/docs/internals/providers.md
+++ b/docs/internals/providers.md
@@ -107,3 +107,54 @@ current client support.
Model classification has its own [manifest constraints](./model-manifest.md). Assistant-reference
handling is documented under [citations](./assistant-citations.md).
+
+## pi RPC transport
+
+Fork-local driver. After merging upstream, follow
+[pi-provider-maintenance.md](./pi-provider-maintenance.md).
+
+The [driver](../../apps/server/src/provider/Drivers/PiDriver.ts) wraps the user's `pi` binary in
+`--mode rpc`, one process per thread, speaking pi's LF-delimited JSONL through the
+[client](../../apps/server/src/provider/pi/PiRpcClient.ts). The
+[event mapper](../../apps/server/src/provider/pi/PiRuntimeEvents.ts) is pure; the adapter owns
+`turn.started` and `turn.completed` around a `prompt` command and pi's `agent_settled`.
+
+pi has no native permission prompt in RPC mode. T3 materializes a small
+[extension](../../apps/server/src/provider/pi/piExtension.ts) into
+`/providers/pi/extensions/t3-code.ts` and loads it with `-e`. Its `tool_call` handler
+reads `T3_PI_RUNTIME_MODE` and, for tools the mode does not auto-approve, calls `ctx.ui.select`
+with a `{ t3: "t3-approval", toolCallId, toolName, input }` envelope. pi turns that into an
+`extension_ui_request`, which the adapter maps to `request.opened`; the decision returns as
+`extension_ui_response`. `full-access` gates nothing, `auto-accept-edits` gates everything except
+`edit`, `write`, and read-only tools, and `approval-required` and `auto` gate every non-read tool.
+`acceptForSession` is remembered in the extension by tool name plus serialized input. `-e` is
+additive: the user's global extensions, packages, skills, and `models.json` load as in the
+terminal. Project-local `.pi/` resources follow pi's saved trust decision because
+non-interactive modes never prompt.
+
+User questions use the same dialog channel. An extension that wants a T3 question card calls
+`ctx.ui.select` with a `{ t3: "t3-question", question, options }` title and the option labels plus
+`__t3_other__`; the adapter emits `user-input.requested` with `allowCustomAnswer: true`. An
+answer matching a label is returned as the select value. Any other text is held on the session and
+the adapter replies `__t3_other__`; the extension then opens `ctx.ui.input` with a
+`{ t3: "t3-question-custom", question }` title, which the adapter answers from the held text
+without showing a second card. Dismiss, Stop, and session teardown reply `cancelled`. The
+reference caller is the user's `ask_user` extension; pi itself raises no questions.
+
+The same extension bridges T3's MCP server. When `T3_MCP_URL` and `T3_MCP_BEARER_TOKEN` are set
+it runs `initialize` and `tools/list` against the Streamable HTTP endpoint and registers each tool
+as `t3_`, forwarding `tools/call`. Failure to reach the server is logged to stderr and pi
+continues without the preview toolkit.
+
+Model slugs are `provider/id` from pi's `get_available_models`, which lists only models with
+working credentials. `pi-default` is a sentinel meaning "pi's own default" and is never sent to
+`set_model`. Thinking levels are exposed as the `thinkingLevel` option; the adapter applies
+`set_model` and `set_thinking_level` before each prompt, so `sessionModelSwitch` is `in-session`.
+Native conversation rollback is unsupported.
+
+The resume cursor is `{ schemaVersion: 1, sessionFile }` from `get_state`; a restart passes
+`--session ` so pi reloads its own history, and the thread stays visible to `pi -r`. The
+health check runs `pi --version` and one ephemeral `--no-session` RPC session for `get_state`,
+`get_available_models`, and `get_commands`; an empty model list reports `unauthenticated`. Text
+generation uses `pi -p --no-tools --no-extensions --no-session --no-approve`. Neither opens a
+persistent session, in keeping with the health-check rule above.
diff --git a/docs/user/install.md b/docs/user/install.md
index 17e9291bf1a0..2f0702db57e4 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -75,6 +75,7 @@ computer.
| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. |
| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. |
| Antigravity | Install and sign in with Google from T3 Code's provider settings. |
+| pi | Install [pi](https://pi.dev), then run `pi` and `/login`. |
Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its
**Binary path** in provider settings, especially when using a version manager.
@@ -94,8 +95,8 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa
their original values.
For provider-specific setup and accounts, see [Codex](./providers-codex.md),
-[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and
-[Antigravity](./providers-antigravity.md).
+[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md),
+[Antigravity](./providers-antigravity.md), and [pi](./providers-pi.md).
## Next steps
diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md
index 3d37850c5e20..db8796d2814b 100644
--- a/docs/user/permission-modes.md
+++ b/docs/user/permission-modes.md
@@ -20,7 +20,7 @@ not prevent the agent from asking questions about the task.
Providers enforce permissions differently. Some read-only actions can proceed in **Supervised**.
**Auto** uses automatic review on Codex, Claude, and Cursor; providers without an equivalent,
-including OpenCode and Antigravity, fall back to asking.
+including OpenCode, Antigravity, and pi, fall back to asking.
For Grok, **Always allow this session** remembers the matching command or tool input. Other
actions still require approval.
diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md
new file mode 100644
index 000000000000..93dac1962fdc
--- /dev/null
+++ b/docs/user/providers-pi.md
@@ -0,0 +1,72 @@
+# pi
+
+T3 Code runs the pi installed on the connected environment. With a remote environment, its pi
+setup applies, not the one on your desktop or phone.
+
+## Set up
+
+1. Install pi and log in to at least one model provider: run `pi` in a terminal, then `/login`.
+2. In T3 Code, open **Settings** > **Providers** and turn on **pi**.
+3. Pick a pi model in the composer. Models appear as `provider/model`, and only models with
+ working credentials are listed. **pi default** keeps whatever pi would pick on its own.
+
+If the card says pi has no model with credentials, log in from the terminal and refresh the
+provider status.
+
+## Settings
+
+**Binary path**: the `pi` executable. Leave empty to use the one on `PATH`.
+
+**Agent directory**: a custom `PI_CODING_AGENT_DIR`. Use it to keep a separate set of
+credentials, models, extensions, and sessions for this T3 Code instance.
+
+**Launch arguments**: extra flags for every pi session, for example `-e ./my-extension.ts`.
+
+## Extensions, packages, skills, and models
+
+Everything in your pi setup loads in T3 Code threads: global extensions, installed packages,
+skills, prompt templates, custom providers from `models.json`, and any MCP servers you attach
+through a pi extension. Manage them from the terminal with `pi install` and `pi remove`.
+
+Project-local `.pi/` resources load only if you trusted that folder in pi with `/trust`.
+
+Skills and prompt templates that pi finds for the thread's folder appear under `/` in the
+composer.
+
+## Permission modes
+
+**Full access** runs commands and edits without asking.
+
+**Auto-accept edits** lets file edits through and asks before commands and other tools.
+
+**Supervised** and **Auto** ask before commands, edits, and other tools. Reads never ask.
+pi has no built-in risk reviewer, so **Auto** behaves like **Supervised**.
+
+**Allow for this session** remembers that exact tool and input for the rest of the session.
+Denying a request tells pi the action was declined and lets it continue.
+
+## Questions from the agent
+
+When a pi extension asks you a multiple-choice question, it appears as a question card in the
+thread. Pick an option or type your own answer. Dismissing the card tells the agent you declined.
+The extension has to support this; the `ask_user` extension does.
+
+## Thinking
+
+Reasoning models show a **Thinking** control with the levels pi exposes for that model. Changing
+the model or thinking level applies to the next message without starting a new thread.
+
+## Sessions
+
+Each thread is a pi session. It is saved where pi normally saves sessions, so `pi -r` in the
+terminal lists your T3 Code threads by title, and a restarted server resumes the same history.
+
+## Preview browser
+
+pi threads get the T3 Code preview tools, prefixed `t3_`, alongside pi's own tools.
+
+## Limits
+
+- Images are sent to the model. Other attachments reach pi as file paths.
+- Plan mode and conversation rollback are not available for pi threads.
+- Updates are yours: run `pi update`.
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index bce1a766bc9b..02722bd7146d 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -148,6 +148,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
+const PI_DRIVER_KIND = ProviderDriverKind.make("pi");
export const DEFAULT_MODEL = "gpt-5.6-sol";
@@ -163,6 +164,8 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [
export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";
/** Keep the official Antigravity session's current model. Never send this ID to ACP. */
export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default";
+/** Keep pi's own default model. Never sent to pi's `set_model`. */
+export const PI_DEFAULT_MODEL = "pi-default";
export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low";
export const DEFAULT_MODEL_BY_PROVIDER: Partial> = {
@@ -173,6 +176,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial>
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
[OPENCODE_DRIVER_KIND]: "OpenCode",
+ [PI_DRIVER_KIND]: "pi",
};
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index af1baac74f9d..f663bdf1d1cc 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -28,6 +28,7 @@ const RuntimeEventRawSource = Schema.Union([
Schema.Literal("claude.sdk.permission"),
Schema.Literal("codex.sdk.thread-event"),
Schema.Literal("opencode.sdk.event"),
+ Schema.Literal("pi.rpc"),
Schema.Literal("acp.jsonrpc"),
Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]),
]);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 7e25c8444dbb..5a10df7e98ff 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -627,6 +627,48 @@ export const GrokSettings = makeProviderSettingsSchema(
);
export type GrokSettings = typeof GrokSettings.Type;
+export const PiSettings = makeProviderSettingsSchema(
+ {
+ // Off by default like Cursor, Grok, and OpenCode. Users opt in from Settings.
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(false)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("pi").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description: "Path to the pi binary.",
+ providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" },
+ }),
+ ),
+ agentDir: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Agent directory",
+ description:
+ "Custom PI_CODING_AGENT_DIR. Keeps auth.json, models.json, extensions, and sessions separate.",
+ providerSettingsForm: { placeholder: "~/.pi/agent", clearWhenEmpty: "omit" },
+ }),
+ ),
+ launchArgs: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Launch arguments",
+ description: "Additional CLI arguments passed to pi on session start.",
+ providerSettingsForm: { placeholder: "e.g. -e ./my-extension.ts", clearWhenEmpty: "omit" },
+ }),
+ ),
+ customModels: Schema.Array(CustomModelSetting).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath", "agentDir", "launchArgs"],
+ },
+);
+export type PiSettings = typeof PiSettings.Type;
+
/**
* Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in
* in the browser. The API key and Agent Platform methods take credentials from
@@ -965,6 +1007,7 @@ export const ServerSettings = Schema.Struct({
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
}).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
// New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
// are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -1119,6 +1162,14 @@ const GrokSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
});
+const PiSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ agentDir: Schema.optionalKey(TrimmedString),
+ launchArgs: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
+});
+
const AntigravitySettingsPatch = Schema.Struct({
enabled: Schema.optionalKey(Schema.Boolean),
authMethod: Schema.optionalKey(AntigravityAuthMethod),
@@ -1195,6 +1246,7 @@ export const ServerSettingsPatch = Schema.Struct({
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
antigravity: Schema.optionalKey(AntigravitySettingsPatch),
+ pi: Schema.optionalKey(PiSettingsPatch),
}),
),
// Whole-map replacement for the new instance config. Patching individual