diff --git a/.gitignore b/.gitignore
index ef6067824f2..1a34378912a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,7 @@ node_modules/
*.log
.env*
!.env.example
+
+# pi subagent + analysis scratch (not part of the change)
+.pi-subagents/
+.repos/pi-pr-analysis/
diff --git a/README.md b/README.md
index 6aebfc7e8b8..b2e2a5b3103 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,18 @@
# T3 Code
-T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon).
+T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, OpenCode, and Pi, more coming soon).
## Installation
> [!WARNING]
-> T3 Code currently supports Codex, Claude, Cursor, and OpenCode.
+> T3 Code currently supports Codex, Claude, Cursor, OpenCode, 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`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
+> - Pi: install the Pi CLI and configure a provider API key (Pi is Early Access and disabled by default — see [docs/providers/pi.md](./docs/providers/pi.md))
### Run without installing
@@ -77,7 +78,7 @@ curl -fsSL https://vite.plus | bash
irm https://vite.plus/ps1 | iex
```
-Checkout their getting started guide for more information: https://viteplus.dev/guide/
+Checkout their getting started guide for more information:
### Install dependencies
diff --git a/apps/server/package.json b/apps/server/package.json
index d0903c77d75..79fdf42ea43 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -34,6 +34,7 @@
"node-pty": "^1.1.0"
},
"devDependencies": {
+ "@earendil-works/pi-coding-agent": "^0.80.10",
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts
index 00b6c4cfcce..8a9dc0cd70f 100644
--- a/apps/server/scripts/cli.ts
+++ b/apps/server/scripts/cli.ts
@@ -180,6 +180,17 @@ const buildCmd = Command.make(
const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");
+ // Pi loads the approval-gate extension at runtime; vp pack won't emit it, so copy it next to the bundle.
+ const piExtensionSource = path.join(serverDir, "src/provider/assets/pi/t3-approvals.ts");
+ const piAssetTargetDir = path.join(serverDir, "dist/assets/pi");
+ if (yield* fs.exists(piExtensionSource)) {
+ yield* fs.makeDirectory(piAssetTargetDir, { recursive: true });
+ yield* fs.copyFile(piExtensionSource, path.join(piAssetTargetDir, "t3-approvals.ts"));
+ yield* Effect.log("[cli] Bundled Pi approval extension into dist/assets/pi");
+ } else {
+ return yield* new ServerCliBuildAssetMissingError({ assetPath: piExtensionSource });
+ }
+
if (yield* fs.exists(webDist)) {
yield* fs.copy(webDist, clientTarget);
yield* applyDevelopmentIconOverrides(repoRoot, serverDir);
diff --git a/apps/server/scripts/pi-mock-rpc.ts b/apps/server/scripts/pi-mock-rpc.ts
new file mode 100644
index 00000000000..27cf569e237
--- /dev/null
+++ b/apps/server/scripts/pi-mock-rpc.ts
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+// @effect-diagnostics nodeBuiltinImport:off
+// Fake `pi --mode rpc` for tests; driven by `PI_MOCK_*` env vars.
+import * as NodeReadline from "node:readline";
+
+const assistantText = process.env["PI_MOCK_ASSISTANT_TEXT"] ?? '{"title":"Mock title"}';
+const emitInvalidJson = process.env["PI_MOCK_EMIT_INVALID_JSON"] === "1";
+const lastTextFails = process.env["PI_MOCK_LAST_TEXT_FAILS"] === "1";
+
+const replyText = emitInvalidJson
+ ? "Sure — here is the answer, with no JSON at all."
+ : assistantText;
+let lastAssistantText: string | null = null;
+
+function write(obj: unknown): void {
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
+}
+
+const rl = NodeReadline.createInterface({ input: process.stdin });
+
+rl.on("line", (line: string) => {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+ let command: { type?: string; id?: string };
+ try {
+ command = JSON.parse(trimmed) as { type?: string; id?: string };
+ } catch {
+ return;
+ }
+
+ switch (command.type) {
+ case "prompt":
+ case "steer":
+ case "follow_up": {
+ write({ type: "agent_start" });
+ write({ type: "turn_start" });
+ write({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_delta", delta: replyText },
+ });
+ lastAssistantText = replyText;
+ write({ type: "message_end" });
+ write({ type: "turn_end" });
+ write({ type: "agent_end", willRetry: false });
+ write({ type: "agent_settled" });
+ return;
+ }
+ case "get_last_assistant_text": {
+ write(
+ lastTextFails
+ ? {
+ type: "response",
+ id: command.id,
+ command: "get_last_assistant_text",
+ success: false,
+ error: "no assistant text",
+ }
+ : {
+ type: "response",
+ id: command.id,
+ command: "get_last_assistant_text",
+ success: true,
+ data: { text: lastAssistantText },
+ },
+ );
+ return;
+ }
+ case "get_state": {
+ write({
+ type: "response",
+ id: command.id,
+ command: "get_state",
+ success: true,
+ data: {
+ sessionId: "mock-session",
+ sessionFile: "/tmp/pi-mock-session.json",
+ thinkingLevel: "off",
+ isStreaming: false,
+ isCompacting: false,
+ steeringMode: "all",
+ followUpMode: "all",
+ autoCompactionEnabled: false,
+ messageCount: 0,
+ pendingMessageCount: 0,
+ },
+ });
+ return;
+ }
+ case "get_commands": {
+ write({
+ type: "response",
+ id: command.id,
+ command: "get_commands",
+ success: true,
+ data: { commands: [] },
+ });
+ return;
+ }
+ default: {
+ if (command.id !== undefined) {
+ write({
+ type: "response",
+ id: command.id,
+ command: command.type ?? "unknown",
+ success: true,
+ });
+ }
+ return;
+ }
+ }
+});
+
+rl.on("close", () => {
+ 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 00000000000..c24bc5cac16
--- /dev/null
+++ b/apps/server/src/provider/Drivers/PiDriver.ts
@@ -0,0 +1,169 @@
+import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Duration from "effect/Duration";
+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 { 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 { resolvePiProcessEnv } from "../Layers/PiEnvironment.ts";
+import { buildInitialPiProviderSnapshot, checkPiProviderStatus } from "../Layers/PiProvider.ts";
+import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
+import {
+ defaultProviderContinuationIdentity,
+ type ProviderDriver,
+ type ProviderInstance,
+} from "../ProviderDriver.ts";
+import type { ServerProviderDraft } from "../providerSnapshot.ts";
+import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ makePackageManagedProviderMaintenanceResolver,
+ resolveProviderMaintenanceCapabilitiesEffect,
+} from "../providerMaintenance.ts";
+import {
+ haveProviderSnapshotSettingsChanged,
+ makeProviderSnapshotSettingsSource,
+ type ProviderSnapshotSettings,
+} from "../providerUpdateSettings.ts";
+
+const decodePiSettings = Schema.decodeSync(PiSettings);
+
+const DRIVER_KIND = ProviderDriverKind.make("pi");
+const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
+
+const UPDATE = makePackageManagedProviderMaintenanceResolver({
+ provider: DRIVER_KIND,
+ npmPackageName: "@earendil-works/pi-coding-agent",
+ homebrewFormula: null,
+ nativeUpdate: null,
+});
+
+export type PiDriverEnv =
+ | ChildProcessSpawner.ChildProcessSpawner
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | HttpClient.HttpClient
+ | Path.Path
+ | ServerConfig
+ | ServerSettingsService;
+
+const withInstanceIdentity =
+ (input: {
+ readonly instanceId: ProviderInstance["instanceId"];
+ readonly displayName: string | undefined;
+ readonly accentColor: string | undefined;
+ readonly continuationGroupKey: string;
+ }) =>
+ (snapshot: ServerProviderDraft): ServerProvider => ({
+ ...snapshot,
+ instanceId: input.instanceId,
+ driver: DRIVER_KIND,
+ ...(input.displayName ? { displayName: input.displayName } : {}),
+ ...(input.accentColor ? { accentColor: input.accentColor } : {}),
+ continuation: { groupKey: input.continuationGroupKey },
+ });
+
+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 serverConfig = yield* ServerConfig;
+ const effectiveConfig = { ...config, enabled } satisfies PiSettings;
+ const processEnv = resolvePiProcessEnv(
+ effectiveConfig,
+ mergeProviderInstanceEnvironment(environment),
+ );
+ const continuationIdentity = defaultProviderContinuationIdentity({
+ driverKind: DRIVER_KIND,
+ instanceId,
+ });
+ const stampIdentity = withInstanceIdentity({
+ instanceId,
+ displayName,
+ accentColor,
+ continuationGroupKey: continuationIdentity.continuationKey,
+ });
+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
+ binaryPath: effectiveConfig.binaryPath,
+ env: processEnv,
+ });
+
+ const adapter = yield* makePiAdapter(effectiveConfig, {
+ instanceId,
+ environment: processEnv,
+ });
+ const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv);
+
+ const checkProvider = checkPiProviderStatus(
+ effectiveConfig,
+ serverConfig.cwd,
+ processEnv,
+ ).pipe(
+ Effect.map(stampIdentity),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+
+ const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
+ const snapshot = yield* makeManagedServerProvider>({
+ maintenanceCapabilities,
+ getSettings: snapshotSettings.getSettings,
+ streamSettings: snapshotSettings.streamSettings,
+ haveSettingsChanged: haveProviderSnapshotSettingsChanged,
+ initialSnapshot: (settings) =>
+ buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
+ checkProvider,
+ enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
+ enrichProviderSnapshotWithVersionAdvisory(currentSnapshot, maintenanceCapabilities, {
+ enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
+ }).pipe(
+ Effect.provideService(HttpClient.HttpClient, httpClient),
+ Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Pi version advisory enrichment failed", { cause }).pipe(
+ Effect.asVoid,
+ ),
+ ),
+ ),
+ refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: `Failed to build Pi snapshot: ${cause.message ?? String(cause)}`,
+ cause,
+ }),
+ ),
+ );
+
+ return {
+ instanceId,
+ driverKind: DRIVER_KIND,
+ continuationIdentity,
+ displayName,
+ accentColor,
+ enabled,
+ snapshot,
+ adapter,
+ textGeneration,
+ } satisfies ProviderInstance;
+ }),
+};
diff --git a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts
new file mode 100644
index 00000000000..b3af0ff6155
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts
@@ -0,0 +1,549 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { expect, it } from "@effect/vitest";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Queue from "effect/Queue";
+import * as Ref from "effect/Ref";
+import * as Result from "effect/Result";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+
+import {
+ ApprovalRequestId,
+ PiSettings,
+ ProviderDriverKind,
+ type ProviderRuntimeEvent,
+ ThreadId,
+} from "@t3tools/contracts";
+
+import { ServerConfig } from "../../config.ts";
+import type { PiAdapterShape } from "../Services/PiAdapter.ts";
+import { makePiAdapter } from "./PiAdapter.ts";
+import type {
+ AgentSessionEvent,
+ PiRpcTransport,
+ PiStdoutMessage,
+ RpcCommand,
+ RpcExtensionUIRequest,
+ RpcExtensionUIResponse,
+ RpcResponse,
+} from "./PiRpcClient.ts";
+
+const decodePiSettings = Schema.decodeSync(PiSettings);
+const PI = ProviderDriverKind.make("pi");
+
+const HarnessLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-pi-adapter-integration-",
+}).pipe(Layer.provideMerge(NodeServices.layer));
+
+interface FakePiTransport {
+ readonly transport: PiRpcTransport;
+ readonly commands: Array;
+ readonly extensionResponses: Array;
+ readonly pushEvent: (event: AgentSessionEvent) => Effect.Effect;
+ readonly pushExtensionUI: (request: RpcExtensionUIRequest) => Effect.Effect;
+ readonly setResponse: (commandType: string, response: RpcResponse) => void;
+}
+
+const asResponse = (value: unknown): RpcResponse => value as RpcResponse;
+
+const makeFakePiRpcTransport = Effect.gen(function* () {
+ const messages = yield* Queue.unbounded();
+ const commands: Array = [];
+ const extensionResponses: Array = [];
+ const responses = new Map();
+ responses.set(
+ "get_state",
+ asResponse({
+ type: "response",
+ id: "x",
+ command: "get_state",
+ success: true,
+ data: {
+ sessionFile: "/tmp/pi-session.json",
+ model: { provider: "openai", id: "gpt-4o" },
+ },
+ }),
+ );
+ responses.set(
+ "get_commands",
+ asResponse({
+ type: "response",
+ id: "x",
+ command: "get_commands",
+ success: true,
+ data: { commands: [{ name: "t3-approval-gate", source: "extension" }] },
+ }),
+ );
+
+ const transport: PiRpcTransport = {
+ writeCommand: (command) =>
+ Effect.sync(() => {
+ commands.push(command);
+ }),
+ writeExtensionResponse: (response) =>
+ Effect.sync(() => {
+ extensionResponses.push(response);
+ }),
+ request: (command) => Effect.succeed(responses.get((command as { type: string }).type)),
+ messages,
+ kill: Effect.void,
+ };
+
+ return {
+ transport,
+ commands,
+ extensionResponses,
+ pushEvent: (event) => Queue.offer(messages, { _tag: "event", event }).pipe(Effect.asVoid),
+ pushExtensionUI: (request) =>
+ Queue.offer(messages, { _tag: "extension-ui", request }).pipe(Effect.asVoid),
+ setResponse: (commandType, response) => {
+ responses.set(commandType, response);
+ },
+ } satisfies FakePiTransport;
+});
+
+const makePiAdapterForTest = (settings: PiSettings) =>
+ Effect.gen(function* () {
+ const fake = yield* makeFakePiRpcTransport;
+ const adapter = yield* makePiAdapter(settings, {
+ makeTransport: () => Effect.succeed(fake.transport),
+ });
+ return { adapter, fake } as const;
+ });
+
+const collectEvents = (
+ adapter: PiAdapterShape,
+ threadId: ThreadId,
+ isTerminal: (event: ProviderRuntimeEvent) => boolean,
+) =>
+ Effect.gen(function* () {
+ const store = yield* Ref.make>([]);
+ const fiber = yield* adapter.streamEvents.pipe(
+ Stream.filter((event) => event.threadId === threadId),
+ Stream.takeUntil(isTerminal),
+ Stream.runForEach((event) => Ref.update(store, (events) => [...events, event])),
+ Effect.forkChild,
+ );
+ return { store, fiber } as const;
+ });
+
+const enabledSettings = (overrides: Record = {}) =>
+ decodePiSettings({ enabled: true, ...overrides });
+
+it.layer(HarnessLayer)("PiAdapter integration", (it) => {
+ it.effect("starts a session, streams assistant text, and completes the turn", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-basic");
+ const collected = yield* collectEvents(
+ adapter,
+ threadId,
+ (event) => event.type === "turn.completed",
+ );
+
+ const session = yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ expect(session.provider).toBe("pi");
+ expect(session.status).toBe("ready");
+ expect(session.resumeCursor).toEqual({ sessionFile: "/tmp/pi-session.json" });
+ expect(session.model).toBe("openai/gpt-4o");
+
+ const turn = yield* adapter.sendTurn({ threadId, input: "hello", attachments: [] });
+ expect(turn.turnId).toBeDefined();
+ expect(fake.commands.some((c) => c.type === "prompt")).toBe(true);
+
+ yield* fake.pushEvent({ type: "agent_start" } as AgentSessionEvent);
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ yield* fake.pushEvent({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_delta", delta: "hi" },
+ } as AgentSessionEvent);
+ yield* fake.pushEvent({ type: "agent_end" } as AgentSessionEvent);
+
+ const events = yield* Fiber.join(collected.fiber).pipe(
+ Effect.flatMap(() => Ref.get(collected.store)),
+ );
+ const types = events.map((event) => event.type);
+ expect(types).toContain("session.started");
+ expect(types).toContain("turn.started");
+
+ const turnStarted = events.find((event) => event.type === "turn.started");
+ expect(turnStarted).toBeDefined();
+ if (turnStarted && turnStarted.type === "turn.started") {
+ expect(turnStarted.payload).toEqual({ model: "openai/gpt-4o" });
+ }
+
+ const delta = events.find((event) => event.type === "content.delta");
+ expect(delta).toBeDefined();
+ if (delta && delta.type === "content.delta") {
+ expect(delta.payload.streamKind).toBe("assistant_text");
+ expect(delta.payload.delta).toBe("hi");
+ expect(delta.raw?.source).toBe("pi.rpc.event");
+ }
+ const completed = events.find((event) => event.type === "turn.completed");
+ if (completed && completed.type === "turn.completed") {
+ expect(completed.payload.state).toBe("completed");
+ }
+
+ yield* adapter.stopSession(threadId);
+ expect(yield* adapter.hasSession(threadId)).toBe(false);
+ }),
+ );
+
+ it.effect("maps thinking_delta to a reasoning_text content delta", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-reasoning");
+ const collected = yield* collectEvents(
+ adapter,
+ threadId,
+ (event) => event.type === "turn.completed",
+ );
+ yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "think", attachments: [] });
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ yield* fake.pushEvent({
+ type: "message_update",
+ assistantMessageEvent: { type: "thinking_delta", delta: "why" },
+ } as AgentSessionEvent);
+ yield* fake.pushEvent({ type: "agent_end" } as AgentSessionEvent);
+
+ const events = yield* Fiber.join(collected.fiber).pipe(
+ Effect.flatMap(() => Ref.get(collected.store)),
+ );
+ const reasoning = events.find(
+ (event) => event.type === "content.delta" && event.payload.streamKind === "reasoning_text",
+ );
+ expect(reasoning).toBeDefined();
+ }),
+ );
+
+ it.effect(
+ "does not finalize the turn on agent_end willRetry; completes on the terminal end",
+ () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-retry");
+ const collected = yield* collectEvents(
+ adapter,
+ threadId,
+ (event) => event.type === "turn.completed",
+ );
+ yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "retry please", attachments: [] });
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ yield* fake.pushEvent({
+ type: "agent_end",
+ messages: [],
+ willRetry: true,
+ } as AgentSessionEvent);
+ yield* fake.pushEvent({
+ type: "agent_end",
+ messages: [],
+ willRetry: false,
+ } as AgentSessionEvent);
+
+ const events = yield* Fiber.join(collected.fiber).pipe(
+ Effect.flatMap(() => Ref.get(collected.store)),
+ );
+ const completions = events.filter((event) => event.type === "turn.completed");
+ expect(completions).toHaveLength(1);
+ const completed = completions[0];
+ if (completed && completed.type === "turn.completed") {
+ expect(completed.payload.state).toBe("completed");
+ }
+
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("maps a tool execution lifecycle to item events", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-tool");
+ const collected = yield* collectEvents(
+ adapter,
+ threadId,
+ (event) => event.type === "turn.completed",
+ );
+ yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "run ls", attachments: [] });
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ yield* fake.pushEvent({
+ type: "tool_execution_start",
+ toolCallId: "t1",
+ toolName: "bash",
+ args: { command: "ls" },
+ } as AgentSessionEvent);
+ yield* fake.pushEvent({
+ type: "tool_execution_end",
+ toolCallId: "t1",
+ toolName: "bash",
+ result: "file.txt",
+ isError: false,
+ } as AgentSessionEvent);
+ yield* fake.pushEvent({ type: "agent_end" } as AgentSessionEvent);
+
+ const events = yield* Fiber.join(collected.fiber).pipe(
+ Effect.flatMap(() => Ref.get(collected.store)),
+ );
+ const started = events.find((event) => event.type === "item.started");
+ const completed = events.find((event) => event.type === "item.completed");
+ expect(started).toBeDefined();
+ expect(completed).toBeDefined();
+ if (started && started.type === "item.started") {
+ expect(started.payload.itemType).toBe("command_execution");
+ }
+ }),
+ );
+
+ it.effect("bridges a confirm request to an approval round-trip", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-approval");
+ const store = yield* Ref.make>([]);
+ const opened = yield* Deferred.make();
+ const resolved = yield* Deferred.make();
+ const fiber = yield* adapter.streamEvents.pipe(
+ Stream.filter((event) => event.threadId === threadId),
+ Stream.runForEach((event) =>
+ Effect.gen(function* () {
+ yield* Ref.update(store, (events) => [...events, event]);
+ if (event.type === "request.opened" && event.requestId !== undefined) {
+ yield* Deferred.succeed(opened, ApprovalRequestId.make(String(event.requestId))).pipe(
+ Effect.ignore,
+ );
+ }
+ if (event.type === "request.resolved") {
+ yield* Deferred.succeed(resolved, undefined).pipe(Effect.ignore);
+ }
+ }),
+ ),
+ Effect.forkChild,
+ );
+
+ yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "edit file", attachments: [] });
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ yield* fake.pushExtensionUI({
+ type: "extension_ui_request",
+ id: "ui-1",
+ method: "confirm",
+ title: "Run bash?",
+ message: "ls -la",
+ } as RpcExtensionUIRequest);
+
+ const requestId = yield* Deferred.await(opened);
+ yield* adapter.respondToRequest(threadId, requestId, "accept");
+ yield* Deferred.await(resolved);
+ yield* Fiber.interrupt(fiber);
+
+ const events = yield* Ref.get(store);
+ const requestOpened = events.find((event) => event.type === "request.opened");
+ expect(requestOpened).toBeDefined();
+ if (requestOpened && requestOpened.type === "request.opened") {
+ expect(requestOpened.raw?.source).toBe("pi.rpc.extension-ui");
+ expect(requestOpened.payload.requestType).toBe("command_execution_approval");
+ }
+ expect(fake.extensionResponses).toContainEqual({
+ type: "extension_ui_response",
+ id: "ui-1",
+ confirmed: true,
+ });
+ }),
+ );
+
+ it.effect("bridges a select request to a user-input round-trip", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-userinput");
+ const store = yield* Ref.make>([]);
+ const opened = yield* Deferred.make();
+ const resolved = yield* Deferred.make();
+ const fiber = yield* adapter.streamEvents.pipe(
+ Stream.filter((event) => event.threadId === threadId),
+ Stream.runForEach((event) =>
+ Effect.gen(function* () {
+ yield* Ref.update(store, (events) => [...events, event]);
+ if (event.type === "user-input.requested" && event.requestId !== undefined) {
+ yield* Deferred.succeed(opened, ApprovalRequestId.make(String(event.requestId))).pipe(
+ Effect.ignore,
+ );
+ }
+ if (event.type === "user-input.resolved") {
+ yield* Deferred.succeed(resolved, undefined).pipe(Effect.ignore);
+ }
+ }),
+ ),
+ Effect.forkChild,
+ );
+
+ yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "pick one", attachments: [] });
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ yield* fake.pushExtensionUI({
+ type: "extension_ui_request",
+ id: "ui-2",
+ method: "select",
+ title: "Choose an option",
+ options: ["Option A", "Option B"],
+ } as RpcExtensionUIRequest);
+
+ const requestId = yield* Deferred.await(opened);
+ const events0 = yield* Ref.get(store);
+ const requested = events0.find((event) => event.type === "user-input.requested");
+ expect(requested).toBeDefined();
+ if (requested && requested.type === "user-input.requested") {
+ const questionId = requested.payload.questions[0]?.id;
+ expect(questionId).toBeDefined();
+ yield* adapter.respondToUserInput(threadId, requestId, {
+ [String(questionId)]: "Option A",
+ });
+ }
+ yield* Deferred.await(resolved);
+ yield* Fiber.interrupt(fiber);
+
+ expect(
+ fake.extensionResponses.some(
+ (response) => "value" in response && response.value === "Option A",
+ ),
+ ).toBe(true);
+ }),
+ );
+
+ it.effect("fails closed when the approval gate does not load", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ fake.setResponse(
+ "get_commands",
+ asResponse({
+ type: "response",
+ id: "x",
+ command: "get_commands",
+ success: true,
+ data: { commands: [] },
+ }),
+ );
+ const threadId = ThreadId.make("pi-int-failclosed");
+ const result = yield* adapter
+ .startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "approval-required",
+ })
+ .pipe(Effect.result);
+ expect(Result.isFailure(result)).toBe(true);
+ if (Result.isFailure(result)) {
+ expect(String(result.failure.message)).toMatch(/approval gate|ungated/i);
+ }
+ }),
+ );
+
+ it.effect("rejects startSession when the provider does not match", () =>
+ Effect.gen(function* () {
+ const { adapter } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-mismatch");
+ const result = yield* adapter
+ .startSession({
+ threadId,
+ provider: ProviderDriverKind.make("codex"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.result);
+ expect(Result.isFailure(result)).toBe(true);
+ }),
+ );
+
+ it.effect("steers a running turn instead of opening a second turn", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-steer");
+ const store = yield* Ref.make>([]);
+ const fiber = yield* adapter.streamEvents.pipe(
+ Stream.filter((event) => event.threadId === threadId),
+ Stream.runForEach((event) => Ref.update(store, (events) => [...events, event])),
+ Effect.forkChild,
+ );
+ yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const first = yield* adapter.sendTurn({ threadId, input: "first", attachments: [] });
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ const second = yield* adapter.sendTurn({ threadId, input: "steer me", attachments: [] });
+ expect(second.turnId).toBe(first.turnId);
+
+ yield* fake.pushEvent({ type: "agent_end" } as AgentSessionEvent);
+ yield* Effect.yieldNow;
+ yield* Fiber.interrupt(fiber);
+
+ const events = yield* Ref.get(store);
+ const turnStarts = events.filter((event) => event.type === "turn.started");
+ expect(turnStarts.length).toBe(1);
+ expect(fake.commands.some((command) => command.type === "steer")).toBe(true);
+ }),
+ );
+
+ it.effect("completes a turn on agent_settled without double-completing after agent_end", () =>
+ Effect.gen(function* () {
+ const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
+ const threadId = ThreadId.make("pi-int-settled");
+ const { fiber, store } = yield* collectEvents(
+ adapter,
+ threadId,
+ (event) => event.type === "turn.completed",
+ );
+ yield* adapter.startSession({
+ threadId,
+ provider: PI,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "hello", attachments: [] });
+ yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
+ yield* fake.pushEvent({ type: "agent_end", willRetry: false } as AgentSessionEvent);
+ yield* fake.pushEvent({ type: "agent_settled" } as AgentSessionEvent);
+ yield* Fiber.join(fiber);
+ const completions = (yield* Ref.get(store)).filter(
+ (event) => event.type === "turn.completed",
+ );
+ expect(completions.length).toBe(1);
+ }),
+ );
+});
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 00000000000..be27ea435f3
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiAdapter.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "@effect/vitest";
+import type { ProviderApprovalDecision } from "@t3tools/contracts";
+
+import {
+ buildPiApprovalResponse,
+ buildPiUserInputResponse,
+ classifyPiApprovalRequestType,
+ classifyPiToolItemType,
+ isPiApprovalConfirmed,
+ parseNumberedList,
+ summarizePiToolArgs,
+} from "./PiAdapter.ts";
+
+describe("classifyPiToolItemType", () => {
+ it("maps shell / exec tools to command execution", () => {
+ expect(classifyPiToolItemType("bash")).toBe("command_execution");
+ expect(classifyPiToolItemType("run_shell_command")).toBe("command_execution");
+ expect(classifyPiToolItemType("terminal_exec")).toBe("command_execution");
+ });
+
+ it("maps write / edit / patch tools to file changes", () => {
+ expect(classifyPiToolItemType("write_file")).toBe("file_change");
+ expect(classifyPiToolItemType("apply_patch")).toBe("file_change");
+ expect(classifyPiToolItemType("edit")).toBe("file_change");
+ });
+
+ it("classifies agent, mcp, search, and image tools", () => {
+ expect(classifyPiToolItemType("subagent")).toBe("collab_agent_tool_call");
+ expect(classifyPiToolItemType("task")).toBe("collab_agent_tool_call");
+ expect(classifyPiToolItemType("mcp_call")).toBe("mcp_tool_call");
+ expect(classifyPiToolItemType("web_search")).toBe("web_search");
+ expect(classifyPiToolItemType("view_image")).toBe("image_view");
+ });
+
+ it("falls back to a dynamic tool call for unknown tools", () => {
+ expect(classifyPiToolItemType("something_else")).toBe("dynamic_tool_call");
+ });
+});
+
+describe("classifyPiApprovalRequestType", () => {
+ it("derives the approval request type from the tool hint", () => {
+ expect(classifyPiApprovalRequestType("bash")).toBe("command_execution_approval");
+ expect(classifyPiApprovalRequestType("write_file")).toBe("file_change_approval");
+ });
+
+ it("classifies bundled-gate confirm titles like Run bash?", () => {
+ expect(classifyPiApprovalRequestType("Run bash?")).toBe("command_execution_approval");
+ expect(classifyPiApprovalRequestType("Run write?")).toBe("file_change_approval");
+ expect(classifyPiApprovalRequestType("Run edit?")).toBe("file_change_approval");
+ });
+
+ it("maps non-command/non-file tools to dynamic_tool_call (a surfaced approval)", () => {
+ expect(classifyPiApprovalRequestType("web_search")).toBe("dynamic_tool_call");
+ expect(classifyPiApprovalRequestType("mcp__server__tool")).toBe("dynamic_tool_call");
+ expect(classifyPiApprovalRequestType("some_unknown_tool")).toBe("dynamic_tool_call");
+ });
+});
+
+describe("summarizePiToolArgs", () => {
+ it("prefers the command, then path, then pattern fields", () => {
+ expect(summarizePiToolArgs({ command: "ls -la" })).toBe("ls -la");
+ expect(summarizePiToolArgs({ file_path: "/tmp/x.ts" })).toBe("/tmp/x.ts");
+ expect(summarizePiToolArgs({ query: "find TODOs" })).toBe("find TODOs");
+ });
+
+ it("serializes other objects and ignores non-objects", () => {
+ expect(summarizePiToolArgs({ foo: "bar" })).toBe('{"foo":"bar"}');
+ expect(summarizePiToolArgs(undefined)).toBeUndefined();
+ expect(summarizePiToolArgs("string")).toBeUndefined();
+ });
+});
+
+describe("parseNumberedList", () => {
+ it("parses a title + numbered options", () => {
+ expect(parseNumberedList("Pick one\n1. Alpha\n2. Beta")).toEqual({
+ title: "Pick one",
+ items: [
+ { index: 1, label: "Alpha" },
+ { index: 2, label: "Beta" },
+ ],
+ });
+ });
+
+ it("returns null when fewer than two options are present", () => {
+ expect(parseNumberedList("Just a title\n1. Only")).toBeNull();
+ expect(parseNumberedList("No options here")).toBeNull();
+ });
+});
+
+describe("isPiApprovalConfirmed / buildPiApprovalResponse", () => {
+ it("confirms accept and acceptForSession, rejects everything else", () => {
+ expect(isPiApprovalConfirmed("accept")).toBe(true);
+ expect(isPiApprovalConfirmed("acceptForSession")).toBe(true);
+ expect(isPiApprovalConfirmed("decline")).toBe(false);
+ expect(isPiApprovalConfirmed("cancel")).toBe(false);
+ });
+
+ it("round-trips a confirm request into an extension_ui_response", () => {
+ expect(classifyPiApprovalRequestType("bash")).toBe("command_execution_approval");
+ expect(buildPiApprovalResponse("ui-42", "accept")).toEqual({
+ type: "extension_ui_response",
+ id: "ui-42",
+ confirmed: true,
+ });
+ const decline: ProviderApprovalDecision = "decline";
+ expect(buildPiApprovalResponse("ui-42", decline)).toEqual({
+ type: "extension_ui_response",
+ id: "ui-42",
+ confirmed: false,
+ });
+ });
+});
+
+describe("buildPiUserInputResponse", () => {
+ it("echoes a plain string answer for select / input requests", () => {
+ expect(
+ buildPiUserInputResponse(
+ { piId: "ui-1", questionId: "q1", method: "select" },
+ { q1: "Option A" },
+ ),
+ ).toEqual({ type: "extension_ui_response", id: "ui-1", value: "Option A" });
+ });
+
+ it("maps numbered-list selections back to 1-based comma-joined indices", () => {
+ expect(
+ buildPiUserInputResponse(
+ {
+ piId: "ui-2",
+ questionId: "q2",
+ method: "input",
+ numberedOptions: ["Alpha", "Beta", "Gamma"],
+ },
+ { q2: ["Alpha", "Gamma"] },
+ ),
+ ).toEqual({ type: "extension_ui_response", id: "ui-2", value: "1,3" });
+ });
+
+ it("handles a single numbered selection provided as a string", () => {
+ expect(
+ buildPiUserInputResponse(
+ {
+ piId: "ui-3",
+ questionId: "q3",
+ method: "input",
+ numberedOptions: ["Alpha", "Beta"],
+ },
+ { q3: "Beta" },
+ ),
+ ).toEqual({ type: "extension_ui_response", id: "ui-3", value: "2" });
+ });
+
+ it("returns an empty value when the answer is missing", () => {
+ expect(
+ buildPiUserInputResponse({ piId: "ui-4", questionId: "q4", method: "editor" }, {}),
+ ).toEqual({ type: "extension_ui_response", id: "ui-4", value: "" });
+ });
+});
diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts
new file mode 100644
index 00000000000..34203df322b
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiAdapter.ts
@@ -0,0 +1,1382 @@
+/** `ProviderAdapterShape` for the Pi coding agent (per-thread `pi --mode rpc` sessions). */
+import * as NodeURL from "node:url";
+
+import {
+ ApprovalRequestId,
+ type CanonicalItemType,
+ type CanonicalRequestType,
+ EventId,
+ type ModelSelection,
+ type PiSettings,
+ type ProviderApprovalDecision,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ProviderRuntimeEvent,
+ type ProviderSendTurnInput,
+ type ProviderSession,
+ type ProviderUserInputAnswers,
+ RuntimeItemId,
+ RuntimeRequestId,
+ ThreadId,
+ TurnId,
+ type UserInputQuestion,
+} from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Fiber from "effect/Fiber";
+import * as FileSystem from "effect/FileSystem";
+import * as Queue from "effect/Queue";
+import * as Scope from "effect/Scope";
+import * as Stream from "effect/Stream";
+import type * as PlatformError from "effect/PlatformError";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import { ServerConfig } from "../../config.ts";
+import { resolveAttachmentPath } from "../../attachmentStore.ts";
+import {
+ ProviderAdapterProcessError,
+ ProviderAdapterRequestError,
+ ProviderAdapterSessionClosedError,
+ ProviderAdapterSessionNotFoundError,
+ ProviderAdapterValidationError,
+ type ProviderAdapterError,
+} from "../Errors.ts";
+import type { PiAdapterShape } from "../Services/PiAdapter.ts";
+import {
+ type AgentSessionEvent,
+ buildPiTurnCommand,
+ extractAssistantTextDelta,
+ extractForkMessages,
+ extractReasoningTextDelta,
+ extractSessionFile,
+ extractStateModelSlug,
+ makePiRpcTransport,
+ type MakePiRpcTransportOptions,
+ PI_APPROVAL_SENTINEL_COMMAND,
+ piForkSucceeded,
+ piImageContentFromBytes,
+ type PiImageContent,
+ piResponseHasCommand,
+ piResponseSucceeded,
+ planPiModelSwitch,
+ resolveForkTargetEntryId,
+ resolvePiThinkingLevel,
+ type PiRpcTransport,
+ type PiStdoutMessage,
+ type PiThinkingLevel,
+ type RpcExtensionUIRequest,
+ type RpcExtensionUIResponse,
+} from "./PiRpcClient.ts";
+import { resolvePiProcessEnv } from "./PiEnvironment.ts";
+
+const PROVIDER = ProviderDriverKind.make("pi");
+
+const PI_STATE_TIMEOUT_MS = 5_000;
+const PI_COMMANDS_TIMEOUT_MS = 5_000;
+const PI_MESSAGES_TIMEOUT_MS = 5_000;
+// fork/new_session rebinds to a new session file — give it more headroom
+const PI_FORK_TIMEOUT_MS = 15_000;
+const PI_MODEL_OPTIONS_TIMEOUT_MS = 5_000;
+
+// like Claude/Cursor: full-access runs ungated; approval-required and
+// auto-accept-edits gate via the bundled extension (Pi has no native per-tool approval)
+function approvalGateForRuntimeMode(
+ runtimeMode: ProviderSession["runtimeMode"],
+): { readonly gate: false } | { readonly gate: true; readonly mode: string } {
+ if (runtimeMode === "approval-required" || runtimeMode === "auto-accept-edits") {
+ return { gate: true, mode: runtimeMode };
+ }
+ return { gate: false };
+}
+
+// dev resolves ../assets (running from src); the vp-pack build copies the asset
+// next to the bundle, so prod resolves ./assets
+const APPROVAL_EXTENSION_CANDIDATES: ReadonlyArray = (() => {
+ const resolve = (relative: string): string | undefined => {
+ try {
+ return NodeURL.fileURLToPath(new URL(relative, import.meta.url));
+ } catch {
+ return undefined;
+ }
+ };
+ return [resolve("../assets/pi/t3-approvals.ts"), resolve("./assets/pi/t3-approvals.ts")].filter(
+ (value): value is string => value !== undefined,
+ );
+})();
+
+interface PiToolItem {
+ readonly id: RuntimeItemId;
+ readonly type: CanonicalItemType;
+ readonly toolName: string;
+ args: unknown;
+}
+
+interface PiTurnState {
+ readonly turnId: TurnId;
+ readonly startedAt: string;
+ readonly items: Array;
+}
+
+interface PendingApproval {
+ readonly piId: string;
+ readonly requestType: CanonicalRequestType;
+ readonly sessionApprovalKey: string;
+}
+
+interface NumberedOption {
+ readonly index: number;
+ readonly label: string;
+}
+
+type PendingNumberedOption = string | NumberedOption;
+
+interface PendingUserInput {
+ readonly piId: string;
+ readonly questionId: string;
+ readonly method: "select" | "input" | "editor";
+ readonly numberedOptions?: ReadonlyArray;
+}
+
+interface PiSessionContext {
+ session: ProviderSession;
+ readonly sessionScope: Scope.Closeable;
+ readonly transport: PiRpcTransport;
+ notificationFiber: Fiber.Fiber | undefined;
+ readonly pendingApprovals: Map;
+ readonly pendingUserInputs: Map;
+ readonly sessionApprovals: Set;
+ turnState: PiTurnState | undefined;
+ readonly turns: Array<{ id: TurnId; items: Array }>;
+ stopped: boolean;
+ // slug the pi process is running; used to issue set_model only on change
+ currentModel: string | undefined;
+ appliedThinkingLevel: PiThinkingLevel | undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Pure classification helpers
+// ---------------------------------------------------------------------------
+
+export function classifyPiToolItemType(toolName: string): CanonicalItemType {
+ // whole-token match (split camelCase/separators) so "recommend" isn't read as "command".
+ // also strip punctuation so extension titles like "Run bash?" tokenize to "bash".
+ const tokens = new Set(
+ toolName
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/[._/\-?!,;:()[\]]+/g, " ")
+ .toLowerCase()
+ .split(/\s+/)
+ .filter((token) => token.length > 0),
+ );
+ const has = (...words: ReadonlyArray): boolean => words.some((word) => tokens.has(word));
+
+ if (has("mcp")) return "mcp_tool_call";
+ if (has("agent", "subagent", "task", "skill")) return "collab_agent_tool_call";
+ if (has("bash", "shell", "command", "terminal", "exec")) return "command_execution";
+ if (has("edit", "write", "patch", "apply", "file")) return "file_change";
+ if (has("search", "web")) return "web_search";
+ if (has("image")) return "image_view";
+ return "dynamic_tool_call";
+}
+
+export function classifyPiApprovalRequestType(toolHint: string): CanonicalRequestType {
+ const item = classifyPiToolItemType(toolHint);
+ switch (item) {
+ case "command_execution":
+ return "command_execution_approval";
+ case "file_change":
+ return "file_change_approval";
+ default:
+ // a Pi confirm is a binary gate, not structured input; tool_user_input
+ // would be dropped by the runtime-ingestion + web approval pipeline
+ return "dynamic_tool_call";
+ }
+}
+
+export function summarizePiToolArgs(args: unknown): string | undefined {
+ if (!args || typeof args !== "object") return undefined;
+ const input = args as Record;
+ const command = input["command"] ?? input["cmd"];
+ if (typeof command === "string" && command.trim().length > 0) return command.trim().slice(0, 400);
+ const path = input["file_path"] ?? input["path"] ?? input["filePath"];
+ if (typeof path === "string" && path.trim().length > 0) return path.trim().slice(0, 400);
+ const pattern = input["pattern"] ?? input["query"] ?? input["description"];
+ if (typeof pattern === "string" && pattern.trim().length > 0) return pattern.trim().slice(0, 400);
+ try {
+ const serialized = JSON.stringify(input);
+ return serialized.length <= 400 ? serialized : `${serialized.slice(0, 397)}...`;
+ } catch {
+ return undefined;
+ }
+}
+
+// Pi encodes an RPC multi-select as `Title\n1. A\n2. B`
+export function parseNumberedList(
+ text: string,
+): { title: string; items: ReadonlyArray } | null {
+ const lines = text.split("\n");
+ const items: NumberedOption[] = [];
+ for (const line of lines.slice(1)) {
+ const match = /^(\d+)\.\s+(.+)$/.exec(line.trim());
+ if (match?.[1] && match[2]) items.push({ index: Number(match[1]), label: match[2] });
+ }
+ return items.length >= 2 ? { title: lines[0] ?? text, items } : null;
+}
+
+export function isPiApprovalConfirmed(decision: ProviderApprovalDecision): boolean {
+ return decision === "accept" || decision === "acceptForSession";
+}
+
+export function buildPiApprovalResponse(
+ piId: string,
+ decision: ProviderApprovalDecision,
+): RpcExtensionUIResponse {
+ return { type: "extension_ui_response", id: piId, confirmed: isPiApprovalConfirmed(decision) };
+}
+
+// numbered-list multi-select maps labels back to Pi's 1-based, comma-joined indices
+export function buildPiUserInputResponse(
+ pending: {
+ readonly piId: string;
+ readonly questionId: string;
+ readonly method: "select" | "input" | "editor";
+ readonly numberedOptions?: ReadonlyArray;
+ },
+ answers: ProviderUserInputAnswers,
+): RpcExtensionUIResponse {
+ const answer = answers[pending.questionId];
+ const numberedOptions = pending.numberedOptions;
+ if (pending.method === "input" && numberedOptions) {
+ const selected: ReadonlyArray = Array.isArray(answer)
+ ? answer.map(String)
+ : typeof answer === "string" && answer.length > 0
+ ? [answer]
+ : [];
+ const indices = selected
+ .map((label) => {
+ const optionIndex = numberedOptions.findIndex((entry) =>
+ typeof entry === "string" ? entry === label : entry.label === label,
+ );
+ if (optionIndex < 0) return null;
+ const option = numberedOptions[optionIndex];
+ if (option === undefined) return null;
+ return String(typeof option === "string" ? optionIndex + 1 : option.index);
+ })
+ .filter((value): value is string => value !== null);
+ return { type: "extension_ui_response", id: pending.piId, value: indices.join(",") };
+ }
+ const value = typeof answer === "string" ? answer : "";
+ return { type: "extension_ui_response", id: pending.piId, value };
+}
+
+function toMessage(cause: unknown, fallback: string): string {
+ if (cause instanceof Error && cause.message.length > 0) return cause.message;
+ return fallback;
+}
+
+function readPiResumeState(resumeCursor: unknown): { sessionFile: string } | undefined {
+ if (!resumeCursor || typeof resumeCursor !== "object") return undefined;
+ const cursor = resumeCursor as Record;
+ return typeof cursor["sessionFile"] === "string" && cursor["sessionFile"].trim().length > 0
+ ? { sessionFile: cursor["sessionFile"].trim() }
+ : undefined;
+}
+
+export interface PiAdapterLiveOptions {
+ readonly instanceId?: ProviderInstanceId;
+ readonly environment?: NodeJS.ProcessEnv;
+ // transport override for tests; defaults to the real `pi --mode rpc` spawn
+ readonly makeTransport?: (
+ options: MakePiRpcTransportOptions,
+ ) => Effect.Effect<
+ PiRpcTransport,
+ PlatformError.PlatformError,
+ Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
+ >;
+}
+
+export const makePiAdapter = Effect.fn("makePiAdapter")(function* (
+ piSettings: PiSettings,
+ options?: PiAdapterLiveOptions,
+) {
+ const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("pi");
+ const serverConfig = yield* ServerConfig;
+ const crypto = yield* Crypto.Crypto;
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const fileSystem = yield* FileSystem.FileSystem;
+ // Caller may already resolve PI_CODING_AGENT_DIR; re-applying is idempotent.
+ const baseEnvironment = resolvePiProcessEnv(piSettings, options?.environment ?? process.env);
+
+ let approvalExtensionPath: string | undefined;
+ for (const candidate of APPROVAL_EXTENSION_CANDIDATES) {
+ const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
+ if (exists) {
+ approvalExtensionPath = candidate;
+ break;
+ }
+ }
+ const approvalExtensionAvailable = approvalExtensionPath !== undefined;
+
+ const sessions = new Map();
+ const runtimeEventQueue = yield* Queue.unbounded();
+
+ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+ const nextUuid = crypto.randomUUIDv4.pipe(Effect.orDie);
+ const nextEventId = Effect.map(nextUuid, (id) => EventId.make(id));
+ const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
+
+ const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect =>
+ Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);
+
+ const rawEvent = (
+ source: "pi.rpc.event" | "pi.rpc.extension-ui",
+ method: string,
+ payload: unknown,
+ ) => ({ raw: { source, method, payload } }) as const;
+
+ const completeTurn = (
+ context: PiSessionContext,
+ state: "completed" | "failed" | "interrupted" | "cancelled",
+ errorMessage?: string,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const turnState = context.turnState;
+ if (!turnState) return;
+ context.turnState = undefined;
+ context.turns.push({ id: turnState.turnId, items: [...turnState.items] });
+
+ const updatedAt = yield* nowIso;
+ const { activeTurnId: _activeTurnId, ...readySession } = context.session;
+ context.session = { ...readySession, status: "ready", updatedAt };
+
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ type: "turn.completed",
+ ...stamp,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ turnId: turnState.turnId,
+ payload: {
+ state,
+ ...(errorMessage ? { errorMessage } : {}),
+ },
+ });
+ });
+
+ const openTurn = (context: PiSessionContext): Effect.Effect =>
+ Effect.gen(function* () {
+ const turnId = TurnId.make(yield* nextUuid);
+ const startedAt = yield* nowIso;
+ context.turnState = { turnId, startedAt, items: [] };
+ context.session = {
+ ...context.session,
+ status: "running",
+ activeTurnId: turnId,
+ updatedAt: startedAt,
+ };
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...stamp,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ turnId,
+ type: "turn.started",
+ payload: context.currentModel ? { model: context.currentModel } : {},
+ });
+ return turnId;
+ });
+
+ const handlePiEvent = (
+ context: PiSessionContext,
+ event: AgentSessionEvent,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const stamp = yield* makeEventStamp();
+ const base = {
+ ...stamp,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ ...rawEvent("pi.rpc.event", event.type, event),
+ };
+
+ switch (event.type) {
+ case "agent_start": {
+ yield* offerRuntimeEvent({
+ ...base,
+ type: "session.state.changed",
+ payload: { state: "running" },
+ });
+ return;
+ }
+
+ case "turn_start": {
+ if (!context.turnState) {
+ yield* openTurn(context);
+ }
+ return;
+ }
+
+ case "message_update": {
+ if (!context.turnState) return;
+ const text = extractAssistantTextDelta(event);
+ if (text !== null) {
+ yield* offerRuntimeEvent({
+ ...base,
+ turnId: context.turnState.turnId,
+ type: "content.delta",
+ payload: { streamKind: "assistant_text", delta: text },
+ });
+ return;
+ }
+ const reasoning = extractReasoningTextDelta(event);
+ if (reasoning !== null) {
+ yield* offerRuntimeEvent({
+ ...base,
+ turnId: context.turnState.turnId,
+ type: "content.delta",
+ payload: { streamKind: "reasoning_text", delta: reasoning },
+ });
+ }
+ return;
+ }
+
+ case "tool_execution_start": {
+ if (!context.turnState) return;
+ const itemId = RuntimeItemId.make(event.toolCallId);
+ const itemType = classifyPiToolItemType(event.toolName);
+ const detail = summarizePiToolArgs(event.args);
+ const argsObj =
+ event.args && typeof event.args === "object"
+ ? (event.args as Record)
+ : undefined;
+ context.turnState.items.push({
+ id: itemId,
+ type: itemType,
+ toolName: event.toolName,
+ args: event.args,
+ });
+ yield* offerRuntimeEvent({
+ ...base,
+ turnId: context.turnState.turnId,
+ itemId,
+ type: "item.started",
+ payload: {
+ itemType,
+ title: event.toolName,
+ ...(detail ? { detail } : {}),
+ ...(argsObj ? { data: { toolName: event.toolName, input: argsObj } } : {}),
+ },
+ });
+ return;
+ }
+
+ case "tool_execution_update": {
+ if (!context.turnState) return;
+ const partial = (event as { partialResult?: unknown }).partialResult;
+ if (partial === undefined) return;
+ const itemId = RuntimeItemId.make(event.toolCallId);
+ const itemType = classifyPiToolItemType(event.toolName);
+ const delta = typeof partial === "string" ? partial : String(partial);
+ if (delta.length === 0) return;
+ yield* offerRuntimeEvent({
+ ...base,
+ turnId: context.turnState.turnId,
+ itemId,
+ type: "content.delta",
+ payload: {
+ streamKind:
+ itemType === "command_execution" ? "command_output" : "file_change_output",
+ delta,
+ },
+ });
+ return;
+ }
+
+ case "tool_execution_end": {
+ if (!context.turnState) return;
+ const itemId = RuntimeItemId.make(event.toolCallId);
+ const itemType = classifyPiToolItemType(event.toolName);
+ const storedItem = context.turnState.items.find((item) => item.id === itemId);
+ const detail = summarizePiToolArgs(storedItem?.args);
+ const argsObj =
+ storedItem?.args && typeof storedItem.args === "object"
+ ? (storedItem.args as Record)
+ : undefined;
+ yield* offerRuntimeEvent({
+ ...base,
+ turnId: context.turnState.turnId,
+ itemId,
+ type: "item.completed",
+ payload: {
+ itemType,
+ title: event.toolName,
+ status: event.isError ? "failed" : "completed",
+ ...(detail ? { detail } : {}),
+ ...(argsObj ? { data: { toolName: event.toolName, input: argsObj } } : {}),
+ },
+ });
+ return;
+ }
+
+ case "turn_end": {
+ // agent_end drives completion, not turn_end (pi runs many internal turns per prompt)
+ return;
+ }
+
+ case "agent_end": {
+ // willRetry means pi will auto-retry (another agent_start/end cycle).
+ // Prefer agent_settled (Pi >= 0.80.5) for true idle; keep this as a
+ // fallback for older binaries. completeTurn is idempotent either way.
+ if (event.willRetry) return;
+ if (context.turnState) {
+ yield* completeTurn(context, "completed");
+ }
+ return;
+ }
+
+ case "agent_settled": {
+ // Session-level idle: no retry, compaction retry, or queued follow-up remains.
+ if (context.turnState) {
+ yield* completeTurn(context, "completed");
+ }
+ return;
+ }
+
+ case "compaction_start": {
+ yield* offerRuntimeEvent({
+ ...base,
+ type: "session.state.changed",
+ payload: { state: "waiting", reason: "compaction" },
+ });
+ return;
+ }
+
+ case "compaction_end": {
+ yield* offerRuntimeEvent({
+ ...base,
+ type: "thread.state.changed",
+ payload: { state: "compacted" },
+ });
+ return;
+ }
+
+ default:
+ return;
+ }
+ });
+
+ const handleExtensionUIRequest = (
+ context: PiSessionContext,
+ request: RpcExtensionUIRequest,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ // fire-and-forget UI side-effects — Pi does not await a response
+ if (
+ request.method === "notify" ||
+ request.method === "setStatus" ||
+ request.method === "setWidget" ||
+ request.method === "setTitle" ||
+ request.method === "set_editor_text"
+ ) {
+ return;
+ }
+
+ const stamp = yield* makeEventStamp();
+ const requestId = ApprovalRequestId.make(yield* nextUuid);
+ const runtimeRequestId = RuntimeRequestId.make(requestId);
+ const turnId = context.turnState?.turnId;
+
+ if (request.method === "confirm") {
+ const requestType = classifyPiApprovalRequestType(request.title);
+ const detail =
+ request.message.length > 0 ? `${request.title}\n${request.message}` : request.title;
+ const sessionApprovalKey = `${requestType}:${detail}`;
+ if (context.sessionApprovals.has(sessionApprovalKey)) {
+ yield* context.transport.writeExtensionResponse({
+ type: "extension_ui_response",
+ id: request.id,
+ confirmed: true,
+ });
+ return;
+ }
+ context.pendingApprovals.set(requestId, {
+ piId: request.id,
+ requestType,
+ sessionApprovalKey,
+ });
+ yield* offerRuntimeEvent({
+ ...stamp,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ ...(turnId ? { turnId } : {}),
+ requestId: runtimeRequestId,
+ type: "request.opened",
+ payload: { requestType, detail: detail.slice(0, 2000), args: request },
+ ...rawEvent("pi.rpc.extension-ui", request.method, request),
+ });
+ return;
+ }
+
+ const questionId = String(requestId);
+ let question: UserInputQuestion;
+ let numberedOptions: ReadonlyArray | undefined;
+
+ if (request.method === "select") {
+ question = {
+ id: questionId,
+ header: request.title.slice(0, 12) || "Select",
+ question: request.title,
+ options: request.options.map((label) => ({ label, description: label })),
+ multiSelect: false,
+ };
+ } else {
+ const title = "title" in request ? request.title : "";
+ const parsed = request.method === "input" ? parseNumberedList(title) : null;
+ if (parsed) {
+ numberedOptions = parsed.items;
+ question = {
+ id: questionId,
+ header: parsed.title.slice(0, 12) || "Select",
+ question: parsed.title,
+ options: parsed.items.map((item) => ({ label: item.label, description: item.label })),
+ multiSelect: true,
+ };
+ } else {
+ question = {
+ id: questionId,
+ header: title.slice(0, 12) || "Input",
+ question: title || "Input",
+ options: [],
+ multiSelect: false,
+ };
+ }
+ }
+
+ context.pendingUserInputs.set(requestId, {
+ piId: request.id,
+ questionId,
+ method: request.method,
+ ...(numberedOptions ? { numberedOptions } : {}),
+ });
+
+ yield* offerRuntimeEvent({
+ ...stamp,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ ...(turnId ? { turnId } : {}),
+ requestId: runtimeRequestId,
+ type: "user-input.requested",
+ payload: { questions: [question] },
+ ...rawEvent("pi.rpc.extension-ui", request.method, request),
+ });
+ });
+
+ const handleMessage = (
+ context: PiSessionContext,
+ message: PiStdoutMessage,
+ ): Effect.Effect => {
+ switch (message._tag) {
+ case "event":
+ return handlePiEvent(context, message.event);
+ case "extension-ui":
+ return handleExtensionUIRequest(context, message.request);
+ case "response":
+ return Effect.void;
+ }
+ };
+
+ // settle+clear pending extension-UI requests so Pi is never left blocked
+ const cancelPendingExtensionRequests = (context: PiSessionContext): Effect.Effect =>
+ Effect.gen(function* () {
+ for (const [requestId, pending] of context.pendingApprovals) {
+ yield* Effect.ignore(
+ context.transport.writeExtensionResponse({
+ type: "extension_ui_response",
+ id: pending.piId,
+ confirmed: false,
+ }),
+ );
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...stamp,
+ type: "request.resolved",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ ...(context.turnState ? { turnId: context.turnState.turnId } : {}),
+ requestId: RuntimeRequestId.make(requestId),
+ payload: { requestType: pending.requestType, decision: "decline" },
+ });
+ }
+ context.pendingApprovals.clear();
+ for (const [requestId, pending] of context.pendingUserInputs) {
+ yield* Effect.ignore(
+ context.transport.writeExtensionResponse({
+ type: "extension_ui_response",
+ id: pending.piId,
+ cancelled: true,
+ }),
+ );
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...stamp,
+ type: "user-input.resolved",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ ...(context.turnState ? { turnId: context.turnState.turnId } : {}),
+ requestId: RuntimeRequestId.make(requestId),
+ payload: { answers: {} },
+ });
+ }
+ context.pendingUserInputs.clear();
+ });
+
+ const stopSessionInternal = (
+ context: PiSessionContext,
+ opts?: {
+ readonly emitExitEvent?: boolean;
+ readonly exitKind?: "graceful" | "error";
+ readonly reason?: string;
+ },
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ if (context.stopped) return;
+ context.stopped = true;
+
+ if (context.turnState) {
+ yield* completeTurn(context, "interrupted", "Session stopped.");
+ }
+
+ yield* cancelPendingExtensionRequests(context);
+
+ if (context.notificationFiber) yield* Fiber.interrupt(context.notificationFiber);
+
+ yield* Effect.ignore(Scope.close(context.sessionScope, Exit.void));
+
+ const updatedAt = yield* nowIso;
+ const { activeTurnId: _activeTurnId, ...closedSession } = context.session;
+ context.session = { ...closedSession, status: "closed", updatedAt };
+ sessions.delete(context.session.threadId);
+
+ if (opts?.emitExitEvent !== false) {
+ const exitKind = opts?.exitKind ?? "graceful";
+ const reason =
+ opts?.reason ??
+ (exitKind === "error" ? "Pi process exited unexpectedly." : "Session stopped");
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...stamp,
+ type: "session.exited",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ payload: {
+ reason,
+ exitKind,
+ ...(exitKind === "error" ? { recoverable: false } : {}),
+ },
+ });
+ }
+ });
+
+ const requireSession = (
+ threadId: ThreadId,
+ ): Effect.Effect => {
+ const context = sessions.get(threadId);
+ if (!context) {
+ return Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }));
+ }
+ if (context.stopped || context.session.status === "closed") {
+ return Effect.fail(new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }));
+ }
+ return Effect.succeed(context);
+ };
+
+ // resolve attachments before mutating turn state so a bad one fails cleanly
+ const resolvePromptImages = (
+ attachments: ProviderSendTurnInput["attachments"],
+ ): Effect.Effect, ProviderAdapterError> =>
+ Effect.forEach(attachments ?? [], (attachment) =>
+ Effect.gen(function* () {
+ 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: `Failed to read attachment '${attachment.id}'.`,
+ cause,
+ }),
+ ),
+ );
+ return piImageContentFromBytes({ mimeType: attachment.mimeType, bytes });
+ }),
+ );
+
+ // switch only on change; fail closed (prompt not sent) on a bad slug or rejection
+ const maybeSwitchPiModel = (
+ context: PiSessionContext,
+ requestedModel: string | undefined,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const plan = planPiModelSwitch(context.currentModel, requestedModel);
+ if (plan.kind === "noop") return;
+ if (plan.kind === "invalid") {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "sendTurn",
+ issue: `Invalid Pi model slug '${plan.slug}'; expected 'provider/id'.`,
+ });
+ }
+ const response = yield* context.transport.request(
+ { type: "set_model", provider: plan.provider, modelId: plan.modelId },
+ `pi-set-model-${yield* nextUuid}`,
+ PI_MODEL_OPTIONS_TIMEOUT_MS,
+ );
+ if (!piResponseSucceeded(response, "set_model")) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "set_model",
+ detail: `Pi rejected model switch to '${plan.slug}'.`,
+ });
+ }
+ context.currentModel = plan.slug;
+ context.session = { ...context.session, model: plan.slug };
+ // a model switch can reset the thinking level — force re-apply next turn
+ context.appliedThinkingLevel = undefined;
+ });
+
+ const applyThinkingLevel = (
+ context: PiSessionContext,
+ modelSelection: ModelSelection | null | undefined,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const level = resolvePiThinkingLevel(modelSelection);
+ if (level === undefined || level === context.appliedThinkingLevel) return;
+ const response = yield* context.transport.request(
+ { type: "set_thinking_level", level },
+ `pi-set-thinking-${yield* nextUuid}`,
+ PI_MODEL_OPTIONS_TIMEOUT_MS,
+ );
+ if (piResponseSucceeded(response, "set_thinking_level")) {
+ context.appliedThinkingLevel = level;
+ } else {
+ yield* Effect.logWarning("pi.thinking.set-failed", {
+ threadId: context.session.threadId,
+ level,
+ });
+ }
+ });
+
+ const startSession: PiAdapterShape["startSession"] = Effect.fn("startSession")(function* (input) {
+ if (input.provider !== undefined && input.provider !== PROVIDER) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "startSession",
+ issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
+ });
+ }
+
+ const existing = sessions.get(input.threadId);
+ if (existing) {
+ yield* stopSessionInternal(existing, { emitExitEvent: false }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("pi.session.replace.stop-failed", { threadId: input.threadId, cause }),
+ ),
+ );
+ }
+
+ const startedAt = yield* nowIso;
+ const threadId = input.threadId;
+ const modelSelection =
+ input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId
+ ? input.modelSelection
+ : undefined;
+ const resumeState = readPiResumeState(input.resumeCursor);
+ const cwd = input.cwd ?? serverConfig.cwd;
+ const thinkingLevel = resolvePiThinkingLevel(modelSelection);
+
+ const spawnArgs: string[] = ["--mode", "rpc"];
+ if (resumeState) spawnArgs.push("--session", resumeState.sessionFile);
+ if (modelSelection?.model) spawnArgs.push("--model", modelSelection.model);
+ if (thinkingLevel) spawnArgs.push("--thinking", thinkingLevel);
+
+ // gate driven by runtimeMode; if required, must be provably active or we fail closed
+ const approvalGate = approvalGateForRuntimeMode(input.runtimeMode);
+ let processEnv = baseEnvironment;
+ let verifyApprovalGate = false;
+ if (approvalGate.gate) {
+ if (!approvalExtensionAvailable || !approvalExtensionPath) {
+ return yield* new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId,
+ detail:
+ "Tool approval is required for this runtime mode but the bundled approval gate is unavailable; refusing to start an ungated Pi session.",
+ });
+ }
+ spawnArgs.push("--extension", approvalExtensionPath);
+ processEnv = { ...baseEnvironment, T3_PI_APPROVAL_MODE: approvalGate.mode };
+ verifyApprovalGate = true;
+ }
+
+ const sessionScope = yield* Scope.make();
+
+ const makeTransport = options?.makeTransport ?? makePiRpcTransport;
+ const transport = yield* makeTransport({
+ binaryPath: piSettings.binaryPath || "pi",
+ args: spawnArgs,
+ cwd,
+ env: processEnv,
+ onExit: Effect.suspend(() => {
+ const live = sessions.get(threadId);
+ if (live && !live.stopped && live.session.status !== "closed") {
+ return stopSessionInternal(live, { emitExitEvent: true, exitKind: "error" });
+ }
+ return Effect.void;
+ }),
+ }).pipe(
+ Effect.provideService(Scope.Scope, sessionScope),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId,
+ detail: toMessage(cause, "Failed to start Pi RPC process."),
+ cause,
+ }),
+ ),
+ Effect.onError(() => Effect.ignore(Scope.close(sessionScope, Exit.void))),
+ );
+
+ const session: ProviderSession = {
+ threadId,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ status: "ready",
+ runtimeMode: input.runtimeMode,
+ ...(input.cwd ? { cwd: input.cwd } : {}),
+ ...(modelSelection?.model ? { model: modelSelection.model } : {}),
+ createdAt: startedAt,
+ updatedAt: startedAt,
+ };
+
+ const context: PiSessionContext = {
+ session,
+ sessionScope,
+ transport,
+ notificationFiber: undefined,
+ pendingApprovals: new Map(),
+ pendingUserInputs: new Map(),
+ sessionApprovals: new Set(),
+ turnState: undefined,
+ turns: [],
+ stopped: false,
+ currentModel: modelSelection?.model,
+ appliedThinkingLevel: thinkingLevel,
+ };
+ sessions.set(threadId, context);
+
+ const notificationFiber = yield* Stream.fromQueue(transport.messages).pipe(
+ Stream.mapEffect((message) => handleMessage(context, message)),
+ Stream.runDrain,
+ Effect.catchCause((cause) =>
+ Effect.logError("Failed to process Pi runtime message.", { cause }),
+ ),
+ Effect.forkIn(sessionScope),
+ );
+ context.notificationFiber = notificationFiber;
+
+ const stateResponse = yield* transport.request(
+ { type: "get_state" },
+ `pi-get-state-${yield* nextUuid}`,
+ PI_STATE_TIMEOUT_MS,
+ );
+ const sessionFile = extractSessionFile(stateResponse);
+ // Prefer the caller's model selection; otherwise adopt Pi's active model from get_state
+ // so turn.started can include it even when the user didn't pick an explicit slug.
+ const stateModel = extractStateModelSlug(stateResponse);
+ if (context.currentModel === undefined && stateModel !== undefined) {
+ context.currentModel = stateModel;
+ }
+ context.session = {
+ ...context.session,
+ ...(sessionFile !== undefined ? { resumeCursor: { sessionFile } } : {}),
+ ...(context.currentModel !== undefined ? { model: context.currentModel } : {}),
+ };
+
+ // fail closed unless the gate extension registered its sentinel command
+ if (verifyApprovalGate) {
+ const commandsResponse = yield* transport.request(
+ { type: "get_commands" },
+ `pi-get-commands-${yield* nextUuid}`,
+ PI_COMMANDS_TIMEOUT_MS,
+ );
+ if (!piResponseHasCommand(commandsResponse, PI_APPROVAL_SENTINEL_COMMAND)) {
+ yield* stopSessionInternal(context, { emitExitEvent: false });
+ return yield* new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId,
+ detail:
+ "Tool approval is enabled but the approval gate failed to load; refusing to run an ungated Pi session.",
+ });
+ }
+ }
+
+ const startedStamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...startedStamp,
+ type: "session.started",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId,
+ payload: {},
+ });
+
+ // session file is the provider-native thread id — publish for provider_thread_id parity
+ if (sessionFile !== undefined) {
+ const threadStartedStamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...threadStartedStamp,
+ type: "thread.started",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId,
+ payload: { providerThreadId: sessionFile },
+ });
+ }
+
+ const configuredStamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...configuredStamp,
+ type: "session.configured",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId,
+ payload: {
+ config: {
+ ...(context.currentModel !== undefined ? { model: context.currentModel } : {}),
+ ...(input.cwd ? { cwd: input.cwd } : {}),
+ },
+ },
+ });
+
+ const readyStamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...readyStamp,
+ type: "session.state.changed",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId,
+ payload: { state: "ready" },
+ });
+
+ return { ...context.session };
+ });
+
+ const sendTurn: PiAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
+ const context = yield* requireSession(input.threadId);
+
+ const requestedModel =
+ input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined;
+ const promptText = typeof input.input === "string" ? input.input : "";
+ // resolve before mutating turn state so a bad attachment fails cleanly
+ const images = yield* resolvePromptImages(input.attachments);
+
+ if (promptText.length === 0 && images.length === 0) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "sendTurn",
+ issue: "Pi turns require non-empty text or at least one attachment.",
+ });
+ }
+
+ // a message mid-turn steers the running turn; otherwise it opens a fresh one
+ const isMidTurn = context.turnState !== undefined;
+
+ // only on a fresh turn — changing options mid-stream would race the active turn
+ if (!isMidTurn) {
+ yield* maybeSwitchPiModel(context, requestedModel);
+ yield* applyThinkingLevel(context, input.modelSelection);
+ }
+
+ const turnId = context.turnState?.turnId ?? (yield* openTurn(context));
+
+ yield* context.transport
+ .writeCommand(buildPiTurnCommand({ isMidTurn, message: promptText, images }))
+ .pipe(
+ Effect.catchCause((cause) =>
+ completeTurn(context, "failed", "Failed to send message to Pi.").pipe(
+ Effect.andThen(
+ Effect.fail(
+ new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "prompt",
+ detail: "Failed to send message to Pi.",
+ cause,
+ }),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ return {
+ threadId: context.session.threadId,
+ turnId,
+ ...(context.session.resumeCursor !== undefined
+ ? { resumeCursor: context.session.resumeCursor }
+ : {}),
+ };
+ });
+
+ const interruptTurn: PiAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
+ function* (threadId) {
+ const context = yield* requireSession(threadId);
+ yield* Effect.ignore(context.transport.writeCommand({ type: "abort" }));
+ // settle bridged requests so Pi isn't left blocked (matches Cursor)
+ yield* cancelPendingExtensionRequests(context);
+ if (context.turnState) {
+ yield* completeTurn(context, "interrupted", "Turn interrupted.");
+ }
+ },
+ );
+
+ const respondToRequest: PiAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")(
+ function* (threadId, requestId, decision: ProviderApprovalDecision) {
+ const context = yield* requireSession(threadId);
+ const pending = context.pendingApprovals.get(requestId);
+ if (!pending) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "respondToRequest",
+ detail: `Unknown pending approval request: ${requestId}.`,
+ });
+ }
+ context.pendingApprovals.delete(requestId);
+
+ const response: RpcExtensionUIResponse = buildPiApprovalResponse(pending.piId, decision);
+ yield* context.transport.writeExtensionResponse(response);
+ if (decision === "acceptForSession") {
+ context.sessionApprovals.add(pending.sessionApprovalKey);
+ }
+
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...stamp,
+ type: "request.resolved",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ ...(context.turnState ? { turnId: context.turnState.turnId } : {}),
+ requestId: RuntimeRequestId.make(requestId),
+ payload: { requestType: pending.requestType, decision },
+ });
+ },
+ );
+
+ const respondToUserInput: PiAdapterShape["respondToUserInput"] = Effect.fn("respondToUserInput")(
+ function* (threadId, requestId, answers: ProviderUserInputAnswers) {
+ const context = yield* requireSession(threadId);
+ const pending = context.pendingUserInputs.get(requestId);
+ if (!pending) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "respondToUserInput",
+ detail: `Unknown pending user-input request: ${requestId}.`,
+ });
+ }
+ context.pendingUserInputs.delete(requestId);
+
+ const response: RpcExtensionUIResponse = buildPiUserInputResponse(pending, answers);
+
+ yield* context.transport.writeExtensionResponse(response);
+
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...stamp,
+ type: "user-input.resolved",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId: context.session.threadId,
+ ...(context.turnState ? { turnId: context.turnState.turnId } : {}),
+ requestId: RuntimeRequestId.make(requestId),
+ payload: { answers },
+ });
+ },
+ );
+
+ const readThread: PiAdapterShape["readThread"] = Effect.fn("readThread")(function* (threadId) {
+ const context = yield* requireSession(threadId);
+ return {
+ threadId,
+ turns: context.turns.map((turn) => ({ id: turn.id, items: [...turn.items] })),
+ };
+ });
+
+ const rollbackThread: PiAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
+ function* (threadId, numTurns) {
+ const context = yield* requireSession(threadId);
+
+ if (!Number.isInteger(numTurns) || numTurns < 1) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "rollbackThread",
+ issue: "numTurns must be an integer >= 1.",
+ });
+ }
+
+ // forking mid-stream is undefined — abort/finalize any live turn first
+ if (context.turnState) {
+ yield* Effect.ignore(context.transport.writeCommand({ type: "abort" }));
+ yield* cancelPendingExtensionRequests(context);
+ yield* completeTurn(context, "interrupted", "Turn interrupted for rollback.");
+ }
+
+ const forkResponse = yield* context.transport.request(
+ { type: "get_fork_messages" },
+ `pi-fork-messages-${yield* nextUuid}`,
+ PI_MESSAGES_TIMEOUT_MS,
+ );
+ if (!piResponseSucceeded(forkResponse, "get_fork_messages")) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "get_fork_messages",
+ detail: "Pi did not return forkable messages for rollback.",
+ });
+ }
+ const userMessages = extractForkMessages(forkResponse);
+ const target = resolveForkTargetEntryId(userMessages, numTurns);
+
+ if (target === null) {
+ // no known Pi history to fork against; just trim the local skeleton
+ context.turns.splice(Math.max(0, context.turns.length - numTurns));
+ return yield* readThread(threadId);
+ }
+
+ // fork branches before the target message; new_session resets past the first
+ const rollbackResponse =
+ target.kind === "fork"
+ ? yield* context.transport.request(
+ { type: "fork", entryId: target.entryId },
+ `pi-fork-${yield* nextUuid}`,
+ PI_FORK_TIMEOUT_MS,
+ )
+ : yield* context.transport.request(
+ { type: "new_session" },
+ `pi-new-session-${yield* nextUuid}`,
+ PI_FORK_TIMEOUT_MS,
+ );
+ if (!piForkSucceeded(rollbackResponse)) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: target.kind === "fork" ? "fork" : "new_session",
+ detail: "Pi rejected or cancelled the rollback.",
+ });
+ }
+
+ // CRITICAL: fork/new_session rebinds to a new session file — refresh the
+ // resume cursor or a later reconnect resumes the stale pre-rollback branch
+ const stateResponse = yield* context.transport.request(
+ { type: "get_state" },
+ `pi-get-state-${yield* nextUuid}`,
+ PI_STATE_TIMEOUT_MS,
+ );
+ const sessionFile = extractSessionFile(stateResponse);
+ const updatedAt = yield* nowIso;
+ context.session = {
+ ...context.session,
+ status: "ready",
+ updatedAt,
+ resumeCursor: sessionFile !== undefined ? { sessionFile } : undefined,
+ };
+
+ if (sessionFile !== undefined) {
+ const threadStartedStamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ ...threadStartedStamp,
+ type: "thread.started",
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ threadId,
+ payload: { providerThreadId: sessionFile },
+ });
+ }
+
+ context.turns.splice(Math.max(0, context.turns.length - numTurns));
+
+ return yield* readThread(threadId);
+ },
+ );
+
+ const stopSession: PiAdapterShape["stopSession"] = Effect.fn("stopSession")(function* (threadId) {
+ const context = yield* requireSession(threadId);
+ yield* stopSessionInternal(context, { emitExitEvent: true });
+ });
+
+ const listSessions: PiAdapterShape["listSessions"] = () =>
+ Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
+
+ const hasSession: PiAdapterShape["hasSession"] = (threadId) =>
+ Effect.sync(() => {
+ const context = sessions.get(threadId);
+ return context !== undefined && !context.stopped;
+ });
+
+ const stopAll: PiAdapterShape["stopAll"] = () =>
+ Effect.forEach(
+ sessions,
+ ([, context]) => stopSessionInternal(context, { emitExitEvent: true }),
+ {
+ discard: true,
+ },
+ );
+
+ yield* Effect.addFinalizer(() =>
+ Effect.forEach(
+ sessions,
+ ([, context]) => stopSessionInternal(context, { emitExitEvent: false }),
+ {
+ discard: true,
+ },
+ ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))),
+ );
+
+ return {
+ provider: PROVIDER,
+ capabilities: { sessionModelSwitch: "in-session" as const },
+ startSession,
+ sendTurn,
+ interruptTurn,
+ readThread,
+ rollbackThread,
+ respondToRequest,
+ respondToUserInput,
+ stopSession,
+ listSessions,
+ hasSession,
+ stopAll,
+ get streamEvents() {
+ return Stream.fromQueue(runtimeEventQueue);
+ },
+ } satisfies PiAdapterShape;
+});
diff --git a/apps/server/src/provider/Layers/PiEnvironment.test.ts b/apps/server/src/provider/Layers/PiEnvironment.test.ts
new file mode 100644
index 00000000000..10b071e5ffb
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiEnvironment.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { expandHomePath } from "../../pathExpansion.ts";
+import { resolvePiProcessEnv } from "./PiEnvironment.ts";
+
+describe("resolvePiProcessEnv", () => {
+ it("returns the base env unchanged when codingAgentDir is empty", () => {
+ const base = { PATH: "/usr/bin", PI_CODING_AGENT_DIR: "/existing" };
+ expect(resolvePiProcessEnv({ codingAgentDir: "" }, base)).toBe(base);
+ expect(resolvePiProcessEnv({ codingAgentDir: " " }, base)).toBe(base);
+ });
+
+ it("sets PI_CODING_AGENT_DIR from codingAgentDir, expanding ~", () => {
+ const resolved = resolvePiProcessEnv(
+ { codingAgentDir: "~/.pi/work" },
+ { PATH: "/usr/bin", KEEP: "1" },
+ );
+ expect(resolved).toEqual({
+ PATH: "/usr/bin",
+ KEEP: "1",
+ PI_CODING_AGENT_DIR: expandHomePath("~/.pi/work"),
+ });
+ });
+});
diff --git a/apps/server/src/provider/Layers/PiEnvironment.ts b/apps/server/src/provider/Layers/PiEnvironment.ts
new file mode 100644
index 00000000000..25089ffcb4b
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiEnvironment.ts
@@ -0,0 +1,24 @@
+import type { PiSettings } from "@t3tools/contracts";
+
+import { expandHomePath } from "../../pathExpansion.ts";
+
+/**
+ * Resolve the process environment for a Pi CLI spawn.
+ *
+ * When `codingAgentDir` is set, it becomes `PI_CODING_AGENT_DIR` so Pi loads
+ * auth/models/settings/extensions/packages from that directory — the same
+ * config surface the `pi` CLI uses.
+ */
+export function resolvePiProcessEnv(
+ settings: Pick,
+ baseEnv: NodeJS.ProcessEnv = process.env,
+): NodeJS.ProcessEnv {
+ const codingAgentDir = settings.codingAgentDir.trim();
+ if (codingAgentDir.length === 0) {
+ return baseEnv;
+ }
+ return {
+ ...baseEnv,
+ PI_CODING_AGENT_DIR: expandHomePath(codingAgentDir),
+ };
+}
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 00000000000..a9e5217f5c0
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiProvider.test.ts
@@ -0,0 +1,206 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+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 { PiSettings } from "@t3tools/contracts";
+
+import { buildInitialPiProviderSnapshot, checkPiProviderStatus } from "./PiProvider.ts";
+
+const decodePiSettings = Schema.decodeSync(PiSettings);
+
+// fake `pi`: `--version` exits 0; for RPC, answer get_available_models + get_commands
+// then exit. Responding only after a request avoids racing pending-request registration.
+const healthyPiScript = (modelsJson: string, commandsJson = "[]") =>
+ [
+ "#!/bin/sh",
+ 'if [ "$1" = "--version" ]; then',
+ ' printf "pi 0.80.10\\n"',
+ " exit 0",
+ "fi",
+ "answered_models=0",
+ "answered_commands=0",
+ "while IFS= read -r line; do",
+ ' id=$(printf "%s" "$line" | sed -n \'s/.*"id":"\\([^"]*\\)".*/\\1/p\')',
+ ' [ -z "$id" ] && id="pi-discovery"',
+ ' if printf "%s" "$line" | grep -q \'"type":"get_available_models"\'; then',
+ ` printf '{"type":"response","command":"get_available_models","id":"%s","success":true,"data":{"models":${modelsJson}}}\\n' "$id"`,
+ " answered_models=1",
+ ' elif printf "%s" "$line" | grep -q \'"type":"get_commands"\'; then',
+ ` printf '{"type":"response","command":"get_commands","id":"%s","success":true,"data":{"commands":${commandsJson}}}\\n' "$id"`,
+ " answered_commands=1",
+ " fi",
+ ' if [ "$answered_models" = "1" ] && [ "$answered_commands" = "1" ]; then',
+ " exit 0",
+ " fi",
+ "done",
+ "",
+ ].join("\n");
+
+const HEALTHY_PI_SCRIPT_NO_MODELS = healthyPiScript("[]");
+const HEALTHY_PI_SCRIPT_WITH_MODELS = healthyPiScript(
+ '[{"provider":"openai","id":"gpt-4o"}]',
+ '[{"name":"session-name","description":"Rename session","source":"extension"},{"name":"t3-approval-gate","source":"extension"},{"name":"skill:demo","description":"Demo skill","source":"skill"}]',
+);
+
+describe("buildInitialPiProviderSnapshot", () => {
+ it.effect("returns a disabled snapshot when settings.enabled is false", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* buildInitialPiProviderSnapshot(decodePiSettings({ enabled: false }));
+ expect(snapshot.enabled).toBe(false);
+ expect(snapshot.status).toBe("disabled");
+ expect(snapshot.installed).toBe(false);
+ expect(snapshot.message).toContain("disabled");
+ }),
+ );
+
+ it.effect("returns a pending snapshot when enabled", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* buildInitialPiProviderSnapshot(decodePiSettings({ enabled: true }));
+ expect(snapshot.enabled).toBe(true);
+ expect(snapshot.installed).toBe(true);
+ expect(snapshot.status).toBe("warning");
+ expect(snapshot.version).toBeNull();
+ expect(snapshot.message).toContain("Checking Pi");
+ }),
+ );
+
+ it.effect("appends custom models from settings to the catalog", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* buildInitialPiProviderSnapshot(
+ decodePiSettings({ enabled: true, customModels: ["anthropic/claude-custom"] }),
+ );
+ expect(snapshot.models.map((model) => model.slug)).toContain("anthropic/claude-custom");
+ expect(
+ snapshot.models.find((model) => model.slug === "anthropic/claude-custom")?.isCustom,
+ ).toBe(true);
+ }),
+ );
+});
+
+it.layer(NodeServices.layer)("checkPiProviderStatus", (it) => {
+ it.effect("reports the binary as missing when the binary path does not resolve", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: true, binaryPath: "/definitely/not/installed/pi-binary" }),
+ process.cwd(),
+ );
+ expect(snapshot.enabled).toBe(true);
+ expect(snapshot.installed).toBe(false);
+ expect(snapshot.status).toBe("error");
+ expect(snapshot.message).toMatch(/not installed|not on PATH/);
+ }),
+ );
+
+ it.effect("returns a disabled snapshot without probing when settings.enabled is false", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: false }),
+ process.cwd(),
+ );
+ expect(snapshot.enabled).toBe(false);
+ expect(snapshot.status).toBe("disabled");
+ expect(snapshot.installed).toBe(false);
+ }),
+ );
+
+ it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-pi-version-" });
+ const piPath = path.join(dir, "pi");
+ yield* fs.writeFileString(
+ piPath,
+ ["#!/bin/sh", 'printf "pi error\\n" >&2', "exit 2", ""].join("\n"),
+ );
+ yield* fs.chmod(piPath, 0o755);
+
+ return yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: true, binaryPath: piPath }),
+ dir,
+ );
+ }),
+ );
+
+ expect(snapshot.enabled).toBe(true);
+ expect(snapshot.installed).toBe(true);
+ expect(snapshot.status).toBe("error");
+ expect(typeof snapshot.message).toBe("string");
+ }),
+ );
+
+ it.effect("reports ready/authenticated when discovered models are available", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-pi-ready-" });
+ const piPath = path.join(dir, "pi");
+ yield* fs.writeFileString(piPath, HEALTHY_PI_SCRIPT_WITH_MODELS);
+ yield* fs.chmod(piPath, 0o755);
+ return yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: true, binaryPath: piPath }),
+ dir,
+ );
+ }),
+ );
+ expect(snapshot.status).toBe("ready");
+ expect(snapshot.auth.status).toBe("authenticated");
+ expect(snapshot.models.map((model) => model.slug)).toContain("openai/gpt-4o");
+ expect(snapshot.slashCommands.map((command) => command.name)).toEqual([
+ "session-name",
+ "skill:demo",
+ ]);
+ expect(snapshot.showInteractionModeToggle).toBe(false);
+ }),
+ );
+
+ it.effect("does not treat custom-only models as authenticated", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-pi-custom-only-" });
+ const piPath = path.join(dir, "pi");
+ yield* fs.writeFileString(piPath, HEALTHY_PI_SCRIPT_NO_MODELS);
+ yield* fs.chmod(piPath, 0o755);
+ return yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: true, binaryPath: piPath, customModels: ["x/y"] }),
+ dir,
+ );
+ }),
+ );
+ expect(snapshot.status).toBe("warning");
+ expect(snapshot.auth.status).toBe("unknown");
+ expect(snapshot.models.map((model) => model.slug)).toContain("x/y");
+ }),
+ );
+
+ it.effect("degrades to warning/unknown when the CLI is healthy but no models are available", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-pi-nomodels-" });
+ const piPath = path.join(dir, "pi");
+ yield* fs.writeFileString(piPath, HEALTHY_PI_SCRIPT_NO_MODELS);
+ yield* fs.chmod(piPath, 0o755);
+ return yield* checkPiProviderStatus(
+ decodePiSettings({ enabled: true, binaryPath: piPath }),
+ dir,
+ );
+ }),
+ );
+ expect(snapshot.status).toBe("warning");
+ expect(snapshot.auth.status).toBe("unknown");
+ expect(snapshot.message).toMatch(/no models/i);
+ }),
+ );
+});
diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts
new file mode 100644
index 00000000000..c3f12aa3fc6
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiProvider.ts
@@ -0,0 +1,271 @@
+import {
+ type ModelCapabilities,
+ type PiSettings,
+ type ServerProviderModel,
+ type ServerProviderSlashCommand,
+ ProviderDriverKind,
+} from "@t3tools/contracts";
+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 Option from "effect/Option";
+import * as Result from "effect/Result";
+import { ChildProcess } from "effect/unstable/process";
+
+import {
+ buildServerProvider,
+ DEFAULT_TIMEOUT_MS,
+ detailFromResult,
+ isCommandMissingCause,
+ parseGenericCliVersion,
+ providerModelsFromSettings,
+ spawnAndCollect,
+} from "../providerSnapshot.ts";
+import { resolvePiProcessEnv } from "./PiEnvironment.ts";
+import {
+ extractAvailableModels,
+ extractSlashCommands,
+ makePiRpcTransport,
+ piModelInfoToServerModel,
+} from "./PiRpcClient.ts";
+
+const PROVIDER = ProviderDriverKind.make("pi");
+
+const PI_PRESENTATION = {
+ displayName: "Pi",
+ badgeLabel: "Early Access",
+ // Pi has no T3 interaction-mode mapping (plan/default); hide the unused toggle.
+ showInteractionModeToggle: false,
+} as const;
+
+const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] });
+
+const PI_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
+
+const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+
+const runPiVersion = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) =>
+ Effect.suspend(() => {
+ const binaryPath = piSettings.binaryPath || "pi";
+ return Effect.gen(function* () {
+ const spawnCommand = yield* resolveSpawnCommand(binaryPath, ["--version"], {
+ env: environment,
+ extendEnv: true,
+ });
+ const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ env: environment,
+ extendEnv: true,
+ shell: spawnCommand.shell,
+ });
+ return yield* spawnAndCollect(binaryPath, command);
+ });
+ });
+
+export interface PiCatalogDiscovery {
+ readonly models: ReadonlyArray;
+ readonly slashCommands: ReadonlyArray;
+}
+
+/** Discover models + plugin/extension slash commands via a short-lived `pi --mode rpc` session. */
+export const discoverPiCatalogViaRpc = Effect.fn("discoverPiCatalogViaRpc")(
+ function* (piSettings: PiSettings, cwd: string, environment: NodeJS.ProcessEnv) {
+ const transport = yield* makePiRpcTransport({
+ binaryPath: piSettings.binaryPath || "pi",
+ // Keep extensions enabled so installed plugins/packages contribute commands.
+ args: ["--mode", "rpc", "--no-session"],
+ cwd,
+ env: resolvePiProcessEnv(piSettings, environment),
+ onExit: Effect.void,
+ });
+ const modelsResponse = yield* transport.request(
+ { type: "get_available_models" },
+ "pi-model-discovery",
+ PI_MODEL_DISCOVERY_TIMEOUT_MS,
+ );
+ const commandsResponse = yield* transport.request(
+ { type: "get_commands" },
+ "pi-command-discovery",
+ PI_MODEL_DISCOVERY_TIMEOUT_MS,
+ );
+ return {
+ models: extractAvailableModels(modelsResponse).map(piModelInfoToServerModel),
+ slashCommands: extractSlashCommands(commandsResponse),
+ } satisfies PiCatalogDiscovery;
+ },
+ Effect.scoped,
+ Effect.timeoutOption(PI_MODEL_DISCOVERY_TIMEOUT_MS),
+ Effect.map(
+ Option.getOrElse(
+ () =>
+ ({
+ models: [],
+ slashCommands: [],
+ }) satisfies PiCatalogDiscovery,
+ ),
+ ),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Pi catalog discovery failed", { cause }).pipe(
+ Effect.as({
+ models: [],
+ slashCommands: [],
+ } satisfies PiCatalogDiscovery),
+ ),
+ ),
+);
+
+const modelsFromSettings = (
+ piSettings: PiSettings,
+ discovered: ReadonlyArray,
+): ReadonlyArray =>
+ providerModelsFromSettings(discovered, PROVIDER, piSettings.customModels, EMPTY_CAPABILITIES);
+
+export const buildInitialPiProviderSnapshot = Effect.fn("buildInitialPiProviderSnapshot")(
+ function* (piSettings: PiSettings) {
+ const checkedAt = yield* nowIso;
+ const models = modelsFromSettings(piSettings, []);
+
+ if (!piSettings.enabled) {
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: false,
+ checkedAt,
+ models,
+ probe: {
+ installed: false,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Pi is disabled in T3 Code settings.",
+ },
+ });
+ }
+
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models,
+ probe: {
+ installed: true,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Checking Pi availability...",
+ },
+ });
+ },
+);
+
+export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* (
+ piSettings: PiSettings,
+ cwd: string,
+ environment: NodeJS.ProcessEnv = process.env,
+) {
+ const checkedAt = yield* nowIso;
+ const fallbackModels = modelsFromSettings(piSettings, []);
+ const processEnv = resolvePiProcessEnv(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 versionProbe = yield* runPiVersion(piSettings, processEnv).pipe(
+ Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
+ Effect.result,
+ );
+
+ if (Result.isFailure(versionProbe)) {
+ const error = versionProbe.failure;
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: piSettings.enabled,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: !isCommandMissingCause(error),
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: isCommandMissingCause(error)
+ ? "Pi CLI (`pi`) is not installed or not on PATH."
+ : "Failed to execute Pi CLI health check.",
+ },
+ });
+ }
+
+ if (Option.isNone(versionProbe.success)) {
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: piSettings.enabled,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: "Pi CLI is installed but timed out while running `pi --version`.",
+ },
+ });
+ }
+
+ const version = versionProbe.success.value;
+ const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
+
+ if (version.code !== 0) {
+ const detail = detailFromResult(version);
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: piSettings.enabled,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed: true,
+ version: parsedVersion,
+ status: "error",
+ auth: { status: "unknown" },
+ message: detail ?? "Pi CLI returned an error during health check.",
+ },
+ });
+ }
+
+ // Pass the raw caller env; discoverPiCatalogViaRpc applies codingAgentDir itself.
+ const catalog = yield* discoverPiCatalogViaRpc(piSettings, cwd, environment);
+ const models = modelsFromSettings(piSettings, catalog.models);
+
+ // no auth query in pi; get_available_models only lists once a key is configured in ~/.pi/agent
+ const authenticated = catalog.models.length > 0;
+
+ return buildServerProvider({
+ presentation: PI_PRESENTATION,
+ enabled: piSettings.enabled,
+ checkedAt,
+ models,
+ slashCommands: catalog.slashCommands,
+ probe: {
+ installed: true,
+ version: parsedVersion,
+ status: authenticated ? "ready" : "warning",
+ auth: { status: authenticated ? "authenticated" : "unknown", type: "pi" },
+ ...(authenticated
+ ? {}
+ : {
+ message:
+ "Pi is installed but no models are available. Configure a provider or API key in ~/.pi/agent (e.g. run `pi`) so models appear.",
+ }),
+ },
+ });
+});
diff --git a/apps/server/src/provider/Layers/PiRpcClient.test.ts b/apps/server/src/provider/Layers/PiRpcClient.test.ts
new file mode 100644
index 00000000000..9810aa7edce
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiRpcClient.test.ts
@@ -0,0 +1,641 @@
+import { describe, expect, it } from "@effect/vitest";
+import type { AgentSessionEvent, ModelInfo, RpcResponse } from "@earendil-works/pi-coding-agent";
+
+import {
+ asPiThinkingLevel,
+ buildPiTurnCommand,
+ classifyPiStdoutMessage,
+ extractAssistantTextDelta,
+ extractAvailableModels,
+ extractForkMessages,
+ extractLastAssistantText,
+ extractReasoningTextDelta,
+ extractSessionFile,
+ extractSlashCommands,
+ extractStateModelSlug,
+ parsePiStdoutLine,
+ PI_APPROVAL_SENTINEL_COMMAND,
+ PI_THINKING_LEVEL_VALUES,
+ piForkSucceeded,
+ piImageContentFromBytes,
+ piModelCapabilities,
+ piModelInfoToServerModel,
+ piModelSlug,
+ piResponseHasCommand,
+ piResponseSucceeded,
+ planPiModelSwitch,
+ resolveForkTargetEntryId,
+ resolvePiThinkingLevel,
+ splitPiModelSlug,
+ tryParsePiJsonObject,
+} from "./PiRpcClient.ts";
+
+const asEvent = (value: unknown): AgentSessionEvent => value as AgentSessionEvent;
+const asResponse = (value: unknown): RpcResponse => value as RpcResponse;
+const asModelInfo = (value: unknown): ModelInfo => value as ModelInfo;
+const modelSelectionWithThinking = (value: string | undefined) =>
+ ({
+ instanceId: "pi",
+ model: "openai/gpt-5",
+ options: value === undefined ? [] : [{ id: "thinking", value }],
+ }) as unknown as Parameters[0];
+
+describe("tryParsePiJsonObject", () => {
+ it("parses a JSON object line", () => {
+ expect(tryParsePiJsonObject('{"type":"response","id":"1"}')).toEqual({
+ type: "response",
+ id: "1",
+ });
+ });
+
+ it("ignores blank, non-object, and malformed lines", () => {
+ expect(tryParsePiJsonObject("")).toBeNull();
+ expect(tryParsePiJsonObject(" ")).toBeNull();
+ expect(tryParsePiJsonObject("plain log output")).toBeNull();
+ expect(tryParsePiJsonObject("[1,2,3]")).toBeNull();
+ expect(tryParsePiJsonObject("{ not json }")).toBeNull();
+ });
+});
+
+describe("classifyPiStdoutMessage / parsePiStdoutLine", () => {
+ it("discriminates a response frame and extracts its correlation id", () => {
+ const message = classifyPiStdoutMessage({ type: "response", id: "req-1", success: true });
+ expect(message).toMatchObject({ _tag: "response", id: "req-1" });
+ });
+
+ it("treats a response without a string id as undefined", () => {
+ const message = classifyPiStdoutMessage({ type: "response", success: true });
+ expect(message).toMatchObject({ _tag: "response", id: undefined });
+ });
+
+ it("discriminates an extension_ui_request frame", () => {
+ const message = parsePiStdoutLine(
+ '{"type":"extension_ui_request","id":"ui-1","method":"confirm","title":"bash"}',
+ );
+ expect(message).toMatchObject({ _tag: "extension-ui" });
+ });
+
+ it("treats any other typed frame as an agent event", () => {
+ const message = parsePiStdoutLine('{"type":"agent_start"}');
+ expect(message).toMatchObject({ _tag: "event" });
+ });
+
+ it("ignores frames without a usable type discriminator", () => {
+ expect(classifyPiStdoutMessage({ id: "x" })).toBeNull();
+ expect(classifyPiStdoutMessage({ type: "" })).toBeNull();
+ expect(parsePiStdoutLine("not-json")).toBeNull();
+ });
+});
+
+describe("extractAssistantTextDelta", () => {
+ it("returns the delta for a text_delta assistant message", () => {
+ expect(
+ extractAssistantTextDelta(
+ asEvent({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_delta", delta: "hello" },
+ }),
+ ),
+ ).toBe("hello");
+ });
+
+ it("hides thinking and non-message events", () => {
+ expect(
+ extractAssistantTextDelta(
+ asEvent({
+ type: "message_update",
+ assistantMessageEvent: { type: "thinking_delta", delta: "secret" },
+ }),
+ ),
+ ).toBeNull();
+ expect(extractAssistantTextDelta(asEvent({ type: "agent_start" }))).toBeNull();
+ });
+});
+
+describe("extractReasoningTextDelta", () => {
+ it("returns the delta for a thinking_delta assistant message", () => {
+ expect(
+ extractReasoningTextDelta(
+ asEvent({
+ type: "message_update",
+ assistantMessageEvent: { type: "thinking_delta", delta: "reasoning" },
+ }),
+ ),
+ ).toBe("reasoning");
+ });
+
+ it("returns null for text deltas and other events", () => {
+ expect(
+ extractReasoningTextDelta(
+ asEvent({
+ type: "message_update",
+ assistantMessageEvent: { type: "text_delta", delta: "hi" },
+ }),
+ ),
+ ).toBeNull();
+ expect(extractReasoningTextDelta(asEvent({ type: "turn_start" }))).toBeNull();
+ });
+});
+
+describe("splitPiModelSlug / piModelSlug", () => {
+ it("splits a provider/id slug keeping extra segments in the id", () => {
+ expect(splitPiModelSlug("openai/gpt-4o")).toEqual({ provider: "openai", id: "gpt-4o" });
+ expect(splitPiModelSlug("openrouter/openai/gpt-4o")).toEqual({
+ provider: "openrouter",
+ id: "openai/gpt-4o",
+ });
+ });
+
+ it("returns null when there is no interior separator", () => {
+ expect(splitPiModelSlug("gpt-4o")).toBeNull();
+ expect(splitPiModelSlug("/gpt-4o")).toBeNull();
+ expect(splitPiModelSlug("openai/")).toBeNull();
+ });
+
+ it("round-trips a model into its canonical slug", () => {
+ expect(piModelSlug({ provider: "anthropic", id: "claude-sonnet-4-6" })).toBe(
+ "anthropic/claude-sonnet-4-6",
+ );
+ });
+});
+
+describe("piModelCapabilities", () => {
+ it("exposes a thinking descriptor for reasoning models", () => {
+ const capabilities = piModelCapabilities(true);
+ expect((capabilities.optionDescriptors ?? []).map((descriptor) => descriptor.id)).toContain(
+ "thinking",
+ );
+ });
+
+ it("exposes no option descriptors for non-reasoning models", () => {
+ expect(piModelCapabilities(false).optionDescriptors ?? []).toEqual([]);
+ });
+
+ it("hides xhigh/max unless the model is known to support them", () => {
+ const plain = piModelCapabilities(
+ asModelInfo({ provider: "anthropic", id: "claude-sonnet-4-6", reasoning: true }),
+ );
+ const plainIds = (plain.optionDescriptors ?? []).flatMap((descriptor) =>
+ descriptor.type === "select" ? descriptor.options.map((option) => option.id) : [],
+ );
+ expect(plainIds).not.toContain("xhigh");
+ expect(plainIds).not.toContain("max");
+
+ const extended = piModelCapabilities(
+ asModelInfo({ provider: "openai", id: "gpt-5.6", reasoning: true }),
+ );
+ const extendedIds = (extended.optionDescriptors ?? []).flatMap((descriptor) =>
+ descriptor.type === "select" ? descriptor.options.map((option) => option.id) : [],
+ );
+ expect(extendedIds).toContain("xhigh");
+ expect(extendedIds).toContain("max");
+ });
+});
+
+describe("extractSlashCommands", () => {
+ it("maps plugin/extension/skill commands and strips the approval sentinel", () => {
+ expect(
+ extractSlashCommands(
+ asResponse({
+ type: "response",
+ success: true,
+ data: {
+ commands: [
+ { name: "session-name", description: "Rename", source: "extension" },
+ { name: PI_APPROVAL_SENTINEL_COMMAND, source: "extension" },
+ { name: "/fix-tests", description: "Fix tests", source: "prompt" },
+ { name: "skill:demo", description: "Demo", source: "skill" },
+ { name: "session-name", description: "duplicate", source: "extension" },
+ ],
+ },
+ }),
+ ),
+ ).toEqual([
+ { name: "session-name", description: "Rename" },
+ { name: "fix-tests", description: "Fix tests" },
+ { name: "skill:demo", description: "Demo" },
+ ]);
+ });
+
+ it("returns an empty array for missing or malformed responses", () => {
+ expect(extractSlashCommands(undefined)).toEqual([]);
+ expect(extractSlashCommands(asResponse({ type: "response", success: false }))).toEqual([]);
+ });
+});
+
+describe("piModelInfoToServerModel", () => {
+ it("maps a ModelInfo to a ServerProviderModel with a canonical slug", () => {
+ const model = piModelInfoToServerModel(
+ asModelInfo({
+ provider: "anthropic",
+ id: "claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ reasoning: true,
+ }),
+ );
+ expect(model.slug).toBe("anthropic/claude-sonnet-4-6");
+ expect(model.name).toBe("Claude Sonnet 4.6");
+ expect(model.isCustom).toBe(false);
+ expect(
+ (model.capabilities?.optionDescriptors ?? []).map((descriptor) => descriptor.id),
+ ).toContain("thinking");
+ });
+
+ it("falls back to the model id when no display name is provided", () => {
+ const model = piModelInfoToServerModel(asModelInfo({ provider: "openai", id: "gpt-4o-mini" }));
+ expect(model.name).toBe("gpt-4o-mini");
+ expect(model.capabilities?.optionDescriptors ?? []).toEqual([]);
+ });
+});
+
+describe("extractSessionFile", () => {
+ it("reads the sessionFile path from a successful get_state response", () => {
+ expect(
+ extractSessionFile(
+ asResponse({ type: "response", success: true, data: { sessionFile: " /tmp/s.json " } }),
+ ),
+ ).toBe("/tmp/s.json");
+ });
+
+ it("returns undefined for missing / unsuccessful / empty responses", () => {
+ expect(extractSessionFile(undefined)).toBeUndefined();
+ expect(
+ extractSessionFile(asResponse({ type: "response", success: false, data: {} })),
+ ).toBeUndefined();
+ expect(
+ extractSessionFile(
+ asResponse({ type: "response", success: true, data: { sessionFile: "" } }),
+ ),
+ ).toBeUndefined();
+ });
+});
+
+describe("extractStateModelSlug", () => {
+ it("builds a provider/id slug from get_state model data", () => {
+ expect(
+ extractStateModelSlug(
+ asResponse({
+ type: "response",
+ success: true,
+ data: { model: { provider: "openai", id: "gpt-4o" } },
+ }),
+ ),
+ ).toBe("openai/gpt-4o");
+ });
+
+ it("returns undefined when model is missing, null, or malformed", () => {
+ expect(extractStateModelSlug(undefined)).toBeUndefined();
+ expect(
+ extractStateModelSlug(asResponse({ type: "response", success: true, data: { model: null } })),
+ ).toBeUndefined();
+ expect(
+ extractStateModelSlug(
+ asResponse({ type: "response", success: true, data: { model: { provider: "openai" } } }),
+ ),
+ ).toBeUndefined();
+ });
+});
+
+describe("extractAvailableModels", () => {
+ it("reads the models array from a successful get_available_models response", () => {
+ const models = extractAvailableModels(
+ asResponse({
+ type: "response",
+ success: true,
+ data: { models: [{ provider: "openai", id: "gpt-4o" }] },
+ }),
+ );
+ expect(models).toHaveLength(1);
+ expect(models[0]).toMatchObject({ provider: "openai", id: "gpt-4o" });
+ });
+
+ it("returns an empty array for missing / unsuccessful / malformed responses", () => {
+ expect(extractAvailableModels(undefined)).toEqual([]);
+ expect(extractAvailableModels(asResponse({ type: "response", success: false }))).toEqual([]);
+ expect(
+ extractAvailableModels(
+ asResponse({ type: "response", success: true, data: { models: "nope" } }),
+ ),
+ ).toEqual([]);
+ });
+});
+
+describe("piResponseHasCommand", () => {
+ const commandsResponse = (names: ReadonlyArray) =>
+ asResponse({
+ type: "response",
+ command: "get_commands",
+ success: true,
+ data: { commands: names.map((name) => ({ name, source: "extension" })) },
+ });
+
+ it("detects a registered command by name", () => {
+ expect(
+ piResponseHasCommand(commandsResponse(["t3-approval-gate", "other"]), "t3-approval-gate"),
+ ).toBe(true);
+ });
+
+ it("returns false when the command is absent", () => {
+ expect(piResponseHasCommand(commandsResponse(["other"]), "t3-approval-gate")).toBe(false);
+ });
+
+ it("returns false for undefined, failed, or non-get_commands responses", () => {
+ expect(piResponseHasCommand(undefined, "t3-approval-gate")).toBe(false);
+ expect(
+ piResponseHasCommand(
+ asResponse({ type: "response", command: "get_commands", success: false, error: "boom" }),
+ "t3-approval-gate",
+ ),
+ ).toBe(false);
+ expect(
+ piResponseHasCommand(
+ asResponse({ type: "response", command: "get_state", success: true, data: {} }),
+ "t3-approval-gate",
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("extractLastAssistantText", () => {
+ it("reads the text field from a successful response", () => {
+ expect(
+ extractLastAssistantText(
+ asResponse({
+ type: "response",
+ command: "get_last_assistant_text",
+ success: true,
+ data: { text: "hello" },
+ }),
+ ),
+ ).toBe("hello");
+ });
+
+ it("returns null for null text, failed, or undefined responses", () => {
+ expect(
+ extractLastAssistantText(
+ asResponse({
+ type: "response",
+ command: "get_last_assistant_text",
+ success: true,
+ data: { text: null },
+ }),
+ ),
+ ).toBeNull();
+ expect(extractLastAssistantText(undefined)).toBeNull();
+ expect(
+ extractLastAssistantText(
+ asResponse({
+ type: "response",
+ command: "get_last_assistant_text",
+ success: false,
+ error: "x",
+ }),
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("piImageContentFromBytes", () => {
+ it("encodes bytes as raw base64 with the given mime type", () => {
+ const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
+ expect(piImageContentFromBytes({ mimeType: "image/png", bytes })).toEqual({
+ type: "image",
+ data: Buffer.from(bytes).toString("base64"),
+ mimeType: "image/png",
+ });
+ });
+
+ it("produces raw base64, not a data: URL", () => {
+ const result = piImageContentFromBytes({
+ mimeType: "image/webp",
+ bytes: new Uint8Array([1, 2, 3]),
+ });
+ expect(result.data.startsWith("data:")).toBe(false);
+ });
+});
+
+describe("buildPiTurnCommand", () => {
+ it("uses steer mid-turn so Pi folds the message into the running turn", () => {
+ expect(buildPiTurnCommand({ isMidTurn: true, message: "keep it short" })).toEqual({
+ type: "steer",
+ message: "keep it short",
+ });
+ });
+
+ it("uses prompt for a fresh turn", () => {
+ expect(buildPiTurnCommand({ isMidTurn: false, message: "start work" })).toEqual({
+ type: "prompt",
+ message: "start work",
+ });
+ });
+
+ it("preserves empty messages without switching command type", () => {
+ expect(buildPiTurnCommand({ isMidTurn: true, message: "" })).toEqual({
+ type: "steer",
+ message: "",
+ });
+ });
+
+ it("attaches images only when the array is non-empty", () => {
+ const images = [{ type: "image" as const, data: "AQ==", mimeType: "image/png" }];
+ expect(buildPiTurnCommand({ isMidTurn: false, message: "hi", images })).toEqual({
+ type: "prompt",
+ message: "hi",
+ images,
+ });
+ expect(buildPiTurnCommand({ isMidTurn: false, message: "hi", images: [] })).toEqual({
+ type: "prompt",
+ message: "hi",
+ });
+ });
+});
+
+describe("asPiThinkingLevel / resolvePiThinkingLevel", () => {
+ it("keeps descriptor option ids in sync with the ThinkingLevel set", () => {
+ const descriptorIds = (piModelCapabilities(true).optionDescriptors ?? []).flatMap(
+ (descriptor) => (descriptor.type === "select" ? descriptor.options.map((o) => o.id) : []),
+ );
+ expect(descriptorIds).toEqual([...PI_THINKING_LEVEL_VALUES]);
+ });
+
+ it("resolves each valid thinking level from a model selection", () => {
+ for (const level of PI_THINKING_LEVEL_VALUES) {
+ expect(resolvePiThinkingLevel(modelSelectionWithThinking(level))).toBe(level);
+ expect(asPiThinkingLevel(level)).toBe(level);
+ }
+ });
+
+ it("returns undefined when the thinking option is absent or unknown", () => {
+ expect(resolvePiThinkingLevel(modelSelectionWithThinking(undefined))).toBeUndefined();
+ expect(resolvePiThinkingLevel(undefined)).toBeUndefined();
+ expect(resolvePiThinkingLevel(modelSelectionWithThinking("turbo"))).toBeUndefined();
+ expect(asPiThinkingLevel(undefined)).toBeUndefined();
+ expect(asPiThinkingLevel("")).toBeUndefined();
+ });
+});
+
+describe("planPiModelSwitch", () => {
+ it("is a noop when no model is requested or it matches the current model", () => {
+ expect(planPiModelSwitch("openai/gpt-4o", undefined)).toEqual({ kind: "noop" });
+ expect(planPiModelSwitch("openai/gpt-4o", "openai/gpt-4o")).toEqual({ kind: "noop" });
+ });
+
+ it("plans a switch with split provider/id for a changed slug", () => {
+ expect(planPiModelSwitch("openai/gpt-4o", "anthropic/claude-sonnet-4-6")).toEqual({
+ kind: "switch",
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-6",
+ slug: "anthropic/claude-sonnet-4-6",
+ });
+ expect(planPiModelSwitch(undefined, "openrouter/openai/gpt-4o")).toEqual({
+ kind: "switch",
+ provider: "openrouter",
+ modelId: "openai/gpt-4o",
+ slug: "openrouter/openai/gpt-4o",
+ });
+ });
+
+ it("flags a malformed slug as invalid", () => {
+ expect(planPiModelSwitch("openai/gpt-4o", "gpt-4o")).toEqual({
+ kind: "invalid",
+ slug: "gpt-4o",
+ });
+ });
+});
+
+describe("piResponseSucceeded", () => {
+ it("is true only for a matching successful response", () => {
+ expect(
+ piResponseSucceeded(
+ asResponse({ type: "response", command: "set_model", success: true, data: {} }),
+ "set_model",
+ ),
+ ).toBe(true);
+ });
+
+ it("is false for failed, mismatched-command, non-response, or undefined values", () => {
+ expect(
+ piResponseSucceeded(
+ asResponse({ type: "response", command: "set_model", success: false, error: "no" }),
+ "set_model",
+ ),
+ ).toBe(false);
+ expect(
+ piResponseSucceeded(
+ asResponse({ type: "response", command: "set_thinking_level", success: true }),
+ "set_model",
+ ),
+ ).toBe(false);
+ expect(piResponseSucceeded(undefined, "set_model")).toBe(false);
+ });
+});
+
+describe("extractForkMessages", () => {
+ const forkResponse = (messages: unknown) =>
+ asResponse({
+ type: "response",
+ command: "get_fork_messages",
+ success: true,
+ data: { messages },
+ });
+
+ it("reads ordered user fork entries", () => {
+ const result = extractForkMessages(
+ forkResponse([
+ { entryId: "e1", text: "first" },
+ { entryId: "e2", text: "second" },
+ ]),
+ );
+ expect(result).toEqual([
+ { entryId: "e1", text: "first" },
+ { entryId: "e2", text: "second" },
+ ]);
+ });
+
+ it("defaults missing text to empty string and drops entries without a string entryId", () => {
+ const result = extractForkMessages(
+ forkResponse([{ entryId: "e1" }, { text: "no id" }, { entryId: 42 }]),
+ );
+ expect(result).toEqual([{ entryId: "e1", text: "" }]);
+ });
+
+ it("returns [] for undefined, failed, or non-array responses", () => {
+ expect(extractForkMessages(undefined)).toEqual([]);
+ expect(
+ extractForkMessages(
+ asResponse({ type: "response", command: "get_fork_messages", success: false, error: "x" }),
+ ),
+ ).toEqual([]);
+ expect(extractForkMessages(forkResponse("nope"))).toEqual([]);
+ });
+});
+
+describe("piForkSucceeded", () => {
+ it("is true for success with no cancellation", () => {
+ expect(
+ piForkSucceeded(
+ asResponse({
+ type: "response",
+ command: "fork",
+ success: true,
+ data: { text: "x", cancelled: false },
+ }),
+ ),
+ ).toBe(true);
+ expect(
+ piForkSucceeded(
+ asResponse({
+ type: "response",
+ command: "new_session",
+ success: true,
+ data: { cancelled: false },
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it("is false when cancelled, failed, or undefined", () => {
+ expect(
+ piForkSucceeded(
+ asResponse({
+ type: "response",
+ command: "fork",
+ success: true,
+ data: { text: "", cancelled: true },
+ }),
+ ),
+ ).toBe(false);
+ expect(
+ piForkSucceeded(
+ asResponse({ type: "response", command: "fork", success: false, error: "invalid entry" }),
+ ),
+ ).toBe(false);
+ expect(piForkSucceeded(undefined)).toBe(false);
+ });
+});
+
+describe("resolveForkTargetEntryId", () => {
+ const msgs = (...ids: string[]) => ids.map((entryId) => ({ entryId }));
+
+ it("returns null when there is nothing to roll back", () => {
+ expect(resolveForkTargetEntryId([], 3)).toBeNull();
+ expect(resolveForkTargetEntryId(msgs("a", "b"), 0)).toBeNull();
+ expect(resolveForkTargetEntryId(msgs("a", "b"), -1)).toBeNull();
+ });
+
+ it("forks before the (len-numTurns)th user message", () => {
+ expect(resolveForkTargetEntryId(msgs("a", "b", "c", "d", "e"), 2)).toEqual({
+ kind: "fork",
+ entryId: "d",
+ });
+ expect(resolveForkTargetEntryId(msgs("a", "b", "c", "d"), 1)).toEqual({
+ kind: "fork",
+ entryId: "d",
+ });
+ });
+
+ it("resets to an empty session when rolling back to or past the first message", () => {
+ expect(resolveForkTargetEntryId(msgs("a", "b", "c"), 3)).toEqual({ kind: "reset" });
+ expect(resolveForkTargetEntryId(msgs("a", "b", "c"), 5)).toEqual({ kind: "reset" });
+ });
+});
diff --git a/apps/server/src/provider/Layers/PiRpcClient.ts b/apps/server/src/provider/Layers/PiRpcClient.ts
new file mode 100644
index 00000000000..94247916348
--- /dev/null
+++ b/apps/server/src/provider/Layers/PiRpcClient.ts
@@ -0,0 +1,494 @@
+/** Typed JSONL transport + pure parsing helpers for `pi --mode rpc`. */
+import type {
+ AgentSessionEvent,
+ ModelInfo,
+ RpcCommand,
+ RpcExtensionUIRequest,
+ RpcExtensionUIResponse,
+ RpcResponse,
+} from "@earendil-works/pi-coding-agent";
+import type {
+ ModelCapabilities,
+ ModelSelection,
+ ServerProviderModel,
+ ServerProviderSlashCommand,
+} from "@t3tools/contracts";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Queue from "effect/Queue";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+import { buildSelectOptionDescriptor } from "../providerSnapshot.ts";
+import { createModelCapabilities, getModelSelectionStringOptionValue } from "@t3tools/shared/model";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+
+/** Internal T3 approval-gate sentinel — never surface as a user slash command. */
+export const PI_APPROVAL_SENTINEL_COMMAND = "t3-approval-gate";
+
+export type PiStdoutMessage =
+ | { readonly _tag: "response"; readonly id: string | undefined; readonly response: RpcResponse }
+ | { readonly _tag: "extension-ui"; readonly request: RpcExtensionUIRequest }
+ | { readonly _tag: "event"; readonly event: AgentSessionEvent };
+
+export function tryParsePiJsonObject(text: string): Record | null {
+ const trimmed = text.trim();
+ if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
+ return null;
+ }
+ try {
+ // eslint-disable-next-line no-restricted-syntax -- boundary parse of an untrusted JSONL line
+ const value = JSON.parse(trimmed) as unknown; // @effect-diagnostics-ignore preferSchemaOverJson
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function classifyPiStdoutMessage(msg: Record): PiStdoutMessage | null {
+ const type = msg["type"];
+ if (type === "response") {
+ return {
+ _tag: "response",
+ id: typeof msg["id"] === "string" ? (msg["id"] as string) : undefined,
+ response: msg as unknown as RpcResponse,
+ };
+ }
+ if (type === "extension_ui_request") {
+ if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string") return null;
+ return { _tag: "extension-ui", request: msg as unknown as RpcExtensionUIRequest };
+ }
+ if (typeof type === "string" && type.length > 0) {
+ return { _tag: "event", event: msg as unknown as AgentSessionEvent };
+ }
+ return null;
+}
+
+export function parsePiStdoutLine(line: string): PiStdoutMessage | null {
+ const msg = tryParsePiJsonObject(line);
+ return msg ? classifyPiStdoutMessage(msg) : null;
+}
+
+// only text_delta is user-visible; thinking/toolcall deltas leak raw json
+export function extractAssistantTextDelta(event: AgentSessionEvent): string | null {
+ if (event.type !== "message_update") return null;
+ const assistantEvent = event.assistantMessageEvent;
+ if (!assistantEvent || assistantEvent.type !== "text_delta") return null;
+ return typeof assistantEvent.delta === "string" ? assistantEvent.delta : null;
+}
+
+export function extractReasoningTextDelta(event: AgentSessionEvent): string | null {
+ if (event.type !== "message_update") return null;
+ const assistantEvent = event.assistantMessageEvent;
+ if (!assistantEvent || assistantEvent.type !== "thinking_delta") return null;
+ const delta = (assistantEvent as { delta?: unknown }).delta;
+ return typeof delta === "string" ? delta : null;
+}
+
+// slugs are provider/id; keep any extra "/" in the id
+export function splitPiModelSlug(slug: string): { provider: string; id: string } | null {
+ const trimmed = slug.trim();
+ const idx = trimmed.indexOf("/");
+ if (idx <= 0 || idx >= trimmed.length - 1) return null;
+ return { provider: trimmed.slice(0, idx), id: trimmed.slice(idx + 1) };
+}
+
+export function piModelSlug(model: Pick): string {
+ return `${model.provider}/${model.id}`;
+}
+
+// derived from RpcCommand so it tracks the installed package
+export type PiImageContent = NonNullable["images"]>[number];
+
+export type PiTurnCommand = Extract;
+
+// raw base64, not a data URL
+export function piImageContentFromBytes(input: {
+ readonly mimeType: string;
+ readonly bytes: Uint8Array;
+}): PiImageContent {
+ return {
+ type: "image",
+ data: Buffer.from(input.bytes).toString("base64"),
+ mimeType: input.mimeType,
+ };
+}
+
+// mid-turn must "steer": a bare prompt is rejected while streaming and, being
+// fire-and-forget, would be silently dropped
+export function buildPiTurnCommand(args: {
+ readonly isMidTurn: boolean;
+ readonly message: string;
+ readonly images?: ReadonlyArray;
+}): PiTurnCommand {
+ const hasImages = args.images !== undefined && args.images.length > 0;
+ const images = hasImages ? [...(args.images as ReadonlyArray)] : undefined;
+ return args.isMidTurn
+ ? { type: "steer", message: args.message, ...(images ? { images } : {}) }
+ : { type: "prompt", message: args.message, ...(images ? { images } : {}) };
+}
+
+const PI_THINKING_LEVELS = [
+ { value: "off", label: "Off" },
+ { value: "minimal", label: "Minimal" },
+ { value: "low", label: "Low" },
+ { value: "medium", label: "Medium", isDefault: true },
+ { value: "high", label: "High" },
+ { value: "xhigh", label: "Extra High" },
+ { value: "max", label: "Max" },
+] as const;
+
+const PI_EXTENDED_THINKING_LEVELS = new Set(["xhigh", "max"]);
+
+export type PiThinkingLevel = Extract["level"];
+
+export const PI_THINKING_OPTION_ID = "thinking";
+
+export const PI_THINKING_LEVEL_VALUES = PI_THINKING_LEVELS.map(
+ (level) => level.value,
+) as ReadonlyArray;
+
+const PI_THINKING_LEVEL_SET: ReadonlySet = new Set(PI_THINKING_LEVEL_VALUES);
+
+export function asPiThinkingLevel(value: string | undefined): PiThinkingLevel | undefined {
+ return value !== undefined && PI_THINKING_LEVEL_SET.has(value)
+ ? (value as PiThinkingLevel)
+ : undefined;
+}
+
+export function resolvePiThinkingLevel(
+ modelSelection: ModelSelection | null | undefined,
+): PiThinkingLevel | undefined {
+ return asPiThinkingLevel(
+ getModelSelectionStringOptionValue(modelSelection, PI_THINKING_OPTION_ID),
+ );
+}
+
+export type PiModelSwitchPlan =
+ | { readonly kind: "noop" }
+ | { readonly kind: "invalid"; readonly slug: string }
+ | {
+ readonly kind: "switch";
+ readonly provider: string;
+ readonly modelId: string;
+ readonly slug: string;
+ };
+
+export function planPiModelSwitch(
+ currentModel: string | undefined,
+ requestedModel: string | undefined,
+): PiModelSwitchPlan {
+ if (requestedModel === undefined || requestedModel === currentModel) return { kind: "noop" };
+ const parts = splitPiModelSlug(requestedModel);
+ if (!parts) return { kind: "invalid", slug: requestedModel };
+ return { kind: "switch", provider: parts.provider, modelId: parts.id, slug: requestedModel };
+}
+
+function supportsExtendedPiThinkingLevels(
+ model: boolean | Pick,
+): boolean {
+ if (typeof model === "boolean") return model;
+ if (model.provider !== "openai") return false;
+ const id = model.id.toLowerCase();
+ // Pi exposes xhigh/max only for models that support them (e.g. codex-max, GPT-5.6).
+ return id === "codex-max" || id.includes("gpt-5.6") || id.includes("gpt-5.5");
+}
+
+export function piModelCapabilities(
+ model: boolean | Pick,
+): ModelCapabilities {
+ const reasoning = typeof model === "boolean" ? model : Boolean(model.reasoning);
+ const supportsExtended = supportsExtendedPiThinkingLevels(model);
+ return createModelCapabilities({
+ optionDescriptors: reasoning
+ ? [
+ buildSelectOptionDescriptor({
+ id: "thinking",
+ label: "Thinking",
+ options: PI_THINKING_LEVELS.filter(
+ (level) => !PI_EXTENDED_THINKING_LEVELS.has(level.value) || supportsExtended,
+ ).map((level) => ({ ...level })),
+ }),
+ ]
+ : [],
+ });
+}
+
+export function piModelInfoToServerModel(model: ModelInfo): ServerProviderModel {
+ const slug = piModelSlug(model);
+ const rawName = (model as unknown as { name?: unknown }).name;
+ const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : model.id;
+ return {
+ slug,
+ name,
+ isCustom: false,
+ capabilities: piModelCapabilities(model),
+ };
+}
+
+export function piResponseData(response: RpcResponse | undefined): Record | null {
+ if (!response || response.type !== "response" || response.success !== true) return null;
+ const data = (response as { data?: unknown }).data;
+ return data !== null && typeof data === "object" ? (data as Record) : null;
+}
+
+export function extractSessionFile(response: RpcResponse | undefined): string | undefined {
+ const sessionFile = piResponseData(response)?.["sessionFile"];
+ return typeof sessionFile === "string" && sessionFile.trim().length > 0
+ ? sessionFile.trim()
+ : undefined;
+}
+
+/** `provider/id` slug from a successful `get_state` response, when Pi reports a model. */
+export function extractStateModelSlug(response: RpcResponse | undefined): string | undefined {
+ const model = piResponseData(response)?.["model"];
+ if (!model || typeof model !== "object") return undefined;
+ const record = model as Record;
+ if (typeof record["provider"] !== "string" || typeof record["id"] !== "string") return undefined;
+ const provider = record["provider"].trim();
+ const id = record["id"].trim();
+ if (provider.length === 0 || id.length === 0) return undefined;
+ return piModelSlug({ provider, id });
+}
+
+export function extractAvailableModels(
+ response: RpcResponse | undefined,
+): ReadonlyArray {
+ const models = piResponseData(response)?.["models"];
+ if (!Array.isArray(models)) return [];
+ return models.flatMap((entry) => {
+ if (!entry || typeof entry !== "object") return [];
+ const model = entry as Record;
+ if (typeof model["provider"] !== "string" || typeof model["id"] !== "string") return [];
+ return [model as unknown as ModelInfo];
+ });
+}
+
+// approval-gate handshake: the sentinel command's presence confirms the gate loaded
+export function piResponseHasCommand(
+ response: RpcResponse | undefined,
+ commandName: string,
+): boolean {
+ const commands = piResponseData(response)?.["commands"];
+ if (!Array.isArray(commands)) return false;
+ return commands.some(
+ (entry) =>
+ typeof entry === "object" &&
+ entry !== null &&
+ (entry as Record)["name"] === commandName,
+ );
+}
+
+/**
+ * Map Pi `get_commands` into T3 slash-command catalog entries.
+ * Includes extension commands, prompt templates, and skills from the user's
+ * Pi config/plugins (invoked via `/name` in a prompt).
+ */
+export function extractSlashCommands(
+ response: RpcResponse | undefined,
+): ReadonlyArray {
+ const commands = piResponseData(response)?.["commands"];
+ if (!Array.isArray(commands)) return [];
+
+ const byName = new Map();
+ for (const entry of commands) {
+ if (!entry || typeof entry !== "object") continue;
+ const record = entry as Record;
+ const rawName = typeof record["name"] === "string" ? record["name"].trim() : "";
+ if (rawName.length === 0 || rawName === PI_APPROVAL_SENTINEL_COMMAND) continue;
+ const name = rawName.startsWith("/") ? rawName.slice(1) : rawName;
+ if (name.length === 0 || byName.has(name.toLowerCase())) continue;
+ const description =
+ typeof record["description"] === "string" && record["description"].trim().length > 0
+ ? record["description"].trim()
+ : undefined;
+ byName.set(name.toLowerCase(), {
+ name,
+ ...(description ? { description } : {}),
+ });
+ }
+ return [...byName.values()];
+}
+
+export function extractLastAssistantText(response: RpcResponse | undefined): string | null {
+ const text = piResponseData(response)?.["text"];
+ return typeof text === "string" ? text : null;
+}
+
+// fail-closed: a timeout (undefined) or mismatched command counts as failure
+export function piResponseSucceeded(response: RpcResponse | undefined, command: string): boolean {
+ return (
+ response !== undefined &&
+ response.type === "response" &&
+ response.success === true &&
+ (response as { command?: unknown }).command === command
+ );
+}
+
+// branch-scoped user messages (each with entryId) — the only valid fork targets
+export function extractForkMessages(
+ response: RpcResponse | undefined,
+): ReadonlyArray<{ readonly entryId: string; readonly text: string }> {
+ const messages = piResponseData(response)?.["messages"];
+ if (!Array.isArray(messages)) return [];
+ return messages.flatMap((entry) => {
+ if (!entry || typeof entry !== "object") return [];
+ const record = entry as Record;
+ const entryId = record["entryId"];
+ if (typeof entryId !== "string" || entryId.length === 0) return [];
+ const text = typeof record["text"] === "string" ? (record["text"] as string) : "";
+ return [{ entryId, text }];
+ });
+}
+
+// fail-closed; both fork/new_session return { cancelled } on success
+export function piForkSucceeded(response: RpcResponse | undefined): boolean {
+ if (!response || response.type !== "response" || response.success !== true) return false;
+ return piResponseData(response)?.["cancelled"] !== true;
+}
+
+// linear 1-user-message-per-turn mapping; mid-turn steers can under-drop (deferred)
+export function resolveForkTargetEntryId(
+ userMessages: ReadonlyArray<{ readonly entryId: string }>,
+ numTurns: number,
+): { readonly kind: "fork"; readonly entryId: string } | { readonly kind: "reset" } | null {
+ if (numTurns <= 0 || userMessages.length === 0) return null;
+ const targetIndex = userMessages.length - numTurns;
+ if (targetIndex <= 0) return { kind: "reset" };
+ return { kind: "fork", entryId: userMessages[targetIndex]!.entryId };
+}
+
+// ---------------------------------------------------------------------------
+// Transport
+// ---------------------------------------------------------------------------
+
+export interface PiRpcTransport {
+ readonly writeCommand: (command: RpcCommand) => Effect.Effect;
+ readonly writeExtensionResponse: (response: RpcExtensionUIResponse) => Effect.Effect;
+ // sends a command and awaits its correlated response; times out to `undefined`
+ readonly request: (
+ command: RpcCommand,
+ id: string,
+ timeoutMs: number,
+ ) => Effect.Effect;
+ readonly messages: Queue.Dequeue;
+ readonly kill: Effect.Effect;
+}
+
+export interface MakePiRpcTransportOptions {
+ readonly binaryPath: string;
+ readonly args: ReadonlyArray;
+ readonly cwd: string;
+ readonly env: NodeJS.ProcessEnv;
+ readonly onExit: Effect.Effect;
+}
+
+export const makePiRpcTransport = (options: MakePiRpcTransportOptions) =>
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+
+ const spawnCommand = yield* resolveSpawnCommand(options.binaryPath || "pi", options.args, {
+ env: options.env,
+ extendEnv: true,
+ });
+ const child = yield* spawner.spawn(
+ ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ cwd: options.cwd,
+ env: options.env,
+ extendEnv: true,
+ shell: spawnCommand.shell,
+ forceKillAfter: 5000,
+ }),
+ );
+
+ const outgoing = yield* Queue.unbounded();
+ const messages = yield* Queue.unbounded();
+ const pendingRequests = new Map>();
+ // resolved on process exit to unblock in-flight requests (fail fast, not full timeout)
+ const closed = yield* Deferred.make();
+
+ const writeLine = (obj: RpcCommand | RpcExtensionUIResponse): Effect.Effect =>
+ Queue.offer(outgoing, Buffer.from(`${JSON.stringify(obj)}\n`)).pipe(Effect.asVoid);
+
+ const handleLine = (line: string): Effect.Effect =>
+ Effect.gen(function* () {
+ const message = parsePiStdoutLine(line);
+ if (!message) return;
+ if (message._tag === "response") {
+ if (message.id !== undefined) {
+ const deferred = pendingRequests.get(message.id);
+ if (deferred) {
+ pendingRequests.delete(message.id);
+ yield* Deferred.succeed(deferred, message.response);
+ }
+ }
+ return;
+ }
+ yield* Queue.offer(messages, message);
+ });
+
+ const onProcessExit = Deferred.succeed(closed, undefined).pipe(
+ Effect.andThen(Queue.shutdown(messages)),
+ Effect.andThen(Effect.sync(() => pendingRequests.clear())),
+ Effect.andThen(options.onExit),
+ );
+
+ yield* Stream.fromQueue(outgoing).pipe(
+ Stream.run(child.stdin),
+ Effect.ignore,
+ Effect.forkScoped,
+ );
+
+ // stderr drain (prevents the pipe from blocking)
+ yield* child.stderr.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped);
+
+ yield* child.stdout.pipe(
+ Stream.decodeText(),
+ Stream.splitLines,
+ Stream.runForEach(handleLine),
+ Effect.ignore,
+ Effect.ensuring(onProcessExit),
+ Effect.forkScoped,
+ );
+
+ const request = (
+ command: RpcCommand,
+ id: string,
+ timeoutMs: number,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const deferred = yield* Deferred.make();
+ pendingRequests.set(id, deferred);
+ yield* writeLine({ ...command, id });
+ // resolve on response, process exit, or timeout — whichever comes first
+ const outcome = yield* Deferred.await(deferred).pipe(
+ Effect.map((response) => Option.some(response)),
+ Effect.race(Deferred.await(closed).pipe(Effect.as(Option.none()))),
+ Effect.timeoutOption(timeoutMs),
+ );
+ pendingRequests.delete(id);
+ return outcome._tag === "None" ? undefined : Option.getOrUndefined(outcome.value);
+ });
+
+ const kill = child.kill().pipe(Effect.ignore);
+
+ return {
+ writeCommand: (command) => writeLine(command),
+ writeExtensionResponse: (response) => writeLine(response),
+ request,
+ messages,
+ kill,
+ } satisfies PiRpcTransport;
+ });
+
+export type {
+ AgentSessionEvent,
+ ModelInfo,
+ RpcCommand,
+ RpcExtensionUIRequest,
+ RpcExtensionUIResponse,
+ RpcResponse,
+};
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
index b3ab1145495..aecd6547c95 100644
--- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
@@ -1438,6 +1438,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 00000000000..97ae701f64b
--- /dev/null
+++ b/apps/server/src/provider/Services/PiAdapter.ts
@@ -0,0 +1,4 @@
+import type { ProviderAdapterError } from "../Errors.ts";
+import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
+
+export interface PiAdapterShape extends ProviderAdapterShape {}
diff --git a/apps/server/src/provider/assets/pi/t3-approvals.test.ts b/apps/server/src/provider/assets/pi/t3-approvals.test.ts
new file mode 100644
index 00000000000..c224e57c1f5
--- /dev/null
+++ b/apps/server/src/provider/assets/pi/t3-approvals.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { autoApprovedTools, describeToolCall, gateDecision } from "./t3-approvals.ts";
+
+describe("t3-approvals: autoApprovedTools (default-deny allowlist)", () => {
+ it("auto-approves only read-only tools by default", () => {
+ const allowed = autoApprovedTools(undefined);
+ for (const tool of ["read", "grep", "find", "ls", "glob"]) {
+ expect(allowed.has(tool)).toBe(true);
+ }
+ for (const tool of ["bash", "write", "edit", "multi_edit", "apply_patch"]) {
+ expect(allowed.has(tool)).toBe(false);
+ }
+ });
+
+ it("adds edit tools only in auto-accept-edits mode; bash still gated", () => {
+ const allowed = autoApprovedTools("auto-accept-edits");
+ for (const tool of ["write", "edit", "multi_edit", "apply_patch"]) {
+ expect(allowed.has(tool)).toBe(true);
+ }
+ expect(allowed.has("bash")).toBe(false);
+ expect(allowed.has("read")).toBe(true);
+ });
+
+ it("treats unknown / custom / MCP tools as NOT auto-approved (default-deny)", () => {
+ const allowed = autoApprovedTools("auto-accept-edits");
+ for (const tool of ["foobar", "mcp__server__write", "rm", "move", ""]) {
+ expect(allowed.has(tool)).toBe(false);
+ }
+ });
+});
+
+describe("t3-approvals: gateDecision (fail-closed)", () => {
+ it("blocks when there is no UI to ask", () => {
+ expect(gateDecision({ hasUI: false, confirmed: false })).toEqual({
+ block: true,
+ reason: "Denied in T3 Code",
+ });
+ expect(gateDecision({ hasUI: false, confirmed: true })).toEqual({
+ block: true,
+ reason: "Denied in T3 Code",
+ });
+ });
+
+ it("blocks when the user declines", () => {
+ expect(gateDecision({ hasUI: true, confirmed: false })).toEqual({
+ block: true,
+ reason: "Denied in T3 Code",
+ });
+ });
+
+ it("allows when the user confirms", () => {
+ expect(gateDecision({ hasUI: true, confirmed: true })).toBeUndefined();
+ });
+});
+
+describe("t3-approvals: describeToolCall", () => {
+ it("prefers a command string", () => {
+ expect(describeToolCall("bash", { command: " rm -rf /tmp/x " })).toBe("rm -rf /tmp/x");
+ expect(describeToolCall("bash", { cmd: "echo hi" })).toBe("echo hi");
+ });
+
+ it("falls back to a file path", () => {
+ expect(describeToolCall("write", { file_path: "src/a.ts" })).toBe("src/a.ts");
+ expect(describeToolCall("edit", { path: "src/b.ts" })).toBe("src/b.ts");
+ });
+
+ it("falls back to JSON, then the tool name", () => {
+ expect(describeToolCall("custom", { foo: 1 })).toBe('{"foo":1}');
+ expect(describeToolCall("custom", undefined)).toBe("custom");
+ });
+
+ it("truncates long detail to 500 chars", () => {
+ const long = "x".repeat(1000);
+ expect(describeToolCall("bash", { command: long }).length).toBe(500);
+ });
+});
diff --git a/apps/server/src/provider/assets/pi/t3-approvals.ts b/apps/server/src/provider/assets/pi/t3-approvals.ts
new file mode 100644
index 00000000000..dd11eb6034e
--- /dev/null
+++ b/apps/server/src/provider/assets/pi/t3-approvals.ts
@@ -0,0 +1,67 @@
+// Default-deny tool-approval gate, loaded into `pi --mode rpc` via `--extension`.
+// Runs in the user's `pi` runtime (types-only import).
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+
+const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", "glob"]);
+const EDIT_TOOLS = ["write", "edit", "multi_edit", "apply_patch"];
+
+function autoApprovedTools(approvalMode: string | undefined): ReadonlySet {
+ // approval-required (default): auto-approve non-mutating tools only
+ if (approvalMode === "auto-accept-edits") {
+ return new Set([...READ_ONLY_TOOLS, ...EDIT_TOOLS]);
+ }
+ return READ_ONLY_TOOLS;
+}
+
+function gateDecision(opts: {
+ readonly hasUI: boolean;
+ readonly confirmed: boolean;
+}): { readonly block: true; readonly reason: string } | undefined {
+ if (!opts.hasUI || !opts.confirmed) return { block: true, reason: DENIED_REASON };
+ return undefined;
+}
+
+// keep in sync with PI_APPROVAL_SENTINEL_COMMAND in PiRpcClient.ts
+const SENTINEL_COMMAND = "t3-approval-gate";
+
+const DENIED_REASON = "Denied in T3 Code";
+
+function describeToolCall(toolName: string, input: Record | undefined): string {
+ if (!input) return toolName;
+ const command = input["command"] ?? input["cmd"];
+ if (typeof command === "string" && command.trim().length > 0) {
+ return command.trim().slice(0, 500);
+ }
+ const filePath = input["file_path"] ?? input["path"] ?? input["filePath"];
+ if (typeof filePath === "string" && filePath.trim().length > 0) {
+ return filePath.trim().slice(0, 500);
+ }
+ try {
+ return JSON.stringify(input).slice(0, 500);
+ } catch {
+ return toolName;
+ }
+}
+
+export default function (pi: ExtensionAPI): void {
+ pi.registerCommand(SENTINEL_COMMAND, {
+ description: "T3 Code approval gate (active)",
+ handler: async () => {},
+ });
+
+ const allowed = autoApprovedTools(process.env["T3_PI_APPROVAL_MODE"]);
+
+ pi.on("tool_call", async (event, ctx) => {
+ if (allowed.has(event.toolName)) {
+ return undefined;
+ }
+
+ const input = (event as { input?: Record }).input;
+ const detail = describeToolCall(event.toolName, input);
+ const confirmed = ctx.hasUI ? await ctx.ui.confirm(`Run ${event.toolName}?`, detail) : false;
+
+ return gateDecision({ hasUI: ctx.hasUI, confirmed });
+ });
+}
+
+export { autoApprovedTools, describeToolCall, gateDecision };
diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts
index 791a96e1da3..02f95495da5 100644
--- a/apps/server/src/provider/builtInDrivers.ts
+++ b/apps/server/src/provider/builtInDrivers.ts
@@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
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 { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts";
import type { AnyProviderDriver } from "./ProviderDriver.ts";
/**
@@ -37,7 +38,8 @@ export type BuiltInDriversEnv =
| CodexDriverEnv
| CursorDriverEnv
| GrokDriverEnv
- | OpenCodeDriverEnv;
+ | OpenCodeDriverEnv
+ | PiDriverEnv;
/**
* Ordered list of built-in drivers. Order matters only for tie-breaking in
@@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray {
+ const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-textgen-mock-"));
+ const wrapperPath = NodePath.join(dir, "fake-pi.sh");
+ const script = `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(MOCK_PATH)} "$@"\n`;
+ await NodeFSP.writeFile(wrapperPath, script, "utf8");
+ await NodeFSP.chmod(wrapperPath, 0o755);
+ return wrapperPath;
+}
+
+const withFakePi = (
+ mockEnv: Record,
+ use: (textGeneration: TextGenerationShape) => Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ const wrapperPath = yield* Effect.promise(() => makePiWrapper());
+ const settings = decodePiSettings({ binaryPath: wrapperPath });
+ const environment: NodeJS.ProcessEnv = { ...process.env, ...mockEnv };
+ const textGeneration = yield* makePiTextGeneration(settings, environment);
+ return yield* use(textGeneration);
+ });
+
+it.effect("generateThreadTitle parses the JSON returned via get_last_assistant_text", () =>
+ withFakePi(
+ { PI_MOCK_ASSISTANT_TEXT: '{"title":"Investigate reconnect regressions"}' },
+ (textGeneration) =>
+ Effect.gen(function* () {
+ const result = yield* textGeneration.generateThreadTitle({
+ cwd: process.cwd(),
+ message: "look into reconnect bugs",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ });
+ expect(result.title).toBe("Investigate reconnect regressions");
+ }),
+ ).pipe(Effect.provide(PiTextGenerationTestLayer)),
+);
+
+it.effect("generateCommitMessage sanitizes subject and trims body", () =>
+ withFakePi(
+ { PI_MOCK_ASSISTANT_TEXT: '{"subject":"Add reconnect handling.","body":"- detail\\n"}' },
+ (textGeneration) =>
+ Effect.gen(function* () {
+ const result = yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feature/pi",
+ stagedSummary: "M file.ts",
+ stagedPatch: "@@ -1 +1 @@\n-old\n+new\n",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ });
+ expect(result.subject).toBe("Add reconnect handling");
+ expect(result.body).toBe("- detail");
+ expect(result).not.toHaveProperty("branch");
+ }),
+ ).pipe(Effect.provide(PiTextGenerationTestLayer)),
+);
+
+it.effect("generateBranchName sanitizes the branch fragment", () =>
+ withFakePi({ PI_MOCK_ASSISTANT_TEXT: '{"branch":" Feat/Session "}' }, (textGeneration) =>
+ Effect.gen(function* () {
+ const result = yield* textGeneration.generateBranchName({
+ cwd: process.cwd(),
+ message: "please update session handling",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ });
+ expect(result.branch).toBe("feat/session");
+ }),
+ ).pipe(Effect.provide(PiTextGenerationTestLayer)),
+);
+
+it.effect("generatePrContent sanitizes title and body", () =>
+ withFakePi(
+ {
+ PI_MOCK_ASSISTANT_TEXT: '{"title":"Improve reconnect flow","body":"## Summary\\n- x\\n\\n"}',
+ },
+ (textGeneration) =>
+ Effect.gen(function* () {
+ const result = yield* textGeneration.generatePrContent({
+ cwd: process.cwd(),
+ baseBranch: "main",
+ headBranch: "feature/pi",
+ commitSummary: "one commit",
+ diffSummary: "file.ts | 2 +-",
+ diffPatch: "@@ -1 +1 @@\n-old\n+new\n",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ });
+ expect(result.title).toBe("Improve reconnect flow");
+ expect(result.body).toBe("## Summary\n- x");
+ }),
+ ).pipe(Effect.provide(PiTextGenerationTestLayer)),
+);
+
+it.effect("tolerates JSON wrapped in a markdown code fence", () =>
+ withFakePi({ PI_MOCK_ASSISTANT_TEXT: '```json {"title":"Fenced title"} ```' }, (textGeneration) =>
+ Effect.gen(function* () {
+ const result = yield* textGeneration.generateThreadTitle({
+ cwd: process.cwd(),
+ message: "anything",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ });
+ expect(result.title).toBe("Fenced title");
+ }),
+ ).pipe(Effect.provide(PiTextGenerationTestLayer)),
+);
+
+it.effect("fails with a TextGenerationError when Pi returns non-JSON prose", () =>
+ withFakePi({ PI_MOCK_EMIT_INVALID_JSON: "1" }, (textGeneration) =>
+ Effect.gen(function* () {
+ const result = yield* textGeneration
+ .generateThreadTitle({
+ cwd: process.cwd(),
+ message: "anything",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ })
+ .pipe(Effect.result);
+ expect(Result.isFailure(result)).toBe(true);
+ if (Result.isFailure(result)) {
+ expect(result.failure).toBeInstanceOf(TextGenerationError);
+ expect(result.failure.message).toContain("invalid structured output");
+ }
+ }),
+ ).pipe(Effect.provide(PiTextGenerationTestLayer)),
+);
+
+it.effect("fails with a TextGenerationError when the assistant text is unavailable", () =>
+ withFakePi({ PI_MOCK_LAST_TEXT_FAILS: "1" }, (textGeneration) =>
+ Effect.gen(function* () {
+ const result = yield* textGeneration
+ .generateThreadTitle({
+ cwd: process.cwd(),
+ message: "anything",
+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
+ })
+ .pipe(Effect.result);
+ expect(Result.isFailure(result)).toBe(true);
+ if (Result.isFailure(result)) {
+ expect(result.failure).toBeInstanceOf(TextGenerationError);
+ }
+ }),
+ ).pipe(Effect.provide(PiTextGenerationTestLayer)),
+);
diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts
new file mode 100644
index 00000000000..5e405fe53a2
--- /dev/null
+++ b/apps/server/src/textGeneration/PiTextGeneration.ts
@@ -0,0 +1,255 @@
+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 { ChildProcessSpawner } from "effect/unstable/process";
+
+import { type ModelSelection, type PiSettings, TextGenerationError } from "@t3tools/contracts";
+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
+import { extractJsonObject } from "@t3tools/shared/schemaJson";
+
+import { resolvePiProcessEnv } from "../provider/Layers/PiEnvironment.ts";
+import {
+ extractLastAssistantText,
+ makePiRpcTransport,
+ resolvePiThinkingLevel,
+} from "../provider/Layers/PiRpcClient.ts";
+import * as TextGeneration from "./TextGeneration.ts";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+ buildThreadTitlePrompt,
+} from "./TextGenerationPrompts.ts";
+import {
+ sanitizeCommitSubject,
+ sanitizePrTitle,
+ sanitizeThreadTitle,
+ toJsonSchemaObject,
+} from "./TextGenerationUtils.ts";
+
+const PI_TIMEOUT_MS = 180_000;
+const PI_LAST_TEXT_TIMEOUT_MS = 5_000;
+
+type TextGenOperation =
+ | "generateCommitMessage"
+ | "generatePrContent"
+ | "generateBranchName"
+ | "generateThreadTitle";
+
+const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
+const isTextGenerationError = Schema.is(TextGenerationError);
+
+function isPiTextGenerationSettled(message: {
+ readonly _tag: string;
+ readonly event?: { readonly type: string; readonly willRetry?: boolean };
+}): boolean {
+ if (message._tag !== "event" || message.event === undefined) return false;
+ // Prefer agent_settled (0.80.5+); fall back to terminal agent_end for older Pi.
+ if (message.event.type === "agent_settled") return true;
+ return message.event.type === "agent_end" && message.event.willRetry !== true;
+}
+
+export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* (
+ piSettings: PiSettings,
+ environment: NodeJS.ProcessEnv = process.env,
+) {
+ const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const processEnv = resolvePiProcessEnv(piSettings, environment);
+
+ const runPiPrompt = (input: {
+ readonly message: string;
+ readonly cwd: string;
+ readonly modelSelection: ModelSelection;
+ readonly operation: TextGenOperation;
+ }): Effect.Effect =>
+ Effect.gen(function* () {
+ const transport = yield* makePiRpcTransport({
+ binaryPath: piSettings.binaryPath || "pi",
+ args: [
+ "--mode",
+ "rpc",
+ "--no-session",
+ "--no-tools",
+ // Isolated prompts should not load user plugins / approval gates.
+ "--no-extensions",
+ ...(resolvePiThinkingLevel(input.modelSelection)
+ ? ["--thinking", resolvePiThinkingLevel(input.modelSelection)!]
+ : []),
+ ...(input.modelSelection.model ? ["--model", input.modelSelection.model] : []),
+ ],
+ cwd: input.cwd,
+ env: processEnv,
+ onExit: Effect.void,
+ });
+ yield* transport.writeCommand({ type: "prompt", message: input.message });
+ yield* Stream.fromQueue(transport.messages).pipe(
+ Stream.takeUntil(isPiTextGenerationSettled),
+ Stream.runDrain,
+ );
+ const response = yield* transport.request(
+ { type: "get_last_assistant_text" },
+ "pi-textgen-last-text",
+ PI_LAST_TEXT_TIMEOUT_MS,
+ );
+ return extractLastAssistantText(response) ?? "";
+ }).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner),
+ Effect.scoped,
+ Effect.timeoutOption(PI_TIMEOUT_MS),
+ Effect.flatMap(
+ Option.match({
+ onNone: () =>
+ Effect.fail(
+ new TextGenerationError({
+ operation: input.operation,
+ detail: "Pi request timed out.",
+ }),
+ ),
+ onSome: (value) => Effect.succeed(value),
+ }),
+ ),
+ Effect.mapError((cause) =>
+ isTextGenerationError(cause)
+ ? cause
+ : new TextGenerationError({
+ operation: input.operation,
+ detail: "Pi RPC request failed.",
+ cause,
+ }),
+ ),
+ );
+
+ const runPiJson = Effect.fn("runPiJson")(function* ({
+ operation,
+ cwd,
+ prompt,
+ outputSchemaJson,
+ modelSelection,
+ }: {
+ operation: TextGenOperation;
+ cwd: string;
+ prompt: string;
+ outputSchemaJson: S;
+ modelSelection: ModelSelection;
+ }): Effect.fn.Return {
+ const schemaJson = yield* encodeJsonString(toJsonSchemaObject(outputSchemaJson)).pipe(
+ Effect.mapError(
+ (cause) =>
+ new TextGenerationError({
+ operation,
+ detail: "Failed to encode structured output schema.",
+ cause,
+ }),
+ ),
+ );
+
+ const rawResult = yield* runPiPrompt({
+ message:
+ `${prompt}\n\nRespond ONLY with minified JSON matching this schema. ` +
+ `No markdown, no code fences, no prose:\n${schemaJson}`,
+ cwd,
+ modelSelection,
+ operation,
+ });
+
+ const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));
+ return yield* decodeOutput(extractJsonObject(rawResult)).pipe(
+ Effect.catchTags({
+ SchemaError: (cause) =>
+ Effect.fail(
+ new TextGenerationError({
+ operation,
+ detail: "Pi returned invalid structured output.",
+ 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,
+ });
+ 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,
+ });
+ 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,
+ 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/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts
index f9e7a700716..83620dc7f39 100644
--- a/apps/web/src/components/chat/providerIconUtils.ts
+++ b/apps/web/src/components/chat/providerIconUtils.ts
@@ -1,5 +1,5 @@
import { ProviderDriverKind } from "@t3tools/contracts";
-import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon, PiAgentIcon } from "../Icons";
import { PROVIDER_OPTIONS } from "../../session-logic";
export const PROVIDER_ICON_BY_PROVIDER: Partial> = {
@@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("opencode")]: OpenCodeIcon,
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
+ [ProviderDriverKind.make("pi")]: PiAgentIcon,
};
function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
index 77c1813f110..3ff2e145f9f 100644
--- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
+++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
@@ -13,7 +13,7 @@ import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSet
import { cn } from "../../lib/utils";
import { normalizeProviderAccentColor } from "../../providerInstances";
import { Button } from "../ui/button";
-import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
+import { ACPRegistryIcon, Gemini, GithubCopilotIcon, type Icon } from "../Icons";
import {
Dialog,
DialogDescription,
@@ -86,11 +86,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [
label: "ACP Registry",
icon: ACPRegistryIcon,
},
- {
- value: ProviderDriverKind.make("piAgent"),
- label: "Pi Agent",
- icon: PiAgentIcon,
- },
];
/**
diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts
index 33331c14901..ce936d5060f 100644
--- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts
+++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts
@@ -21,6 +21,17 @@ describe("ProviderSettingsForm helpers", () => {
]);
});
+ it("registers pi as an active configurable driver", () => {
+ const pi = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("pi")];
+
+ expect(pi).toBeDefined();
+ expect(pi!.label).toBe("Pi");
+ expect(deriveProviderSettingsFields(pi!).map((field) => field.key)).toEqual([
+ "binaryPath",
+ "codingAgentDir",
+ ]);
+ });
+
it("sources labels and descriptions from schema annotations", () => {
const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
expect(opencode).toBeDefined();
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts
index bfee6a8d680..d0d2c908d80 100644
--- a/apps/web/src/components/settings/providerDriverMeta.ts
+++ b/apps/web/src/components/settings/providerDriverMeta.ts
@@ -4,10 +4,19 @@ import {
CursorSettings,
GrokSettings,
OpenCodeSettings,
+ PiSettings,
ProviderDriverKind,
} from "@t3tools/contracts";
import type * as Schema from "effect/Schema";
-import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import {
+ ClaudeAI,
+ CursorIcon,
+ GrokIcon,
+ type Icon,
+ OpenAI,
+ OpenCodeIcon,
+ PiAgentIcon,
+} from "../Icons";
type ProviderSettingsSchema = {
readonly fields: Readonly>;
@@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
icon: OpenCodeIcon,
settingsSchema: OpenCodeSettings,
},
+ {
+ value: ProviderDriverKind.make("pi"),
+ label: "Pi",
+ icon: PiAgentIcon,
+ badgeLabel: "Early Access",
+ settingsSchema: PiSettings,
+ },
];
export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 5d5051f748e..f7f8388144c 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{
available: true,
pickerSidebarBadge: "new",
},
+ {
+ value: ProviderDriverKind.make("pi"),
+ label: "Pi",
+ available: true,
+ pickerSidebarBadge: "new",
+ },
];
export type WorkLogToolLifecycleStatus =
diff --git a/docs/providers/pi.md b/docs/providers/pi.md
new file mode 100644
index 00000000000..4df113a1a81
--- /dev/null
+++ b/docs/providers/pi.md
@@ -0,0 +1,123 @@
+# Pi
+
+This guide is for people who want to use the Pi coding agent in T3 Code.
+
+Pi support is **Early Access** and is disabled by default. You opt in from Settings, and
+Pi runs through its own `~/.pi/agent` configuration — the same setup the `pi` CLI uses.
+T3 talks to Pi over its native `pi --mode rpc` protocol (not ACP), so installed Pi
+extensions, skills, and packages remain available in sessions.
+
+## Before You Start
+
+Install the Pi CLI and make sure it is on your `PATH`:
+
+```bash
+pi --version
+```
+
+Pi does not have a single `login` command like Codex or Claude. Instead, Pi talks to
+upstream model providers (for example Google, Anthropic, or xAI) using per-provider API
+keys stored under `~/.pi/agent`. Configure at least one provider with the Pi CLI before
+using Pi in T3 Code, then confirm it works:
+
+```bash
+pi --list-models
+```
+
+If that prints models, Pi is ready.
+
+## Enable Pi In T3 Code
+
+Pi is off by default. Turn it on in Settings.
+
+In Settings, your Pi provider looks like this:
+
+```text
+Display name: Pi
+Binary path: pi
+Pi config directory: (empty → ~/.pi/agent)
+Require tool approval: on
+```
+
+An empty (or `pi`) `Binary path` uses the `pi` binary from your `PATH`. Point it at an
+absolute path if you run a specific build.
+
+## Where Pi Keeps Its Config
+
+Pi reads auth, models, settings, extensions, and packages from a single directory:
+
+```text
+~/.pi/agent/auth.json upstream provider API keys
+~/.pi/agent/models.json enabled models
+~/.pi/agent/settings.json default provider/model, packages, theme
+~/.pi/agent/extensions/ TypeScript extensions / plugins
+~/.pi/agent/skills/ skills invocable as /skill:name
+```
+
+T3 Code uses this directory as-is, so the models, providers, and plugin commands you see
+in T3 Code match what the `pi` CLI shows.
+
+To point Pi at a different config directory, set **Pi config directory** in the provider
+settings (this sets `PI_CODING_AGENT_DIR`). You can also set that env var under Environment
+variables. This is useful if you want work and personal Pi setups.
+
+## Plugins And Slash Commands
+
+Normal chat sessions load your Pi extensions and packages from the config directory.
+T3 discovers available commands with Pi's `get_commands` RPC and surfaces them in the
+composer as provider slash commands (extension commands, prompt templates, and skills).
+
+Isolated text-generation helpers (titles, commit messages, etc.) intentionally run with
+`--no-extensions` so plugins cannot affect structured output.
+
+## Which Models Are Available?
+
+T3 Code discovers Pi models live. When it checks Pi's status, it briefly starts
+`pi --mode rpc` and asks Pi for its available models and commands, then appends any
+custom models you configured. The result is exactly the catalog your `~/.pi/agent`
+configuration exposes — including plugin-provided providers when those are installed.
+
+If discovery fails or times out, T3 Code falls back to your custom models only. Enable more
+models with the Pi CLI (`pi config`) or by editing `~/.pi/agent/models.json`, then refresh
+provider status in Settings.
+
+## How Tool Approval Works
+
+Pi has no built-in per-tool approval prompt, so T3 Code adds one with a small bundled Pi
+extension. When **Require tool approval** is on (the default), T3 Code gates every tool
+call that is not read-only. Your other installed Pi extensions still load alongside this
+gate.
+
+Read-only tools run without asking:
+
+```text
+read grep find ls glob
+```
+
+Everything else — including `bash`, `write`, `edit`, and any unknown or custom tool — is
+**denied unless you approve it**. This is a default-deny gate: unfamiliar tools are treated
+as unsafe rather than trusted.
+
+T3 Code will not run an ungated Pi session. Before allowing a tool-capable turn, it
+verifies that the approval extension actually loaded. If the gate cannot be guaranteed
+active, T3 Code refuses to start the session rather than run Pi with unguarded tools.
+
+If you turn **Require tool approval** off, Pi runs tools without asking. Only do this in
+environments where that is acceptable.
+
+If tool approval is required but the bundled approval gate cannot be loaded, T3 Code
+refuses to start the session rather than run Pi with unguarded tools.
+
+## Limitations
+
+- **Early Access.** Expect rough edges.
+- **Disabled by default.** You must enable Pi in Settings before it appears in the model
+ picker.
+- **Auth is inferred from model discovery.** Pi has no `login` command, so T3 Code reports
+ Pi as authenticated when Pi returns available models (which requires a working provider or
+ API key configured in `~/.pi/agent`), and shows a "no models available" warning otherwise.
+ An invalid key surfaces as an error when you send a message. Use `pi --list-models` to
+ confirm your keys work.
+- **Config is shared with the Pi CLI.** Changes you make in `~/.pi/agent` affect both T3
+ Code and the `pi` CLI. Use **Pi config directory** / `PI_CODING_AGENT_DIR` to isolate a
+ setup.
diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts
new file mode 100644
index 00000000000..40b788659fc
--- /dev/null
+++ b/packages/contracts/src/model.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { ProviderDriverKind } from "./providerInstance.ts";
+import {
+ DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
+ DEFAULT_MODEL_BY_PROVIDER,
+ MODEL_SLUG_ALIASES_BY_PROVIDER,
+ PROVIDER_DISPLAY_NAMES,
+} from "./model.ts";
+
+const PI = ProviderDriverKind.make("pi");
+
+describe("model maps — pi", () => {
+ it("registers Pi with an explicit empty alias map (canonical provider/id passthrough)", () => {
+ expect(MODEL_SLUG_ALIASES_BY_PROVIDER[PI]).toEqual({});
+ });
+
+ it("intentionally omits Pi from the static default-model maps", () => {
+ expect(DEFAULT_MODEL_BY_PROVIDER[PI]).toBeUndefined();
+ expect(DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[PI]).toBeUndefined();
+ });
+
+ it("keeps the Pi display name registered", () => {
+ expect(PROVIDER_DISPLAY_NAMES[PI]).toBe("Pi");
+ });
+});
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index dddf3f37459..58f077247a0 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -132,10 +132,12 @@ 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.4";
export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini";
+// pi: no static default — models are discovered live and slugs are account-specific.
export const DEFAULT_MODEL_BY_PROVIDER: Partial> = {
[CODEX_DRIVER_KIND]: DEFAULT_MODEL,
[CLAUDE_DRIVER_KIND]: "claude-sonnet-5",
@@ -198,6 +200,8 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
"opus-4.5": "claude-opus-4-5",
},
[OPENCODE_DRIVER_KIND]: {},
+ // pi: canonical provider/id slugs, no aliases to rewrite.
+ [PI_DRIVER_KIND]: {},
};
// ── Provider display names ────────────────────────────────────────────
@@ -208,4 +212,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial>
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
[OPENCODE_DRIVER_KIND]: "OpenCode",
+ [PI_DRIVER_KIND]: "Pi",
};
diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts
index 0e6ced58fd5..546402dd66d 100644
--- a/packages/contracts/src/providerRuntime.test.ts
+++ b/packages/contracts/src/providerRuntime.test.ts
@@ -181,4 +181,64 @@ describe("ProviderRuntimeEvent", () => {
expect(parsed.payload.usage.maxTokens).toBe(200000);
expect(parsed.payload.usage.usedTokens).toBe(31251);
});
+
+ it("decodes a content.delta carrying a pi.rpc.event raw source", () => {
+ const parsed = decodeRuntimeEvent({
+ type: "content.delta",
+ eventId: "event-pi-1",
+ provider: "pi",
+ providerInstanceId: "pi",
+ createdAt: "2026-02-28T00:00:00.000Z",
+ threadId: "thread-1",
+ turnId: "turn-1",
+ payload: { streamKind: "assistant_text", delta: "hi" },
+ raw: {
+ source: "pi.rpc.event",
+ method: "message_update",
+ payload: { type: "message_update" },
+ },
+ });
+ expect(parsed.type).toBe("content.delta");
+ if (parsed.type !== "content.delta") throw new Error("expected content.delta");
+ expect(parsed.raw?.source).toBe("pi.rpc.event");
+ expect(parsed.raw?.method).toBe("message_update");
+ });
+
+ it("decodes a request.opened carrying a pi.rpc.extension-ui raw source", () => {
+ const parsed = decodeRuntimeEvent({
+ type: "request.opened",
+ eventId: "event-pi-2",
+ provider: "pi",
+ providerInstanceId: "pi",
+ createdAt: "2026-02-28T00:00:01.000Z",
+ threadId: "thread-1",
+ turnId: "turn-1",
+ requestId: "req-1",
+ payload: { requestType: "command_execution_approval", detail: "bash\nls -la" },
+ raw: {
+ source: "pi.rpc.extension-ui",
+ method: "confirm",
+ payload: { type: "extension_ui_request", id: "ui-1", method: "confirm" },
+ },
+ });
+ expect(parsed.type).toBe("request.opened");
+ if (parsed.type !== "request.opened") throw new Error("expected request.opened");
+ expect(parsed.raw?.source).toBe("pi.rpc.extension-ui");
+ });
+
+ it("rejects an unknown pi raw source literal", () => {
+ expect(() =>
+ decodeRuntimeEvent({
+ type: "content.delta",
+ eventId: "event-pi-3",
+ provider: "pi",
+ providerInstanceId: "pi",
+ createdAt: "2026-02-28T00:00:02.000Z",
+ threadId: "thread-1",
+ turnId: "turn-1",
+ payload: { streamKind: "assistant_text", delta: "x" },
+ raw: { source: "pi.rpc.unknown", payload: {} },
+ }),
+ ).toThrow();
+ });
});
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index eb2563eff00..2c6fe341e1b 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -26,6 +26,8 @@ const RuntimeEventRawSource = Schema.Union([
Schema.Literal("claude.sdk.permission"),
Schema.Literal("codex.sdk.thread-event"),
Schema.Literal("opencode.sdk.event"),
+ Schema.Literal("pi.rpc.event"),
+ Schema.Literal("pi.rpc.extension-ui"),
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 6ccd65533dd..77d2061a61f 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -355,6 +355,42 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
);
export type OpenCodeSettings = typeof OpenCodeSettings.Type;
+export const PiSettings = makeProviderSettingsSchema(
+ {
+ 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 coding-agent binary.",
+ providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" },
+ }),
+ ),
+ codingAgentDir: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Pi config directory",
+ description:
+ "Custom PI_CODING_AGENT_DIR. Auth, models, settings, extensions, and packages live here (default ~/.pi/agent).",
+ providerSettingsForm: {
+ placeholder: "~/.pi/agent",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath", "codingAgentDir"],
+ },
+);
+export type PiSettings = typeof PiSettings.Type;
+
export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -399,6 +435,7 @@ export const ServerSettings = Schema.Struct({
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.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
@@ -501,6 +538,13 @@ const OpenCodeSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const PiSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ codingAgentDir: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+});
+
export const ServerSettingsPatch = Schema.Struct({
// Server settings
enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
@@ -523,6 +567,7 @@ export const ServerSettingsPatch = Schema.Struct({
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
+ pi: Schema.optionalKey(PiSettingsPatch),
}),
),
// Whole-map replacement for the new instance config. Patching individual
diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts
index 9fdbd6c0447..3ab09935bc8 100644
--- a/packages/shared/src/model.ts
+++ b/packages/shared/src/model.ts
@@ -11,6 +11,7 @@ import {
} from "@t3tools/contracts";
const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
+const PI_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("pi");
export interface SelectableModelOption {
slug: string;
@@ -287,10 +288,9 @@ export function resolveSelectableModel(
function resolveModelSlug(model: string | null | undefined, provider: ProviderDriverKind): string {
const normalized = normalizeModelSlug(model, provider);
- if (!normalized) {
- return DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
- }
- return normalized;
+ if (normalized) return normalized;
+ if (provider === PI_PROVIDER_DRIVER_KIND) return "";
+ return DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
}
export function resolveModelSlugForProvider(
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ee737f9e750..124dca791f6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -102,7 +102,7 @@ importers:
version: 7.0.0-dev.20260604.1
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
apps/desktop:
dependencies:
@@ -166,7 +166,7 @@ importers:
version: 4.3.0
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
apps/marketing:
dependencies:
@@ -464,6 +464,9 @@ importers:
specifier: ^1.1.0
version: 1.1.0
devDependencies:
+ '@earendil-works/pi-coding-agent':
+ specifier: ^0.80.10
+ version: 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)
'@effect/vitest':
specifier: 4.0.0-beta.78
version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
@@ -493,7 +496,7 @@ importers:
version: link:../../packages/effect-codex-app-server
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
apps/web:
dependencies:
@@ -650,7 +653,7 @@ importers:
version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
infra/relay:
dependencies:
@@ -677,10 +680,10 @@ importers:
version: link:../../packages/shared
alchemy:
specifier: https://pkg.ing/alchemy/078ff00
- version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39)
+ version: https://pkg.ing/alchemy/078ff00(71014aa0d8b768bbcfac2674259faaab)
drizzle-orm:
specifier: 1.0.0-rc.3
- version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3)
+ version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(typebox@1.1.38)(zod@4.4.3)
effect:
specifier: 4.0.0-beta.78
version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
@@ -705,7 +708,7 @@ importers:
version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
oxlint-plugin-t3code:
dependencies:
@@ -724,7 +727,7 @@ importers:
version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages/client-runtime:
dependencies:
@@ -743,7 +746,7 @@ importers:
version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages/contracts:
dependencies:
@@ -756,7 +759,7 @@ importers:
version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages/effect-acp:
dependencies:
@@ -778,7 +781,7 @@ importers:
version: 24.12.4
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages/effect-codex-app-server:
dependencies:
@@ -800,7 +803,7 @@ importers:
version: 24.12.4
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages/shared:
dependencies:
@@ -834,7 +837,7 @@ importers:
version: 24.12.4
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages/ssh:
dependencies:
@@ -859,7 +862,7 @@ importers:
version: 24.12.4
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages/tailscale:
dependencies:
@@ -881,7 +884,7 @@ importers:
version: 24.12.4
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
scripts:
dependencies:
@@ -906,7 +909,7 @@ importers:
version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
vite-plus:
specifier: 'catalog:'
- version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ version: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
packages:
@@ -972,6 +975,15 @@ packages:
'@modelcontextprotocol/sdk': ^1.29.0
zod: ^4.0.0
+ '@anthropic-ai/sdk@0.91.1':
+ resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==}
+ hasBin: true
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
'@anthropic-ai/sdk@0.93.0':
resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==}
hasBin: true
@@ -1103,6 +1115,10 @@ packages:
'@aws-crypto/util@5.2.0':
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
+ '@aws-sdk/client-bedrock-runtime@3.1048.0':
+ resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/client-cognito-identity@3.1062.0':
resolution: {integrity: sha512-QA5z/Pl3aTMR3+bmiHoC6MpKYa4FMk/9lNP7k104uKuUsjMqP4ysRa43IwdcbI9sH023T//kSJCLxrxa2CP/Tw==}
engines: {node: '>=20.0.0'}
@@ -1111,6 +1127,10 @@ packages:
resolution: {integrity: sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/core@3.975.0':
+ resolution: {integrity: sha512-rro0KMJz0XBZw1HChXXgylx9aaf60ITyBmZtH0x+9QG6u3SOoyM81LI9ONSGQe0nuN4554LOg4xKIY7frHpgmg==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-cognito-identity@3.972.41':
resolution: {integrity: sha512-byGPybEQe9ejeyUzhWjtjfh0ctv25HsRx2djF/Tl2j9+DAuAmhjq0NqSRqYZEoSe8vJObXz5RYDtJYAmdupBig==}
engines: {node: '>=20.0.0'}
@@ -1151,6 +1171,18 @@ packages:
resolution: {integrity: sha512-QS2UT3srjNppZv6mq7V0igqK/ThYKqRWwDscxDsMEmmEE5JqCPPSqFW71aEpkvXaMdgmG8xEpt4RtNHpZ30cTA==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/eventstream-handler-node@3.972.26':
+ resolution: {integrity: sha512-RE1fu7Nn05vG0EUJM+8Sde2GFecC658WGaC/asPzLF6K4x3H5ZaDBcQtHRE67Gdgb1VZpyUUliYejHFK1qt0Uw==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/middleware-eventstream@3.972.22':
+ resolution: {integrity: sha512-jtkgmhevnpzC1WeS+Y/sgymYbaQ6qg7pVOUl5cUT/8MiLptqrtnXQlNV80m+j2WIx5MIL7kVHIZNxxcK2tfUEQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/middleware-websocket@3.972.38':
+ resolution: {integrity: sha512-xLd1EO2Gtm1D7XtgPf9WUgD1t2ln87yZL+IIXj/IFcExVUsFV4ob2ZISeVN+J80uJdIZzOlKHlsZSx0foKs8DQ==}
+ engines: {node: '>= 14.0.0'}
+
'@aws-sdk/nested-clients@3.997.16':
resolution: {integrity: sha512-bGvfDgC2KQePjEmZdltScPPLKFoyjPElAXeZcLfvZ58J1AO283//WGtvp9GdnryLHTi7gis0UoCezqh0vl/nig==}
engines: {node: '>=20.0.0'}
@@ -1159,6 +1191,10 @@ packages:
resolution: {integrity: sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/token-providers@3.1048.0':
+ resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/token-providers@3.1062.0':
resolution: {integrity: sha512-fvHh53zSm2FoQPgkw9thH5D7sd13bC0nPyuZb+mQJ85l5v7lQnsZ97u6e6YkJJN/LU1Mxm1/DLGrIIRR2L7tZw==}
engines: {node: '>=20.0.0'}
@@ -1167,6 +1203,10 @@ packages:
resolution: {integrity: sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/types@3.974.0':
+ resolution: {integrity: sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/util-locate-window@3.965.5':
resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==}
engines: {node: '>=20.0.0'}
@@ -1175,10 +1215,18 @@ packages:
resolution: {integrity: sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/xml-builder@3.972.34':
+ resolution: {integrity: sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==}
+ engines: {node: '>=20.0.0'}
+
'@aws/lambda-invoke-store@0.2.4':
resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
engines: {node: '>=18.0.0'}
+ '@aws/lambda-invoke-store@0.3.0':
+ resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
+ engines: {node: '>=18.0.0'}
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -1916,6 +1964,24 @@ packages:
'@drizzle-team/brocli@0.11.0':
resolution: {integrity: sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==}
+ '@earendil-works/pi-agent-core@0.80.10':
+ resolution: {integrity: sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==}
+ engines: {node: '>=22.19.0'}
+
+ '@earendil-works/pi-ai@0.80.10':
+ resolution: {integrity: sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==}
+ engines: {node: '>=22.19.0'}
+ hasBin: true
+
+ '@earendil-works/pi-coding-agent@0.80.10':
+ resolution: {integrity: sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==}
+ engines: {node: '>=22.19.0'}
+ hasBin: true
+
+ '@earendil-works/pi-tui@0.80.10':
+ resolution: {integrity: sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg==}
+ engines: {node: '>=22.19.0'}
+
'@effect/atom-react@4.0.0-beta.78':
resolution: {integrity: sha512-cgxDXJaD0wlbQXbp6tiEmmY+yajwurB0ynkFG20RVucvH4LsQMB3ogiHe0mt42wGggfbVYMEDxgBpQdqDRY8yA==}
peerDependencies:
@@ -2691,6 +2757,15 @@ packages:
'@formkit/auto-animate@0.9.0':
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
+ '@google/genai@1.52.0':
+ resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ '@modelcontextprotocol/sdk': ^1.25.2
+ peerDependenciesMeta:
+ '@modelcontextprotocol/sdk':
+ optional: true
+
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -3081,6 +3156,82 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'}
+ '@mariozechner/clipboard-darwin-arm64@0.3.9':
+ resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@mariozechner/clipboard-darwin-universal@0.3.9':
+ resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==}
+ engines: {node: '>= 10'}
+ os: [darwin]
+
+ '@mariozechner/clipboard-darwin-x64@0.3.9':
+ resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@mariozechner/clipboard-linux-arm64-gnu@0.3.9':
+ resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@mariozechner/clipboard-linux-arm64-musl@0.3.9':
+ resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9':
+ resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@mariozechner/clipboard-linux-x64-gnu@0.3.9':
+ resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@mariozechner/clipboard-linux-x64-musl@0.3.9':
+ resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@mariozechner/clipboard-win32-arm64-msvc@0.3.9':
+ resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@mariozechner/clipboard-win32-x64-msvc@0.3.9':
+ resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@mariozechner/clipboard@0.3.9':
+ resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==}
+ engines: {node: '>= 10'}
+
+ '@mistralai/mistralai@2.2.6':
+ resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==}
+ peerDependencies:
+ '@opentelemetry/api': ^1.9.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
@@ -3229,6 +3380,14 @@ packages:
'@opencode-ai/sdk@1.15.13':
resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==}
+ '@opentelemetry/api@1.9.0':
+ resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
+ engines: {node: '>=8.0.0'}
+
+ '@opentelemetry/semantic-conventions@1.42.0':
+ resolution: {integrity: sha512-icc5xCzndZfhuJMy5oqk5AvloWquR7jtae74qzpkKkhGp8BivK+oCcEXgGnjCdTfp8hA44l+w8gE8yYJbocJJw==}
+ engines: {node: '>=14'}
+
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
@@ -3582,6 +3741,33 @@ packages:
'@preact/signals-core@1.14.2':
resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==}
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.5':
+ resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
+
+ '@protobufjs/eventemitter@1.1.1':
+ resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
+
+ '@protobufjs/fetch@1.1.1':
+ resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.2':
+ resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
+
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -4435,6 +4621,9 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+ '@silvia-odwyer/photon-node@0.3.4':
+ resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==}
+
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@@ -4446,6 +4635,10 @@ packages:
resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==}
engines: {node: '>=18.0.0'}
+ '@smithy/core@3.29.2':
+ resolution: {integrity: sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/credential-provider-imds@4.3.8':
resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==}
engines: {node: '>=18.0.0'}
@@ -4454,6 +4647,10 @@ packages:
resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==}
engines: {node: '>=18.0.0'}
+ '@smithy/fetch-http-handler@5.6.4':
+ resolution: {integrity: sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/is-array-buffer@2.2.0':
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
engines: {node: '>=14.0.0'}
@@ -4462,6 +4659,10 @@ packages:
resolution: {integrity: sha512-M+gG6eQ0y073mSmNB+erRXJvwpsqsN72ol2w6vcd8FEKeG7pqYK0JvzfVqONkPj2ElBB2pg+cU13I850b//Wag==}
engines: {node: '>=18.0.0'}
+ '@smithy/node-http-handler@4.7.3':
+ resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/node-http-handler@4.7.7':
resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==}
engines: {node: '>=18.0.0'}
@@ -4474,10 +4675,18 @@ packages:
resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/signature-v4@5.6.3':
+ resolution: {integrity: sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/types@4.14.3':
resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/types@4.16.0':
+ resolution: {integrity: sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-base64@4.4.6':
resolution: {integrity: sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==}
engines: {node: '>=18.0.0'}
@@ -4913,6 +5122,9 @@ packages:
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
+ '@types/retry@0.12.0':
+ resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
+
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@@ -5599,6 +5811,9 @@ packages:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
+ bignumber.js@9.3.1:
+ resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
+
bippy@0.5.41:
resolution: {integrity: sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg==}
peerDependencies:
@@ -5660,6 +5875,9 @@ packages:
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -6034,6 +6252,10 @@ packages:
resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ data-uri-to-buffer@4.0.1:
+ resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
+ engines: {node: '>= 12'}
+
debounce-fn@4.0.0:
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
engines: {node: '>=10'}
@@ -6143,6 +6365,10 @@ packages:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
+ engines: {node: '>=0.3.1'}
+
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
@@ -6314,6 +6540,9 @@ packages:
duplexer2@0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -6889,6 +7118,10 @@ packages:
picomatch:
optional: true
+ fetch-blob@3.2.0:
+ resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
+ engines: {node: ^12.20 || >= 14.13}
+
fetch-nodeshim@0.4.10:
resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==}
@@ -6942,6 +7175,10 @@ packages:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
+ formdata-polyfill@4.0.10:
+ resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+ engines: {node: '>=12.20.0'}
+
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -6989,6 +7226,14 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ gaxios@7.2.0:
+ resolution: {integrity: sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==}
+ engines: {node: '>=18'}
+
+ gcp-metadata@8.1.2:
+ resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
+ engines: {node: '>=18'}
+
generate-function@2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
@@ -7057,6 +7302,14 @@ packages:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
+ google-auth-library@10.9.0:
+ resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==}
+ engines: {node: '>=18'}
+
+ google-logging-utils@1.1.3:
+ resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
+ engines: {node: '>=14'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -7152,6 +7405,9 @@ packages:
hermes-parser@0.35.0:
resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==}
+ highlight.js@10.7.3:
+ resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
+
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@@ -7167,6 +7423,10 @@ packages:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
+ hosted-git-info@9.0.3:
+ resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@@ -7459,6 +7719,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ json-bigint@1.0.0:
+ resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -7504,6 +7767,12 @@ packages:
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -7854,6 +8123,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+ marked@18.0.5:
+ resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
@@ -8267,9 +8541,18 @@ packages:
node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
+ node-domexception@1.0.0:
+ resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+ engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
+
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+ node-fetch@3.3.2:
+ resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
node-forge@1.4.0:
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
engines: {node: '>= 6.13.0'}
@@ -8389,6 +8672,18 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
+ openai@6.26.0:
+ resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
+ hasBin: true
+ peerDependencies:
+ ws: ^8.18.0
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ ws:
+ optional: true
+ zod:
+ optional: true
+
ora@3.4.0:
resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==}
engines: {node: '>=6'}
@@ -8458,6 +8753,10 @@ packages:
resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==}
engines: {node: '>=20'}
+ p-retry@4.6.2:
+ resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
+ engines: {node: '>=8'}
+
p-timeout@7.0.1:
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
engines: {node: '>=20'}
@@ -8489,6 +8788,9 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
+ partial-json@0.1.7:
+ resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
+
patch-console@2.0.0:
resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -8747,6 +9049,10 @@ packages:
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
+ protobufjs@7.6.5:
+ resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
+ engines: {node: '>=12.0.0'}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -9177,6 +9483,10 @@ packages:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
+ retry@0.13.1:
+ resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
+ engines: {node: '>= 4'}
+
rettime@0.10.1:
resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
@@ -9262,6 +9572,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.0:
+ resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
@@ -9718,6 +10033,9 @@ packages:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
+ typebox@1.1.38:
+ resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==}
+
typesafe-path@0.2.2:
resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==}
@@ -9753,6 +10071,10 @@ packages:
resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==}
engines: {node: '>=22.19.0'}
+ undici@8.5.0:
+ resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
+ engines: {node: '>=22.19.0'}
+
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
@@ -10163,6 +10485,10 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+ web-streams-polyfill@3.3.3:
+ resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
+ engines: {node: '>= 8'}
+
webcrypto-core@1.9.2:
resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==}
@@ -10439,6 +10765,12 @@ snapshots:
'@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.170
'@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.170
+ '@anthropic-ai/sdk@0.91.1(zod@4.4.3)':
+ dependencies:
+ json-schema-to-ts: 3.1.1
+ optionalDependencies:
+ zod: 4.4.3
+
'@anthropic-ai/sdk@0.93.0(zod@4.4.3)':
dependencies:
json-schema-to-ts: 3.1.1
@@ -10605,7 +10937,7 @@ snapshots:
'@aws-crypto/sha256-js': 5.2.0
'@aws-crypto/supports-web-crypto': 5.2.0
'@aws-crypto/util': 5.2.0
- '@aws-sdk/types': 3.973.10
+ '@aws-sdk/types': 3.974.0
'@aws-sdk/util-locate-window': 3.965.5
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
@@ -10613,7 +10945,7 @@ snapshots:
'@aws-crypto/sha256-js@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
- '@aws-sdk/types': 3.973.10
+ '@aws-sdk/types': 3.974.0
tslib: 2.8.1
'@aws-crypto/supports-web-crypto@5.2.0':
@@ -10626,6 +10958,23 @@ snapshots:
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
+ '@aws-sdk/client-bedrock-runtime@3.1048.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.975.0
+ '@aws-sdk/credential-provider-node': 3.972.51
+ '@aws-sdk/eventstream-handler-node': 3.972.26
+ '@aws-sdk/middleware-eventstream': 3.972.22
+ '@aws-sdk/middleware-websocket': 3.972.38
+ '@aws-sdk/token-providers': 3.1048.0
+ '@aws-sdk/types': 3.974.0
+ '@smithy/core': 3.29.2
+ '@smithy/fetch-http-handler': 5.6.4
+ '@smithy/node-http-handler': 4.7.7
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
'@aws-sdk/client-cognito-identity@3.1062.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -10650,6 +10999,17 @@ snapshots:
bowser: 2.14.1
tslib: 2.8.1
+ '@aws-sdk/core@3.975.0':
+ dependencies:
+ '@aws-sdk/types': 3.974.0
+ '@aws-sdk/xml-builder': 3.972.34
+ '@aws/lambda-invoke-store': 0.3.0
+ '@smithy/core': 3.29.2
+ '@smithy/signature-v4': 5.6.3
+ '@smithy/types': 4.16.0
+ bowser: 2.14.1
+ tslib: 2.8.1
+
'@aws-sdk/credential-provider-cognito-identity@3.972.41':
dependencies:
'@aws-sdk/nested-clients': 3.997.16
@@ -10762,6 +11122,30 @@ snapshots:
'@smithy/types': 4.14.3
tslib: 2.8.1
+ '@aws-sdk/eventstream-handler-node@3.972.26':
+ dependencies:
+ '@aws-sdk/types': 3.974.0
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-eventstream@3.972.22':
+ dependencies:
+ '@aws-sdk/types': 3.974.0
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-websocket@3.972.38':
+ dependencies:
+ '@aws-sdk/core': 3.975.0
+ '@aws-sdk/types': 3.974.0
+ '@smithy/core': 3.29.2
+ '@smithy/fetch-http-handler': 5.6.4
+ '@smithy/signature-v4': 5.6.3
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
'@aws-sdk/nested-clients@3.997.16':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -10777,18 +11161,27 @@ snapshots:
'@aws-sdk/signature-v4-multi-region@3.996.31':
dependencies:
- '@aws-sdk/types': 3.973.10
+ '@aws-sdk/types': 3.974.0
'@smithy/signature-v4': 5.4.6
- '@smithy/types': 4.14.3
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
+ '@aws-sdk/token-providers@3.1048.0':
+ dependencies:
+ '@aws-sdk/core': 3.975.0
+ '@aws-sdk/nested-clients': 3.997.16
+ '@aws-sdk/types': 3.974.0
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1062.0':
dependencies:
- '@aws-sdk/core': 3.974.17
+ '@aws-sdk/core': 3.975.0
'@aws-sdk/nested-clients': 3.997.16
- '@aws-sdk/types': 3.973.10
- '@smithy/core': 3.24.6
- '@smithy/types': 4.14.3
+ '@aws-sdk/types': 3.974.0
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
tslib: 2.8.1
'@aws-sdk/types@3.973.10':
@@ -10796,18 +11189,30 @@ snapshots:
'@smithy/types': 4.14.3
tslib: 2.8.1
+ '@aws-sdk/types@3.974.0':
+ dependencies:
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
'@aws-sdk/util-locate-window@3.965.5':
dependencies:
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.27':
dependencies:
- '@smithy/types': 4.14.3
+ '@smithy/types': 4.16.0
fast-xml-parser: 5.7.3
tslib: 2.8.1
+ '@aws-sdk/xml-builder@3.972.34':
+ dependencies:
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
'@aws/lambda-invoke-store@0.2.4': {}
+ '@aws/lambda-invoke-store@0.3.0': {}
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -11651,6 +12056,76 @@ snapshots:
'@drizzle-team/brocli@0.11.0': {}
+ '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)':
+ dependencies:
+ '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)
+ ignore: 7.0.5
+ typebox: 1.1.38
+ yaml: 2.9.0
+ transitivePeerDependencies:
+ - '@modelcontextprotocol/sdk'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - ws
+ - zod
+
+ '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)':
+ dependencies:
+ '@anthropic-ai/sdk': 0.91.1(zod@4.4.3)
+ '@aws-sdk/client-bedrock-runtime': 3.1048.0
+ '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ '@opentelemetry/api': 1.9.0
+ '@smithy/node-http-handler': 4.7.3
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ openai: 6.26.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)
+ partial-json: 0.1.7
+ typebox: 1.1.38
+ transitivePeerDependencies:
+ - '@modelcontextprotocol/sdk'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - ws
+ - zod
+
+ '@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)':
+ dependencies:
+ '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)
+ '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3)
+ '@earendil-works/pi-tui': 0.80.10
+ '@silvia-odwyer/photon-node': 0.3.4
+ chalk: 5.6.2
+ cross-spawn: 7.0.6
+ diff: 8.0.4
+ glob: 13.0.6
+ highlight.js: 10.7.3
+ hosted-git-info: 9.0.3
+ ignore: 7.0.5
+ jiti: 2.7.0
+ minimatch: 10.2.5
+ proper-lockfile: 4.1.2
+ semver: 7.8.0
+ typebox: 1.1.38
+ undici: 8.5.0
+ yaml: 2.9.0
+ optionalDependencies:
+ '@mariozechner/clipboard': 0.3.9
+ transitivePeerDependencies:
+ - '@modelcontextprotocol/sdk'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - ws
+ - zod
+
+ '@earendil-works/pi-tui@0.80.10':
+ dependencies:
+ get-east-asian-width: 1.6.0
+ marked: 18.0.5
+
'@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)':
dependencies:
effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
@@ -12700,6 +13175,19 @@ snapshots:
'@formkit/auto-animate@0.9.0': {}
+ '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
+ dependencies:
+ google-auth-library: 10.9.0
+ p-retry: 4.6.2
+ protobufjs: 7.6.5
+ ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ optionalDependencies:
+ '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
'@hono/node-server@1.19.14(hono@4.12.27)':
dependencies:
hono: 4.12.27
@@ -13125,6 +13613,62 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@mariozechner/clipboard-darwin-arm64@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-darwin-universal@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-darwin-x64@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-linux-arm64-gnu@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-linux-arm64-musl@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-linux-x64-gnu@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-linux-x64-musl@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-win32-arm64-msvc@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard-win32-x64-msvc@0.3.9':
+ optional: true
+
+ '@mariozechner/clipboard@0.3.9':
+ optionalDependencies:
+ '@mariozechner/clipboard-darwin-arm64': 0.3.9
+ '@mariozechner/clipboard-darwin-universal': 0.3.9
+ '@mariozechner/clipboard-darwin-x64': 0.3.9
+ '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9
+ '@mariozechner/clipboard-linux-arm64-musl': 0.3.9
+ '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9
+ '@mariozechner/clipboard-linux-x64-gnu': 0.3.9
+ '@mariozechner/clipboard-linux-x64-musl': 0.3.9
+ '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9
+ '@mariozechner/clipboard-win32-x64-msvc': 0.3.9
+ optional: true
+
+ '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
+ dependencies:
+ '@opentelemetry/semantic-conventions': 1.42.0
+ ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ zod: 4.4.3
+ zod-to-json-schema: 3.25.2(zod@4.4.3)
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
'@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.27)
@@ -13290,6 +13834,10 @@ snapshots:
dependencies:
cross-spawn: 7.0.6
+ '@opentelemetry/api@1.9.0': {}
+
+ '@opentelemetry/semantic-conventions@1.42.0': {}
+
'@oslojs/encoding@1.1.0': {}
'@oxc-project/runtime@0.138.0': {}
@@ -13523,6 +14071,26 @@ snapshots:
'@preact/signals-core@1.14.2': {}
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.5': {}
+
+ '@protobufjs/eventemitter@1.1.1': {}
+
+ '@protobufjs/fetch@1.1.1':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.2': {}
+
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
@@ -14479,6 +15047,8 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
+ '@silvia-odwyer/photon-node@0.3.4': {}
+
'@sinclair/typebox@0.27.10': {}
'@sindresorhus/is@4.6.0': {}
@@ -14489,6 +15059,11 @@ snapshots:
'@smithy/types': 4.14.3
tslib: 2.8.1
+ '@smithy/core@3.29.2':
+ dependencies:
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
'@smithy/credential-provider-imds@4.3.8':
dependencies:
'@smithy/core': 3.24.6
@@ -14497,8 +15072,14 @@ snapshots:
'@smithy/fetch-http-handler@5.4.6':
dependencies:
- '@smithy/core': 3.24.6
- '@smithy/types': 4.14.3
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
+ '@smithy/fetch-http-handler@5.6.4':
+ dependencies:
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
tslib: 2.8.1
'@smithy/is-array-buffer@2.2.0':
@@ -14510,10 +15091,16 @@ snapshots:
'@smithy/core': 3.24.6
tslib: 2.8.1
+ '@smithy/node-http-handler@4.7.3':
+ dependencies:
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
'@smithy/node-http-handler@4.7.7':
dependencies:
- '@smithy/core': 3.24.6
- '@smithy/types': 4.14.3
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
tslib: 2.8.1
'@smithy/shared-ini-file-loader@4.5.6':
@@ -14523,14 +15110,24 @@ snapshots:
'@smithy/signature-v4@5.4.6':
dependencies:
- '@smithy/core': 3.24.6
- '@smithy/types': 4.14.3
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
+ tslib: 2.8.1
+
+ '@smithy/signature-v4@5.6.3':
+ dependencies:
+ '@smithy/core': 3.29.2
+ '@smithy/types': 4.16.0
tslib: 2.8.1
'@smithy/types@4.14.3':
dependencies:
tslib: 2.8.1
+ '@smithy/types@4.16.0':
+ dependencies:
+ tslib: 2.8.1
+
'@smithy/util-base64@4.4.6':
dependencies:
'@smithy/core': 3.24.6
@@ -14956,6 +15553,8 @@ snapshots:
dependencies:
'@types/node': 24.12.4
+ '@types/retry@0.12.0': {}
+
'@types/statuses@2.0.6': {}
'@types/unist@2.0.11': {}
@@ -15036,7 +15635,7 @@ snapshots:
'@testing-library/dom': 10.4.1
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
'@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)
- vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
+ vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
transitivePeerDependencies:
- bufferutil
- msw
@@ -15052,7 +15651,7 @@ snapshots:
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
- vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
+ vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
transitivePeerDependencies:
- bufferutil
@@ -15300,7 +15899,7 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
- alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39):
+ alchemy@https://pkg.ing/alchemy/078ff00(71014aa0d8b768bbcfac2674259faaab):
dependencies:
'@alchemy.run/node-utils': 0.0.4
'@aws-sdk/credential-providers': 3.1062.0
@@ -15343,7 +15942,7 @@ snapshots:
'@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6)
'@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
drizzle-kit: 1.0.0-rc.3
- drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3)
+ drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(typebox@1.1.38)(zod@4.4.3)
vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
transitivePeerDependencies:
@@ -15775,6 +16374,8 @@ snapshots:
big-integer@1.6.52: {}
+ bignumber.js@9.3.1: {}
+
bippy@0.5.41(react@19.2.6):
dependencies:
react: 19.2.6
@@ -15849,6 +16450,8 @@ snapshots:
buffer-crc32@0.2.13: {}
+ buffer-equal-constant-time@1.0.1: {}
+
buffer-from@1.1.2: {}
buffer@5.7.1:
@@ -16224,6 +16827,8 @@ snapshots:
culori@4.0.2: {}
+ data-uri-to-buffer@4.0.1: {}
+
debounce-fn@4.0.0:
dependencies:
mimic-fn: 3.1.0
@@ -16303,6 +16908,8 @@ snapshots:
diff@8.0.3: {}
+ diff@8.0.4: {}
+
dir-compare@4.2.0:
dependencies:
minimatch: 3.1.5
@@ -16361,16 +16968,18 @@ snapshots:
get-tsconfig: 4.14.0
jiti: 2.7.0
- drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3):
+ drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(typebox@1.1.38)(zod@4.4.3):
optionalDependencies:
'@cloudflare/workers-types': 4.20260604.1
'@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
'@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ '@opentelemetry/api': 1.9.0
bun-types: 1.3.14
effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
mysql2: 3.22.4(@types/node@24.12.4)
pg: 8.21.0
+ typebox: 1.1.38
zod: 4.4.3
dset@3.1.4: {}
@@ -16385,6 +16994,10 @@ snapshots:
dependencies:
readable-stream: 2.3.8
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
ee-first@1.1.1: {}
effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5):
@@ -17338,6 +17951,11 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
+ fetch-blob@3.2.0:
+ dependencies:
+ node-domexception: 1.0.0
+ web-streams-polyfill: 3.3.3
+
fetch-nodeshim@0.4.10: {}
ffi-rs@1.3.2:
@@ -17415,6 +18033,10 @@ snapshots:
hasown: 2.0.4
mime-types: 2.1.35
+ formdata-polyfill@4.0.10:
+ dependencies:
+ fetch-blob: 3.2.0
+
forwarded@0.2.0: {}
fresh@0.5.2: {}
@@ -17465,6 +18087,22 @@ snapshots:
function-bind@1.1.2: {}
+ gaxios@7.2.0:
+ dependencies:
+ extend: 3.0.2
+ https-proxy-agent: 7.0.6
+ node-fetch: 3.3.2
+ transitivePeerDependencies:
+ - supports-color
+
+ gcp-metadata@8.1.2:
+ dependencies:
+ gaxios: 7.2.0
+ google-logging-utils: 1.1.3
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+
generate-function@2.3.1:
dependencies:
is-property: 1.0.2
@@ -17548,6 +18186,19 @@ snapshots:
gopd: 1.2.0
optional: true
+ google-auth-library@10.9.0:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 7.2.0
+ gcp-metadata: 8.1.2
+ google-logging-utils: 1.1.3
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ google-logging-utils@1.1.3: {}
+
gopd@1.2.0: {}
got@11.8.6:
@@ -17730,6 +18381,8 @@ snapshots:
dependencies:
hermes-estree: 0.35.0
+ highlight.js@10.7.3: {}
+
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
@@ -17744,6 +18397,10 @@ snapshots:
dependencies:
lru-cache: 10.4.3
+ hosted-git-info@9.0.3:
+ dependencies:
+ lru-cache: 11.5.1
+
html-escaper@3.0.3: {}
html-url-attributes@3.0.1: {}
@@ -18009,6 +18666,10 @@ snapshots:
jsesc@3.1.0: {}
+ json-bigint@1.0.0:
+ dependencies:
+ bignumber.js: 9.3.1
+
json-buffer@3.0.1: {}
json-schema-to-ts@3.1.1:
@@ -18053,6 +18714,17 @@ snapshots:
readable-stream: 2.3.8
setimmediate: 1.0.5
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -18322,6 +18994,8 @@ snapshots:
markdown-table@3.0.4: {}
+ marked@18.0.5: {}
+
marky@1.3.0: {}
matcher@3.0.0:
@@ -19040,8 +19714,16 @@ snapshots:
dependencies:
semver: 7.8.5
+ node-domexception@1.0.0: {}
+
node-fetch-native@1.6.7: {}
+ node-fetch@3.3.2:
+ dependencies:
+ data-uri-to-buffer: 4.0.1
+ fetch-blob: 3.2.0
+ formdata-polyfill: 4.0.10
+
node-forge@1.4.0: {}
node-gyp-build-optional-packages@5.2.2:
@@ -19158,6 +19840,11 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
+ openai@6.26.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(zod@4.4.3):
+ optionalDependencies:
+ ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ zod: 4.4.3
+
ora@3.4.0:
dependencies:
chalk: 2.4.2
@@ -19184,7 +19871,7 @@ snapshots:
outvariant@1.4.3: {}
- oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)):
+ oxfmt@0.57.0(vite-plus@0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)):
dependencies:
tinypool: 2.1.0
optionalDependencies:
@@ -19207,7 +19894,7 @@ snapshots:
'@oxfmt/binding-win32-arm64-msvc': 0.57.0
'@oxfmt/binding-win32-ia32-msvc': 0.57.0
'@oxfmt/binding-win32-x64-msvc': 0.57.0
- vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ vite-plus: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
oxlint-tsgolint@0.24.0:
optionalDependencies:
@@ -19218,7 +19905,7 @@ snapshots:
'@oxlint-tsgolint/win32-arm64': 0.24.0
'@oxlint-tsgolint/win32-x64': 0.24.0
- oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)):
+ oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.72.0
'@oxlint/binding-android-arm64': 1.72.0
@@ -19240,7 +19927,7 @@ snapshots:
'@oxlint/binding-win32-ia32-msvc': 1.72.0
'@oxlint/binding-win32-x64-msvc': 1.72.0
oxlint-tsgolint: 0.24.0
- vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ vite-plus: 0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
p-cancelable@2.1.1: {}
@@ -19265,6 +19952,11 @@ snapshots:
eventemitter3: 5.0.4
p-timeout: 7.0.1
+ p-retry@4.6.2:
+ dependencies:
+ '@types/retry': 0.12.0
+ retry: 0.13.1
+
p-timeout@7.0.1: {}
p-try@2.2.0: {}
@@ -19303,6 +19995,8 @@ snapshots:
parseurl@1.3.3: {}
+ partial-json@0.1.7: {}
+
patch-console@2.0.0: {}
path-browserify@1.0.1: {}
@@ -19521,6 +20215,20 @@ snapshots:
property-information@7.2.0: {}
+ protobufjs@7.6.5:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.5
+ '@protobufjs/eventemitter': 1.1.1
+ '@protobufjs/fetch': 1.1.1
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.2
+ '@types/node': 24.12.4
+ long: 5.3.2
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -20213,6 +20921,8 @@ snapshots:
retry@0.12.0: {}
+ retry@0.13.1: {}
+
rettime@0.10.1: {}
reusify@1.1.0: {}
@@ -20388,6 +21098,8 @@ snapshots:
semver@7.7.4: {}
+ semver@7.8.0: {}
+
semver@7.8.5: {}
send@0.19.2:
@@ -20867,6 +21579,8 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
+ typebox@1.1.38: {}
+
typesafe-path@0.2.2: {}
typescript-auto-import-cache@0.3.6:
@@ -20889,6 +21603,8 @@ snapshots:
undici@8.3.0: {}
+ undici@8.5.0: {}
+
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
@@ -21133,7 +21849,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0):
+ vite-plus@0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0):
dependencies:
'@oxc-project/types': 0.138.0
'@oxlint/plugins': 1.68.0
@@ -21147,11 +21863,11 @@ snapshots:
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
'@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)
- oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))
- oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))
+ oxfmt: 0.57.0(vite-plus@0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))
+ oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))
oxlint-tsgolint: 0.24.0
vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
- vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
+ vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
optionalDependencies:
'@voidzero-dev/vite-plus-darwin-arm64': 0.2.2
'@voidzero-dev/vite-plus-darwin-x64': 0.2.2
@@ -21195,7 +21911,7 @@ snapshots:
optionalDependencies:
vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
- vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)):
+ vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
@@ -21218,6 +21934,7 @@ snapshots:
vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
why-is-node-running: 2.3.0
optionalDependencies:
+ '@opentelemetry/api': 1.9.0
'@types/node': 24.12.4
'@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)
transitivePeerDependencies:
@@ -21334,6 +22051,8 @@ snapshots:
web-namespaces@2.0.1: {}
+ web-streams-polyfill@3.3.3: {}
+
webcrypto-core@1.9.2:
dependencies:
'@peculiar/asn1-schema': 2.8.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 7d85b1059d5..799e383a976 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -9,6 +9,7 @@ packages:
# true = allowed to run build scripts; false mirrors the pnpm 10 behavior
# where anything outside onlyBuiltDependencies was silently not built.
allowBuilds:
+ "@google/genai": false
browser-tabs-lock: false
bufferutil: false
core-js: false
@@ -18,6 +19,7 @@ allowBuilds:
msgpackr-extract: true
msw: false
node-pty: true
+ protobufjs: false
sharp: true
utf-8-validate: false
workerd: false
@@ -58,6 +60,10 @@ minimumReleaseAgeExclude:
- "@clerk/expo@3.7.2"
- "@clerk/react@6.12.1"
- "@clerk/shared@4.25.1"
+ - "@earendil-works/pi-agent-core@0.80.10"
+ - "@earendil-works/pi-ai@0.80.10"
+ - "@earendil-works/pi-coding-agent@0.80.10"
+ - "@earendil-works/pi-tui@0.80.10"
overrides:
"@clerk/backend": "catalog:"