From f8389d5754f38716b0cdfbd48fc0af3800001a10 Mon Sep 17 00:00:00 2001 From: Oussama Douhou <16113844+oussamadouhou@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:26:20 +0200 Subject: [PATCH 1/5] feat: add Pi provider integration via RPC --- README.md | 5 +- apps/server/package.json | 1 + apps/server/scripts/cli.ts | 11 + apps/server/scripts/pi-mock-rpc.ts | 114 ++ apps/server/src/provider/Drivers/PiDriver.ts | 167 +++ .../Layers/PiAdapter.integration.test.ts | 547 +++++++ .../src/provider/Layers/PiAdapter.test.ts | 148 ++ apps/server/src/provider/Layers/PiAdapter.ts | 1278 +++++++++++++++++ .../src/provider/Layers/PiProvider.test.ts | 189 +++ apps/server/src/provider/Layers/PiProvider.ts | 235 +++ .../src/provider/Layers/PiRpcClient.test.ts | 561 ++++++++ .../server/src/provider/Layers/PiRpcClient.ts | 410 ++++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + .../server/src/provider/Services/PiAdapter.ts | 4 + .../provider/assets/pi/t3-approvals.test.ts | 77 + .../src/provider/assets/pi/t3-approvals.ts | 67 + apps/server/src/provider/builtInDrivers.ts | 5 +- .../textGeneration/PiTextGeneration.test.ts | 172 +++ .../src/textGeneration/PiTextGeneration.ts | 242 ++++ .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/AddProviderInstanceDialog.tsx | 7 +- .../settings/ProviderSettingsForm.test.ts | 8 + .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/session-logic.ts | 6 + docs/providers/pi.md | 109 ++ packages/contracts/src/model.test.ts | 26 + packages/contracts/src/model.ts | 5 + .../contracts/src/providerRuntime.test.ts | 60 + packages/contracts/src/providerRuntime.ts | 2 + packages/contracts/src/settings.ts | 32 + pnpm-lock.yaml | 779 +++++++++- pnpm-workspace.yaml | 2 + 32 files changed, 5250 insertions(+), 41 deletions(-) create mode 100644 apps/server/scripts/pi-mock-rpc.ts create mode 100644 apps/server/src/provider/Drivers/PiDriver.ts create mode 100644 apps/server/src/provider/Layers/PiAdapter.integration.test.ts create mode 100644 apps/server/src/provider/Layers/PiAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/PiAdapter.ts create mode 100644 apps/server/src/provider/Layers/PiProvider.test.ts create mode 100644 apps/server/src/provider/Layers/PiProvider.ts create mode 100644 apps/server/src/provider/Layers/PiRpcClient.test.ts create mode 100644 apps/server/src/provider/Layers/PiRpcClient.ts create mode 100644 apps/server/src/provider/Services/PiAdapter.ts create mode 100644 apps/server/src/provider/assets/pi/t3-approvals.test.ts create mode 100644 apps/server/src/provider/assets/pi/t3-approvals.ts create mode 100644 apps/server/src/textGeneration/PiTextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/PiTextGeneration.ts create mode 100644 docs/providers/pi.md create mode 100644 packages/contracts/src/model.test.ts diff --git a/README.md b/README.md index 0fbbe90ee66..135c99154f6 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 diff --git a/apps/server/package.json b/apps/server/package.json index d0903c77d75..3ef1213169b 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.2", "@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..81a3de169a3 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 { + yield* Effect.logWarning("[cli] Pi approval extension not found — skipping asset copy."); + } + 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..28e5d00e487 --- /dev/null +++ b/apps/server/scripts/pi-mock-rpc.ts @@ -0,0 +1,114 @@ +#!/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" }); + 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..677eb620e73 --- /dev/null +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -0,0 +1,167 @@ +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 { buildInitialPiProviderSnapshot, checkPiProviderStatus } from "../Layers/PiProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.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 + | ProviderEventLoggers + | 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 processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies PiSettings; + 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..f374eab4263 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts @@ -0,0 +1,547 @@ +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, + ProviderInstanceId, + 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" }, + }), + ); + 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" }); + + 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 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: "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(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("exposes the selected model in fresh-turn turn.started.payload", () => + Effect.gen(function* () { + const { adapter } = yield* makePiAdapterForTest(enabledSettings()); + const threadId = ThreadId.make("pi-int-turn-started-model"); + const store = yield* Ref.make>([]); + const fiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.started"), + Stream.runForEach((event) => Ref.update(store, (events) => [...events, event])), + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: PI, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("pi"), + model: "anthropic/claude-test", + }, + }); + yield* adapter.sendTurn({ threadId, input: "hello", attachments: [] }); + + const events = yield* Fiber.join(fiber).pipe(Effect.flatMap(() => Ref.get(store))); + const turnStarted = events.find((event) => event.type === "turn.started"); + expect(turnStarted).toBeDefined(); + if (turnStarted && turnStarted.type === "turn.started") { + expect(turnStarted.payload.model).toBe("anthropic/claude-test"); + } + + yield* adapter.stopSession(threadId); + }), + ); +}); 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..63f508a7879 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.test.ts @@ -0,0 +1,148 @@ +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("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: ["Alpha", "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..7825422ed42 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -0,0 +1,1278 @@ +/** `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, + makePiRpcTransport, + type MakePiRpcTransportOptions, + piForkSucceeded, + piImageContentFromBytes, + type PiImageContent, + piResponseHasCommand, + piResponseSucceeded, + planPiModelSwitch, + resolveForkTargetEntryId, + resolvePiThinkingLevel, + type PiRpcTransport, + type PiStdoutMessage, + type PiThinkingLevel, + type RpcExtensionUIRequest, + type RpcExtensionUIResponse, +} from "./PiRpcClient.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; + +// keep in sync with SENTINEL_COMMAND in t3-approvals.ts +const PI_APPROVAL_SENTINEL_COMMAND = "t3-approval-gate"; + +// 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; +} + +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; + 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" + 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("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("mcp")) return "mcp_tool_call"; + 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: string[] = []; + for (const line of lines.slice(1)) { + const match = /^\d+\.\s+(.+)$/.exec(line.trim()); + if (match?.[1]) items.push(match[1]); + } + 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 idx = numberedOptions.indexOf(label); + return idx >= 0 ? String(idx + 1) : null; + }) + .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; + const baseEnvironment = 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) — + // finalize only on the terminal end, since a retry isn't a user interrupt + if (event.willRetry) return; + 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; + context.pendingApprovals.set(requestId, { piId: request.id, requestType }); + 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, description: item })), + 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 [, pending] of context.pendingApprovals) { + yield* Effect.ignore( + context.transport.writeExtensionResponse({ + type: "extension_ui_response", + id: pending.piId, + confirmed: false, + }), + ); + } + context.pendingApprovals.clear(); + for (const [, pending] of context.pendingUserInputs) { + yield* Effect.ignore( + context.transport.writeExtensionResponse({ + type: "extension_ui_response", + id: pending.piId, + cancelled: true, + }), + ); + } + 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(), + 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); + if (sessionFile !== undefined) { + context.session = { ...context.session, resumeCursor: { sessionFile } }; + } + + // 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: { + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(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); + } + + if (!context.turnState) { + yield* openTurn(context); + } + + const turnId = context.turnState!.turnId; + + yield* context.transport + .writeCommand(buildPiTurnCommand({ isMidTurn, message: promptText, images })) + .pipe( + Effect.catchCause(() => completeTurn(context, "failed", "Failed to send message to Pi.")), + ); + + 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); + }, + ); + + 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); + + 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* 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, + ); + 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, + ...(sessionFile !== undefined ? { resumeCursor: { 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/PiProvider.test.ts b/apps/server/src/provider/Layers/PiProvider.test.ts new file mode 100644 index 00000000000..98c0e40467b --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.test.ts @@ -0,0 +1,189 @@ +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; anything else returns empty get_available_models +const HEALTHY_PI_SCRIPT = [ + "#!/bin/sh", + 'case "$1" in', + ' --version) printf "pi 0.80.2\\n"; exit 0 ;;', + ' *) printf \'{"type":"response","command":"get_available_models","id":"pi-model-discovery","success":true,"data":{"models":[]}}\\n\'; exit 0 ;;', + "esac", + "", +].join("\n"); + +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 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); + yield* fs.chmod(piPath, 0o755); + return yield* checkPiProviderStatus( + decodePiSettings({ enabled: true, binaryPath: piPath, customModels: ["x/y"] }), + dir, + ); + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + }), + ); + + it.effect( + "allows Pi to load extensions before reporting its version", + () => + 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-slow-version-" }); + const piPath = path.join(dir, "pi"); + yield* fs.writeFileString( + piPath, + [ + "#!/bin/sh", + 'case "$1" in', + ' --version) sleep 5; printf "pi 0.81.1\\n"; exit 0 ;;', + ' *) printf \'{"type":"response","command":"get_available_models","id":"pi-model-discovery","success":true,"data":{"models":[]}}\\n\'; exit 0 ;;', + "esac", + "", + ].join("\n"), + ); + yield* fs.chmod(piPath, 0o755); + return yield* checkPiProviderStatus( + decodePiSettings({ enabled: true, binaryPath: piPath, customModels: ["x/y"] }), + dir, + ); + }), + ); + + expect(snapshot.version).toBe("0.81.1"); + expect(snapshot.status).toBe("ready"); + }), + 10_000, + ); + + 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); + 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..927a1f01c18 --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -0,0 +1,235 @@ +import { + type ModelCapabilities, + type PiSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +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, + detailFromResult, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, +} from "../providerSnapshot.ts"; +import { + extractAvailableModels, + makePiRpcTransport, + piModelInfoToServerModel, +} from "./PiRpcClient.ts"; + +const PI_PRESENTATION = { + displayName: "Pi", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); + +// Outer hard wall on the whole discovery effect. `transport.request` has its own +// per-request timeout (see PI_MODEL_DISCOVERY_REQUEST_TIMEOUT_MS below); keep the +// outer wall strictly larger so the inner request owns cleanup/logging and the +// outer only fires as a defensive backstop. +const PI_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const PI_MODEL_DISCOVERY_REQUEST_TIMEOUT_MS = 14_000; +// Pi can spend several seconds loading extensions before printing its version. +// Keep this provider-specific probe above the shared 4-second CLI default. +const PI_VERSION_PROBE_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"; + const command = ChildProcess.make(binaryPath, ["--version"], { + env: environment, + shell: false, + }); + return spawnAndCollect(binaryPath, command); + }); + +/** Discover models via a short-lived `pi --mode rpc` session; `[]` on any failure. */ +export const discoverPiModelsViaRpc = Effect.fn("discoverPiModelsViaRpc")( + function* (piSettings: PiSettings, cwd: string, environment: NodeJS.ProcessEnv) { + const transport = yield* makePiRpcTransport({ + binaryPath: piSettings.binaryPath || "pi", + args: ["--mode", "rpc", "--no-session"], + cwd, + env: environment, + onExit: Effect.void, + }); + const response = yield* transport.request( + { type: "get_available_models" }, + "pi-model-discovery", + PI_MODEL_DISCOVERY_REQUEST_TIMEOUT_MS, + ); + return extractAvailableModels(response).map(piModelInfoToServerModel); + }, + Effect.scoped, + Effect.timeoutOption(PI_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.map(Option.getOrElse(() => [] as ReadonlyArray)), + Effect.catchCause((cause) => + Effect.logWarning("Pi model discovery failed", { cause }).pipe( + Effect.as([] as ReadonlyArray), + ), + ), +); + +const modelsFromSettings = ( + piSettings: PiSettings, + discovered: ReadonlyArray, +): ReadonlyArray => + providerModelsFromSettings(discovered, 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, []); + + 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, environment).pipe( + Effect.timeoutOption(PI_VERSION_PROBE_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.", + }, + }); + } + + const discovered = yield* discoverPiModelsViaRpc(piSettings, cwd, environment); + const models = modelsFromSettings(piSettings, discovered); + + // no auth query in pi; get_available_models only lists once a key is configured in ~/.pi/agent + const authenticated = models.length > 0; + + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models, + 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..66fbe446e11 --- /dev/null +++ b/apps/server/src/provider/Layers/PiRpcClient.test.ts @@ -0,0 +1,561 @@ +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, + parsePiStdoutLine, + 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([]); + }); +}); + +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("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..63846995aed --- /dev/null +++ b/apps/server/src/provider/Layers/PiRpcClient.ts @@ -0,0 +1,410 @@ +/** 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 { ModelSelection, ServerProviderModel } from "@t3tools/contracts"; +import type { ModelCapabilities } 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"; + +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 { + 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") { + 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" }, +] as const; + +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 }; +} + +export function piModelCapabilities(reasoning: boolean): ModelCapabilities { + return createModelCapabilities({ + optionDescriptors: reasoning + ? [ + buildSelectOptionDescriptor({ + id: "thinking", + label: "Thinking", + options: PI_THINKING_LEVELS.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(Boolean(model.reasoning)), + }; +} + +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; +} + +export function extractAvailableModels( + response: RpcResponse | undefined, +): ReadonlyArray { + const models = piResponseData(response)?.["models"]; + return Array.isArray(models) ? (models as ReadonlyArray) : []; +} + +// 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, + ); +} + +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 child = yield* spawner.spawn( + ChildProcess.make(options.binaryPath || "pi", [...options.args], { + cwd: options.cwd, + env: options.env, + shell: false, + 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(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 5efbb6f1c14..2219b956b65 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1764,6 +1764,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..5512003a3cf --- /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 { + const allowed = new Set(READ_ONLY_TOOLS); + if (approvalMode === "auto-accept-edits") { + for (const tool of EDIT_TOOLS) allowed.add(tool); + } + return allowed; +} + +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 PiAdapter.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..da4ea59223e --- /dev/null +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -0,0 +1,242 @@ +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 { extractLastAssistantText, makePiRpcTransport } from "../provider/Layers/PiRpcClient.ts"; +import { type TextGenerationShape } 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); + +export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + 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", + "--no-extensions", + "--thinking", + "off", + ...(input.modelSelection.model ? ["--model", input.modelSelection.model] : []), + ], + cwd: input.cwd, + env: environment, + onExit: Effect.void, + }); + yield* transport.writeCommand({ type: "prompt", message: input.message }); + yield* Stream.fromQueue(transport.messages).pipe( + Stream.takeUntil( + (message) => message._tag === "event" && message.event.type === "agent_end", + ), + 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.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Pi returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["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: TextGenerationShape["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: TextGenerationShape["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: TextGenerationShape["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 TextGenerationShape; +}); 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 378ce2e3e3c..a2b675af885 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -12,7 +12,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, @@ -91,11 +91,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 ea8712a87eb..c362ef27295 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -22,6 +22,14 @@ 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"]); + }); + 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..1f4469ccde2 --- /dev/null +++ b/docs/providers/pi.md @@ -0,0 +1,109 @@ +# 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. + +## 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 +``` + +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, and settings 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 +``` + +T3 Code uses this directory as-is, so the models and providers you see in T3 Code match +what the `pi` CLI shows. + +To point Pi at a different config directory, set `PI_CODING_AGENT_DIR` in the Pi provider's +Environment variables section in Settings. This is the Pi equivalent of a separate home, +and is useful if you want work and personal Pi setups. + +## 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, then appends any custom models you +configured. The result is exactly the model catalog your `~/.pi/agent` configuration +exposes. + +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. Tool approval is selected through T3 Code's session/runtime approval mode, not a +Pi provider setting — pick it from the runtime-mode control in the composer (for example +**Supervised** or **Auto-accept edits**) or in the project thread options. **Full access** +turns approval off. + +When the active session/runtime mode requires approval, T3 Code gates every tool call that +is not read-only. + +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 when the active mode requires approval. 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 — there is no silent fallback to read-only or any other mode. + +If you select **Full access** for the session, Pi runs tools without asking. Only do this +in environments where that is acceptable. + +## 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_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 8c74c13b89b..16b0a5a2958 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -146,6 +147,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ ]; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; +// 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", @@ -208,6 +210,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 ──────────────────────────────────────────── @@ -218,4 +222,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 57ab44a6b66..1c05445c0ce 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -389,6 +389,30 @@ 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" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +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(""))), @@ -433,6 +457,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 @@ -536,6 +561,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const PiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -558,6 +589,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/pnpm-lock.yaml b/pnpm-lock.yaml index a1e116d95bc..02f996adb20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,7 +104,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: @@ -168,7 +168,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: @@ -472,6 +472,9 @@ importers: specifier: ^1.1.0 version: 1.1.0 devDependencies: + '@earendil-works/pi-coding-agent': + specifier: ^0.80.2 + 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)) @@ -501,7 +504,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: @@ -658,7 +661,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: @@ -685,10 +688,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) @@ -713,7 +716,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: @@ -732,7 +735,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: @@ -751,7 +754,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: @@ -764,7 +767,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: @@ -786,7 +789,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: @@ -808,7 +811,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: @@ -842,7 +845,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: @@ -867,7 +870,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: @@ -889,7 +892,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: @@ -920,7 +923,7 @@ importers: version: 6.0.5 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: @@ -986,6 +989,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 @@ -1117,6 +1129,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'} @@ -1125,6 +1141,10 @@ packages: resolution: {integrity: sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.976.0': + resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==} + 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'} @@ -1165,6 +1185,18 @@ packages: resolution: {integrity: sha512-QS2UT3srjNppZv6mq7V0igqK/ThYKqRWwDscxDsMEmmEE5JqCPPSqFW71aEpkvXaMdgmG8xEpt4RtNHpZ30cTA==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.29': + resolution: {integrity: sha512-t3tKQRTVXsI2QNPE3CaNjHl0wRO9Xi3acZkAyti2RQsiFmZ9Gi0kArX2ighlRJ1BtDVuul413gThAgzyTfgmWA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.24': + resolution: {integrity: sha512-oykin4mDWxNOuYQ7SF1cHzgYeuFEkF4cdRwgvjFFbIklkx09qIFBiOgsORafG9sXZFO3TayMmQuAQYgADXhI8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.42': + resolution: {integrity: sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.997.16': resolution: {integrity: sha512-bGvfDgC2KQePjEmZdltScPPLKFoyjPElAXeZcLfvZ58J1AO283//WGtvp9GdnryLHTi7gis0UoCezqh0vl/nig==} engines: {node: '>=20.0.0'} @@ -1173,6 +1205,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'} @@ -1181,6 +1217,10 @@ packages: resolution: {integrity: sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + 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'} @@ -1189,10 +1229,18 @@ packages: resolution: {integrity: sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + 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'} @@ -1930,6 +1978,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: @@ -2699,6 +2765,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'} @@ -3089,6 +3164,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'} @@ -3237,6 +3388,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.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -3590,6 +3749,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==} @@ -4443,6 +4629,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==} @@ -4454,6 +4643,10 @@ packages: resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} engines: {node: '>=18.0.0'} + '@smithy/core@3.29.7': + resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==} + 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'} @@ -4462,6 +4655,10 @@ packages: resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.9': + resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -4470,6 +4667,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'} @@ -4482,10 +4683,18 @@ packages: resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.8': + resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==} + 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.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.4.6': resolution: {integrity: sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==} engines: {node: '>=18.0.0'} @@ -4924,6 +5133,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==} @@ -5610,6 +5822,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: @@ -5671,6 +5886,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==} @@ -6045,6 +6263,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'} @@ -6154,6 +6376,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==} @@ -6325,6 +6551,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==} @@ -6907,6 +7136,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==} @@ -6960,6 +7193,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'} @@ -7007,6 +7244,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==} @@ -7075,6 +7320,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'} @@ -7170,6 +7423,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==} @@ -7185,6 +7441,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==} @@ -7477,6 +7737,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==} @@ -7522,6 +7785,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==} @@ -7872,6 +8141,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==} @@ -8285,9 +8559,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'} @@ -8407,6 +8690,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'} @@ -8476,6 +8771,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'} @@ -8507,6 +8806,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} @@ -8765,6 +9067,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'} @@ -9195,6 +9501,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==} @@ -9280,6 +9590,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'} @@ -9736,6 +10051,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==} @@ -9771,6 +10089,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==} @@ -10181,6 +10503,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==} @@ -10457,6 +10783,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 @@ -10644,6 +10976,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.974.17 + '@aws-sdk/credential-provider-node': 3.972.51 + '@aws-sdk/eventstream-handler-node': 3.972.29 + '@aws-sdk/middleware-eventstream': 3.972.24 + '@aws-sdk/middleware-websocket': 3.972.42 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/client-cognito-identity@3.1062.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -10668,6 +11017,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.976.0': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.7 + '@smithy/signature-v4': 5.6.8 + '@smithy/types': 4.16.1 + 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 @@ -10780,6 +11140,30 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.29': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.24': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.42': + dependencies: + '@aws-sdk/core': 3.976.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.7 + '@smithy/fetch-http-handler': 5.6.9 + '@smithy/signature-v4': 5.6.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.16': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -10800,6 +11184,15 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.16 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1062.0': dependencies: '@aws-sdk/core': 3.974.17 @@ -10814,6 +11207,11 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 @@ -10824,8 +11222,15 @@ snapshots: fast-xml-parser: 5.7.3 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + 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 @@ -11673,6 +12078,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) @@ -12701,6 +13176,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 @@ -13126,6 +13614,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.43.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) @@ -13291,6 +13835,10 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oslojs/encoding@1.1.0': {} '@oxc-project/runtime@0.138.0': {} @@ -13524,6 +14072,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)': @@ -14480,6 +15048,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': {} @@ -14490,6 +15060,11 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@smithy/core@3.29.7': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.8': dependencies: '@smithy/core': 3.24.6 @@ -14502,6 +15077,12 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.9': + dependencies: + '@smithy/core': 3.29.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 @@ -14511,6 +15092,12 @@ snapshots: '@smithy/core': 3.24.6 tslib: 2.8.1 + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@smithy/node-http-handler@4.7.7': dependencies: '@smithy/core': 3.24.6 @@ -14528,10 +15115,20 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@smithy/signature-v4@5.6.8': + dependencies: + '@smithy/core': 3.29.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/types@4.14.3': dependencies: tslib: 2.8.1 + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-base64@4.4.6': dependencies: '@smithy/core': 3.24.6 @@ -14961,6 +15558,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/retry@0.12.0': {} + '@types/statuses@2.0.6': {} '@types/unist@2.0.11': {} @@ -15041,7 +15640,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 @@ -15057,7 +15656,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 @@ -15305,7 +15904,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 @@ -15348,7 +15947,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: @@ -15780,6 +16379,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 @@ -15854,6 +16455,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -16229,6 +16832,8 @@ snapshots: culori@4.0.2: {} + data-uri-to-buffer@4.0.1: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -16308,6 +16913,8 @@ snapshots: diff@8.0.3: {} + diff@8.0.4: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -16366,16 +16973,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: {} @@ -16390,6 +16999,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): @@ -17355,6 +17968,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: @@ -17432,6 +18050,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: {} @@ -17482,6 +18104,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 @@ -17565,6 +18203,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: @@ -17747,6 +18398,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 @@ -17761,6 +18414,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: {} @@ -18026,6 +18683,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: @@ -18070,6 +18731,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 @@ -18339,6 +19011,8 @@ snapshots: markdown-table@3.0.4: {} + marked@18.0.5: {} + marky@1.3.0: {} matcher@3.0.0: @@ -19057,8 +19731,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: @@ -19175,6 +19857,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 @@ -19201,7 +19888,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: @@ -19224,7 +19911,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: @@ -19235,7 +19922,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 @@ -19257,7 +19944,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: {} @@ -19282,6 +19969,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: {} @@ -19320,6 +20012,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + patch-console@2.0.0: {} path-browserify@1.0.1: {} @@ -19538,6 +20232,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 @@ -20230,6 +20938,8 @@ snapshots: retry@0.12.0: {} + retry@0.13.1: {} + rettime@0.10.1: {} reusify@1.1.0: {} @@ -20405,6 +21115,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.0: {} + semver@7.8.5: {} send@0.19.2: @@ -20884,6 +21596,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: @@ -20906,6 +21620,8 @@ snapshots: undici@8.3.0: {} + undici@8.5.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -21150,7 +21866,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 @@ -21164,11 +21880,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 @@ -21212,7 +21928,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)) @@ -21235,6 +21951,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: @@ -21351,6 +22068,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 22757702fc8..41f1657e725 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 From b0d460aed86a7521bfaae4eb9a6bc485f6c26470 Mon Sep 17 00:00:00 2001 From: Oussama Douhou <16113844+oussamadouhou@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:51:27 +0200 Subject: [PATCH 2/5] fix: harden Pi provider integration --- apps/server/scripts/pi-mock-rpc.ts | 4 + .../Layers/PiAdapter.integration.test.ts | 153 +++++++++++++++++- .../src/provider/Layers/PiAdapter.test.ts | 4 + apps/server/src/provider/Layers/PiAdapter.ts | 39 ++++- .../server/src/provider/Layers/PiRpcClient.ts | 37 +++-- .../textGeneration/PiTextGeneration.test.ts | 24 +++ .../src/textGeneration/PiTextGeneration.ts | 34 ++-- 7 files changed, 270 insertions(+), 25 deletions(-) diff --git a/apps/server/scripts/pi-mock-rpc.ts b/apps/server/scripts/pi-mock-rpc.ts index 28e5d00e487..452024df324 100644 --- a/apps/server/scripts/pi-mock-rpc.ts +++ b/apps/server/scripts/pi-mock-rpc.ts @@ -6,6 +6,7 @@ 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 exitOnPrompt = process.env["PI_MOCK_EXIT_ON_PROMPT"] === "1"; const replyText = emitInvalidJson ? "Sure — here is the answer, with no JSON at all." @@ -32,6 +33,9 @@ rl.on("line", (line: string) => { case "prompt": case "steer": case "follow_up": { + if (exitOnPrompt) { + process.exit(17); + } write({ type: "agent_start" }); write({ type: "turn_start" }); write({ diff --git a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts index f374eab4263..9ed00b41dc9 100644 --- a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts +++ b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -46,15 +47,17 @@ interface FakePiTransport { readonly pushEvent: (event: AgentSessionEvent) => Effect.Effect; readonly pushExtensionUI: (request: RpcExtensionUIRequest) => Effect.Effect; readonly setResponse: (commandType: string, response: RpcResponse) => void; + readonly failNextWrite: (defect?: Error) => void; } const asResponse = (value: unknown): RpcResponse => value as RpcResponse; const makeFakePiRpcTransport = Effect.gen(function* () { - const messages = yield* Queue.unbounded(); + const messages = yield* Queue.unbounded>(); const commands: Array = []; const extensionResponses: Array = []; const responses = new Map(); + let nextWriteDefect: Error | undefined; responses.set( "get_state", asResponse({ @@ -78,8 +81,15 @@ const makeFakePiRpcTransport = Effect.gen(function* () { const transport: PiRpcTransport = { writeCommand: (command) => - Effect.sync(() => { - commands.push(command); + Effect.suspend(() => { + if (nextWriteDefect !== undefined) { + const defect = nextWriteDefect; + nextWriteDefect = undefined; + return Effect.die(defect); + } + return Effect.sync(() => { + commands.push(command); + }); }), writeExtensionResponse: (response) => Effect.sync(() => { @@ -87,6 +97,7 @@ const makeFakePiRpcTransport = Effect.gen(function* () { }), request: (command) => Effect.succeed(responses.get((command as { type: string }).type)), messages, + isClosed: Effect.succeed(false), kill: Effect.void, }; @@ -100,6 +111,9 @@ const makeFakePiRpcTransport = Effect.gen(function* () { setResponse: (commandType, response) => { responses.set(commandType, response); }, + failNextWrite: (defect = new Error("Pi transport write failed.")) => { + nextWriteDefect = defect; + }, } satisfies FakePiTransport; }); @@ -188,6 +202,83 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { }), ); + it.effect("fails startup when the Pi process has already exited", () => + Effect.gen(function* () { + const fake = yield* makeFakePiRpcTransport; + const adapter = yield* makePiAdapter(enabledSettings(), { + makeTransport: () => + Effect.succeed({ + ...fake.transport, + isClosed: Effect.succeed(true), + }), + }); + const threadId = ThreadId.make("pi-int-startup-exit"); + + const result = yield* adapter + .startSession({ + threadId, + provider: PI, + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "ProviderAdapterProcessError", + threadId, + }); + expect(result.failure.message).toMatch(/exited during session startup/i); + } + expect(yield* adapter.hasSession(threadId)).toBe(false); + }), + ); + + it.effect("fails sendTurn when the prompt cannot be delivered", () => + Effect.gen(function* () { + const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); + const threadId = ThreadId.make("pi-int-write-failure"); + const collected = yield* collectEvents( + adapter, + threadId, + (event) => event.type === "turn.completed", + ); + yield* adapter.startSession({ + threadId, + provider: PI, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + fake.failNextWrite(); + + const result = yield* adapter + .sendTurn({ threadId, input: "never delivered", attachments: [] }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "ProviderAdapterRequestError", + method: "prompt", + }); + } + + const events = yield* Fiber.join(collected.fiber).pipe( + Effect.flatMap(() => Ref.get(collected.store)), + ); + const completed = events.find((event) => event.type === "turn.completed"); + expect(completed).toBeDefined(); + if (completed && completed.type === "turn.completed") { + expect(completed.payload.state).toBe("failed"); + } + + const sessions = yield* adapter.listSessions(); + expect(sessions[0]?.activeTurnId).toBeUndefined(); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("maps thinking_delta to a reasoning_text content delta", () => Effect.gen(function* () { const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); @@ -462,6 +553,62 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { }), ); + it.effect("clears a stale resume cursor when rollback cannot read the new session file", () => + Effect.gen(function* () { + const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); + const threadId = ThreadId.make("pi-int-rollback-cursor"); + const started = yield* adapter.startSession({ + threadId, + provider: PI, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + expect(started.resumeCursor).toEqual({ sessionFile: "/tmp/pi-session.json" }); + + fake.setResponse( + "get_fork_messages", + asResponse({ + type: "response", + id: "x", + command: "get_fork_messages", + success: true, + data: { + messages: [ + { entryId: "entry-1", text: "first" }, + { entryId: "entry-2", text: "second" }, + ], + }, + }), + ); + fake.setResponse( + "fork", + asResponse({ + type: "response", + id: "x", + command: "fork", + success: true, + data: { cancelled: false }, + }), + ); + fake.setResponse( + "get_state", + asResponse({ + type: "response", + id: "x", + command: "get_state", + success: true, + data: {}, + }), + ); + + yield* adapter.rollbackThread(threadId, 1); + + const sessions = yield* adapter.listSessions(); + expect(sessions[0]?.resumeCursor).toBeUndefined(); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("rejects startSession when the provider does not match", () => Effect.gen(function* () { const { adapter } = yield* makePiAdapterForTest(enabledSettings()); diff --git a/apps/server/src/provider/Layers/PiAdapter.test.ts b/apps/server/src/provider/Layers/PiAdapter.test.ts index 63f508a7879..fc761b1ae60 100644 --- a/apps/server/src/provider/Layers/PiAdapter.test.ts +++ b/apps/server/src/provider/Layers/PiAdapter.test.ts @@ -28,6 +28,8 @@ describe("classifyPiToolItemType", () => { 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("mcp__filesystem__read_file")).toBe("mcp_tool_call"); + expect(classifyPiToolItemType("mcp__search__image")).toBe("mcp_tool_call"); expect(classifyPiToolItemType("web_search")).toBe("web_search"); expect(classifyPiToolItemType("view_image")).toBe("image_view"); }); @@ -41,6 +43,8 @@ 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"); + expect(classifyPiApprovalRequestType("Run bash?")).toBe("command_execution_approval"); + expect(classifyPiApprovalRequestType("Run write?")).toBe("file_change_approval"); }); it("maps non-command/non-file tools to dynamic_tool_call (a surfaced approval)", () => { diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 7825422ed42..5f90dd2499b 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -155,17 +155,17 @@ export function classifyPiToolItemType(toolName: string): CanonicalItemType { const tokens = new Set( toolName .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[._/-]/g, " ") + .replace(/[^a-zA-Z0-9]+/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("mcp")) return "mcp_tool_call"; if (has("search", "web")) return "web_search"; if (has("image")) return "image_view"; return "dynamic_tool_call"; @@ -950,6 +950,14 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( `pi-get-state-${yield* nextUuid}`, PI_STATE_TIMEOUT_MS, ); + if (yield* transport.isClosed) { + yield* stopSessionInternal(context, { emitExitEvent: false }); + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "Pi RPC process exited during session startup.", + }); + } const sessionFile = extractSessionFile(stateResponse); if (sessionFile !== undefined) { context.session = { ...context.session, resumeCursor: { sessionFile } }; @@ -973,6 +981,15 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( } } + if (yield* transport.isClosed) { + yield* stopSessionInternal(context, { emitExitEvent: false }); + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "Pi RPC process exited during session startup.", + }); + } + const startedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ ...startedStamp, @@ -1059,7 +1076,20 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( yield* context.transport .writeCommand(buildPiTurnCommand({ isMidTurn, message: promptText, images })) .pipe( - Effect.catchCause(() => completeTurn(context, "failed", "Failed to send message to Pi.")), + Effect.catchCause((cause) => + completeTurn(context, "failed", "Failed to send message to Pi.").pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: isMidTurn ? "steer" : "prompt", + detail: "Failed to deliver the message to Pi.", + cause, + }), + ), + ), + ), + ), ); return { @@ -1211,8 +1241,9 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( ); const sessionFile = extractSessionFile(stateResponse); const updatedAt = yield* nowIso; + const { resumeCursor: _resumeCursor, ...sessionWithoutResumeCursor } = context.session; context.session = { - ...context.session, + ...sessionWithoutResumeCursor, status: "ready", updatedAt, ...(sessionFile !== undefined ? { resumeCursor: { sessionFile } } : {}), diff --git a/apps/server/src/provider/Layers/PiRpcClient.ts b/apps/server/src/provider/Layers/PiRpcClient.ts index 63846995aed..39f7af817aa 100644 --- a/apps/server/src/provider/Layers/PiRpcClient.ts +++ b/apps/server/src/provider/Layers/PiRpcClient.ts @@ -9,6 +9,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import type { ModelSelection, ServerProviderModel } from "@t3tools/contracts"; import type { ModelCapabilities } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -296,7 +297,8 @@ export interface PiRpcTransport { id: string, timeoutMs: number, ) => Effect.Effect; - readonly messages: Queue.Dequeue; + readonly messages: Queue.Dequeue>; + readonly isClosed: Effect.Effect; readonly kill: Effect.Effect; } @@ -321,14 +323,23 @@ export const makePiRpcTransport = (options: MakePiRpcTransportOptions) => }), ); - const outgoing = yield* Queue.unbounded(); - const messages = yield* Queue.unbounded(); + 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 offerLine = (obj: RpcCommand | RpcExtensionUIResponse): Effect.Effect => + Queue.offer(outgoing, Buffer.from(`${JSON.stringify(obj)}\n`)); + const writeLine = (obj: RpcCommand | RpcExtensionUIResponse): Effect.Effect => - Queue.offer(outgoing, Buffer.from(`${JSON.stringify(obj)}\n`)).pipe(Effect.asVoid); + offerLine(obj).pipe( + Effect.flatMap((offered) => + offered + ? Effect.void + : Effect.die(new Error("Pi RPC process exited before the command could be delivered.")), + ), + ); const handleLine = (line: string): Effect.Effect => Effect.gen(function* () { @@ -347,10 +358,13 @@ export const makePiRpcTransport = (options: MakePiRpcTransportOptions) => yield* Queue.offer(messages, message); }); - const onProcessExit = Deferred.succeed(closed, undefined).pipe( - Effect.andThen(Effect.sync(() => pendingRequests.clear())), - Effect.andThen(options.onExit), - ); + const onProcessExit = Effect.gen(function* () { + yield* Deferred.succeed(closed, undefined); + yield* Queue.end(outgoing); + yield* Queue.end(messages); + pendingRequests.clear(); + yield* options.onExit; + }); yield* Stream.fromQueue(outgoing).pipe( Stream.run(child.stdin), @@ -378,7 +392,11 @@ export const makePiRpcTransport = (options: MakePiRpcTransportOptions) => Effect.gen(function* () { const deferred = yield* Deferred.make(); pendingRequests.set(id, deferred); - yield* writeLine({ ...command, id }); + const offered = yield* offerLine({ ...command, id }); + if (!offered) { + pendingRequests.delete(id); + return undefined; + } // resolve on response, process exit, or timeout — whichever comes first const outcome = yield* Deferred.await(deferred).pipe( Effect.map((response) => Option.some(response)), @@ -396,6 +414,7 @@ export const makePiRpcTransport = (options: MakePiRpcTransportOptions) => writeExtensionResponse: (response) => writeLine(response), request, messages, + isClosed: Deferred.isDone(closed), kill, } satisfies PiRpcTransport; }); diff --git a/apps/server/src/textGeneration/PiTextGeneration.test.ts b/apps/server/src/textGeneration/PiTextGeneration.test.ts index f0b3230aafc..82926440faa 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.test.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.test.ts @@ -7,6 +7,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; @@ -170,3 +171,26 @@ it.effect("fails with a TextGenerationError when the assistant text is unavailab }), ).pipe(Effect.provide(PiTextGenerationTestLayer)), ); + +it.effect("fails quickly when Pi exits before agent_end", () => + withFakePi({ PI_MOCK_EXIT_ON_PROMPT: "1" }, (textGeneration) => + Effect.gen(function* () { + const completed = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "anything", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result, Effect.timeoutOption(3_000)); + + expect(Option.isSome(completed)).toBe(true); + if (Option.isSome(completed)) { + expect(Result.isFailure(completed.value)).toBe(true); + if (Result.isFailure(completed.value)) { + expect(completed.value.failure).toBeInstanceOf(TextGenerationError); + expect(completed.value.failure.message).toMatch(/process exited/i); + } + } + }), + ).pipe(Effect.provide(PiTextGenerationTestLayer)), +); diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts index da4ea59223e..6eaef03eb5f 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -71,6 +71,12 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ), Stream.runDrain, ); + if (yield* transport.isClosed) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "Pi RPC process exited before completing the request.", + }); + } const response = yield* transport.request( { type: "get_last_assistant_text" }, "pi-textgen-last-text", @@ -102,6 +108,15 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* cause, }), ), + Effect.catchDefect((cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "Pi RPC request failed.", + cause, + }), + ), + ), ); const runPiJson = Effect.fn("runPiJson")(function* ({ @@ -139,15 +154,16 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawResult)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Pi returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Pi returned invalid structured output.", + cause, + }), + ), + }), ); }); From a387b81a802899871c91c6a8d98007de277a64bf Mon Sep 17 00:00:00 2001 From: Oussama Douhou <16113844+oussamadouhou@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:15:22 +0200 Subject: [PATCH 3/5] fix: harden Pi provider edge cases --- apps/server/scripts/cli.ts | 12 +-- apps/server/scripts/cliBuildAssets.test.ts | 47 ++++++++ apps/server/scripts/cliBuildAssets.ts | 20 ++++ apps/server/scripts/pi-mock-rpc.ts | 58 +++++++--- .../Layers/PiAdapter.integration.test.ts | 101 ++++++++++++++++++ apps/server/src/provider/Layers/PiAdapter.ts | 13 ++- .../src/provider/Layers/PiRpcClient.test.ts | 9 +- .../server/src/provider/Layers/PiRpcClient.ts | 5 +- .../textGeneration/PiTextGeneration.test.ts | 65 ++++++++++- .../src/textGeneration/PiTextGeneration.ts | 73 ++++++++++++- 10 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 apps/server/scripts/cliBuildAssets.test.ts create mode 100644 apps/server/scripts/cliBuildAssets.ts diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 81a3de169a3..30786109228 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -19,6 +19,7 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import serverPackageJson from "../package.json" with { type: "json" }; +import { copyRequiredBuildAsset } from "./cliBuildAssets.ts"; import { ServerCliBuildAssetMissingError, ServerCliCommandExitError, @@ -182,14 +183,9 @@ const buildCmd = Command.make( // 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 { - yield* Effect.logWarning("[cli] Pi approval extension not found — skipping asset copy."); - } + const piExtensionTarget = path.join(serverDir, "dist/assets/pi/t3-approvals.ts"); + yield* copyRequiredBuildAsset(piExtensionSource, piExtensionTarget); + yield* Effect.log("[cli] Bundled Pi approval extension into dist/assets/pi"); if (yield* fs.exists(webDist)) { yield* fs.copy(webDist, clientTarget); diff --git a/apps/server/scripts/cliBuildAssets.test.ts b/apps/server/scripts/cliBuildAssets.test.ts new file mode 100644 index 00000000000..71e13cce802 --- /dev/null +++ b/apps/server/scripts/cliBuildAssets.test.ts @@ -0,0 +1,47 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { copyRequiredBuildAsset } from "./cliBuildAssets.ts"; + +it.layer(NodeServices.layer)("server CLI build assets", (it) => { + it.effect("fails when a required build asset is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cli-required-asset-", + }); + const sourcePath = path.join(tempDir, "missing.ts"); + const targetPath = path.join(tempDir, "dist", "required.ts"); + + const error = yield* copyRequiredBuildAsset(sourcePath, targetPath).pipe(Effect.flip); + + assert.equal(error._tag, "ServerCliBuildAssetMissingError"); + if (error._tag !== "ServerCliBuildAssetMissingError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.assetPath, sourcePath); + assert.equal(yield* fileSystem.exists(targetPath), false); + }), + ); + + it.effect("copies a required build asset into its target directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cli-required-asset-", + }); + const sourcePath = path.join(tempDir, "required.ts"); + const targetPath = path.join(tempDir, "dist", "assets", "required.ts"); + yield* fileSystem.writeFileString(sourcePath, "export const required = true;\n"); + + yield* copyRequiredBuildAsset(sourcePath, targetPath); + + assert.equal(yield* fileSystem.readFileString(targetPath), "export const required = true;\n"); + }), + ); +}); diff --git a/apps/server/scripts/cliBuildAssets.ts b/apps/server/scripts/cliBuildAssets.ts new file mode 100644 index 00000000000..98fc7fd67b8 --- /dev/null +++ b/apps/server/scripts/cliBuildAssets.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { ServerCliBuildAssetMissingError } from "./cliErrors.ts"; + +export const copyRequiredBuildAsset = Effect.fn("copyRequiredBuildAsset")(function* ( + sourcePath: string, + targetPath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + if (!(yield* fileSystem.exists(sourcePath))) { + return yield* new ServerCliBuildAssetMissingError({ assetPath: sourcePath }); + } + + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); + yield* fileSystem.copyFile(sourcePath, targetPath); +}); diff --git a/apps/server/scripts/pi-mock-rpc.ts b/apps/server/scripts/pi-mock-rpc.ts index 452024df324..9708a4db023 100644 --- a/apps/server/scripts/pi-mock-rpc.ts +++ b/apps/server/scripts/pi-mock-rpc.ts @@ -1,17 +1,22 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - Standalone JSONL process fixture. // Fake `pi --mode rpc` for tests; driven by `PI_MOCK_*` env vars. import * as NodeReadline from "node:readline"; +import * as NodeTimers from "node:timers"; 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 exitOnPrompt = process.env["PI_MOCK_EXIT_ON_PROMPT"] === "1"; +const retryOnce = process.env["PI_MOCK_RETRY_ONCE"] === "1"; +const requireImage = process.env["PI_MOCK_REQUIRE_IMAGE"] === "1"; +const expectedImageData = process.env["PI_MOCK_EXPECT_IMAGE_DATA"]; const replyText = emitInvalidJson ? "Sure — here is the answer, with no JSON at all." : assistantText; let lastAssistantText: string | null = null; +let didRetry = false; function write(obj: unknown): void { process.stdout.write(`${JSON.stringify(obj)}\n`); @@ -22,9 +27,9 @@ 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 }; + let command: { type?: string; id?: string; images?: unknown }; try { - command = JSON.parse(trimmed) as { type?: string; id?: string }; + command = JSON.parse(trimmed) as { type?: string; id?: string; images?: unknown }; } catch { return; } @@ -36,16 +41,43 @@ rl.on("line", (line: string) => { if (exitOnPrompt) { process.exit(17); } - 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" }); + + const hasExpectedImage = + Array.isArray(command.images) && + command.images.some( + (image) => + image !== null && + typeof image === "object" && + (image as Record)["type"] === "image" && + (image as Record)["mimeType"] === "image/png" && + (expectedImageData === undefined || + (image as Record)["data"] === expectedImageData), + ); + const responseText = + requireImage && !hasExpectedImage + ? "The required image payload was not received." + : replyText; + const emitSuccessfulAttempt = (): void => { + write({ type: "agent_start" }); + write({ type: "turn_start" }); + write({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: responseText }, + }); + lastAssistantText = responseText; + write({ type: "message_end" }); + write({ type: "turn_end" }); + write({ type: "agent_end", willRetry: false }); + }; + + if (retryOnce && !didRetry) { + didRetry = true; + write({ type: "agent_start" }); + write({ type: "agent_end", messages: [], willRetry: true }); + NodeTimers.setTimeout(emitSuccessfulAttempt, 100); + } else { + emitSuccessfulAttempt(); + } return; } case "get_last_assistant_text": { diff --git a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts index 9ed00b41dc9..628ec0b5b9c 100644 --- a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts +++ b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts @@ -401,6 +401,59 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { }), ); + it.effect("uses unknown stream deltas for non-command, non-file tools", () => + Effect.gen(function* () { + const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); + const threadId = ThreadId.make("pi-int-mcp-tool-update"); + 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: "use the MCP tool", attachments: [] }); + yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent); + yield* fake.pushEvent({ + type: "tool_execution_start", + toolCallId: "mcp-1", + toolName: "mcp__server__tool", + args: { query: "status" }, + } as AgentSessionEvent); + yield* fake.pushEvent({ + type: "tool_execution_update", + toolCallId: "mcp-1", + toolName: "mcp__server__tool", + partialResult: "working", + } as AgentSessionEvent); + yield* fake.pushEvent({ + type: "tool_execution_end", + toolCallId: "mcp-1", + toolName: "mcp__server__tool", + result: "done", + 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 delta = events.find( + (event) => event.type === "content.delta" && event.payload.delta === "working", + ); + expect(delta).toBeDefined(); + if (delta && delta.type === "content.delta") { + expect(delta.payload.streamKind).toBe("unknown"); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("bridges a confirm request to an approval round-trip", () => Effect.gen(function* () { const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); @@ -609,6 +662,54 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { }), ); + it.effect("preserves local turns when Pi rollback history cannot be read", () => + Effect.gen(function* () { + const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); + const threadId = ThreadId.make("pi-int-rollback-history-failure"); + 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: "keep this turn", attachments: [] }); + yield* fake.pushEvent({ type: "agent_end" } as AgentSessionEvent); + yield* Fiber.join(collected.fiber); + + const beforeRollback = yield* adapter.readThread(threadId); + expect(beforeRollback.turns).toHaveLength(1); + + fake.setResponse( + "get_fork_messages", + asResponse({ + type: "response", + id: "x", + command: "get_fork_messages", + success: false, + error: "history unavailable", + }), + ); + const result = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "ProviderAdapterRequestError", + method: "get_fork_messages", + }); + } + const afterRollback = yield* adapter.readThread(threadId); + expect(afterRollback.turns).toHaveLength(1); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("rejects startSession when the provider does not match", () => Effect.gen(function* () { const { adapter } = yield* makePiAdapterForTest(enabledSettings()); diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 5f90dd2499b..f36f90aa699 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -474,7 +474,11 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( type: "content.delta", payload: { streamKind: - itemType === "command_execution" ? "command_output" : "file_change_output", + itemType === "command_execution" + ? "command_output" + : itemType === "file_change" + ? "file_change_output" + : "unknown", delta, }, }); @@ -1203,6 +1207,13 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( PI_MESSAGES_TIMEOUT_MS, ); const userMessages = extractForkMessages(forkResponse); + if (userMessages === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "get_fork_messages", + detail: "Pi failed to return rollback history.", + }); + } const target = resolveForkTargetEntryId(userMessages, numTurns); if (target === null) { diff --git a/apps/server/src/provider/Layers/PiRpcClient.test.ts b/apps/server/src/provider/Layers/PiRpcClient.test.ts index 66fbe446e11..b8e8b59ec00 100644 --- a/apps/server/src/provider/Layers/PiRpcClient.test.ts +++ b/apps/server/src/provider/Layers/PiRpcClient.test.ts @@ -479,14 +479,15 @@ describe("extractForkMessages", () => { expect(result).toEqual([{ entryId: "e1", text: "" }]); }); - it("returns [] for undefined, failed, or non-array responses", () => { - expect(extractForkMessages(undefined)).toEqual([]); + it("distinguishes valid empty history from failed or malformed responses", () => { + expect(extractForkMessages(forkResponse([]))).toEqual([]); + expect(extractForkMessages(undefined)).toBeUndefined(); expect( extractForkMessages( asResponse({ type: "response", command: "get_fork_messages", success: false, error: "x" }), ), - ).toEqual([]); - expect(extractForkMessages(forkResponse("nope"))).toEqual([]); + ).toBeUndefined(); + expect(extractForkMessages(forkResponse("nope"))).toBeUndefined(); }); }); diff --git a/apps/server/src/provider/Layers/PiRpcClient.ts b/apps/server/src/provider/Layers/PiRpcClient.ts index 39f7af817aa..0e82b7129af 100644 --- a/apps/server/src/provider/Layers/PiRpcClient.ts +++ b/apps/server/src/provider/Layers/PiRpcClient.ts @@ -254,9 +254,10 @@ export function piResponseSucceeded(response: RpcResponse | undefined, 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 }> { +): ReadonlyArray<{ readonly entryId: string; readonly text: string }> | undefined { + if (!piResponseSucceeded(response, "get_fork_messages")) return undefined; const messages = piResponseData(response)?.["messages"]; - if (!Array.isArray(messages)) return []; + if (!Array.isArray(messages)) return undefined; return messages.flatMap((entry) => { if (!entry || typeof entry !== "object") return []; const record = entry as Record; diff --git a/apps/server/src/textGeneration/PiTextGeneration.test.ts b/apps/server/src/textGeneration/PiTextGeneration.test.ts index 82926440faa..a79362738e5 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.test.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.test.ts @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; @@ -41,9 +42,9 @@ async function makePiWrapper(): Promise { return wrapperPath; } -const withFakePi = ( +const withFakePi = ( mockEnv: Record, - use: (textGeneration: TextGenerationShape) => Effect.Effect, + use: (textGeneration: TextGenerationShape) => Effect.Effect, ) => Effect.gen(function* () { const wrapperPath = yield* Effect.promise(() => makePiWrapper()); @@ -68,6 +69,66 @@ it.effect("generateThreadTitle parses the JSON returned via get_last_assistant_t ).pipe(Effect.provide(PiTextGenerationTestLayer)), ); +it.effect("waits for the terminal agent_end when Pi retries", () => + withFakePi( + { + PI_MOCK_ASSISTANT_TEXT: '{"title":"Recovered after retry"}', + PI_MOCK_RETRY_ONCE: "1", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "retry this request", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Recovered after retry"); + }), + ).pipe(Effect.provide(PiTextGenerationTestLayer)), +); + +it.effect("sends persisted image bytes with Pi text-generation prompts", () => { + const imageBytes = Buffer.from("pi-image-bytes"); + return withFakePi( + { + PI_MOCK_ASSISTANT_TEXT: '{"title":"Screenshot regression"}', + PI_MOCK_REQUIRE_IMAGE: "1", + PI_MOCK_EXPECT_IMAGE_DATA: imageBytes.toString("base64"), + }, + (textGeneration) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const attachmentId = "pi-thread-image-attachment"; + const attachmentPath = NodePath.join(attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.writeFile(attachmentPath, imageBytes); + + const result = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this screenshot regression", + attachments: [ + { + type: "image", + id: attachmentId, + name: "regression.png", + mimeType: "image/png", + sizeBytes: imageBytes.byteLength, + }, + ], + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe( + Effect.ensuring( + fileSystem.remove(attachmentPath).pipe(Effect.catch(() => Effect.void)), + ), + ); + + expect(result.title).toBe("Screenshot regression"); + }), + ).pipe(Effect.provide(PiTextGenerationTestLayer)); +}); + it.effect("generateCommitMessage sanitizes subject and trims body", () => withFakePi( { PI_MOCK_ASSISTANT_TEXT: '{"subject":"Add reconnect handling.","body":"- detail\\n"}' }, diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts index 6eaef03eb5f..b26384f6c41 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -1,14 +1,27 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; 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 { + type ChatAttachment, + type ModelSelection, + type PiSettings, + TextGenerationError, +} from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { extractLastAssistantText, makePiRpcTransport } from "../provider/Layers/PiRpcClient.ts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as ServerConfig from "../config.ts"; +import { + extractLastAssistantText, + makePiRpcTransport, + piImageContentFromBytes, + type PiImageContent, +} from "../provider/Layers/PiRpcClient.ts"; import { type TextGenerationShape } from "./TextGeneration.ts"; import { buildBranchNamePrompt, @@ -40,12 +53,48 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* environment: NodeJS.ProcessEnv = process.env, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig.ServerConfig; + + const materializePiImages = Effect.fn("materializePiImages")(function* ( + operation: TextGenOperation, + attachments: ReadonlyArray | undefined, + ): Effect.fn.Return, TextGenerationError> { + const images: Array = []; + for (const attachment of attachments ?? []) { + if (attachment.type !== "image") continue; + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new TextGenerationError({ + operation, + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to read attachment '${attachment.id}'.`, + cause, + }), + ), + ); + images.push(piImageContentFromBytes({ mimeType: attachment.mimeType, bytes })); + } + return images; + }); const runPiPrompt = (input: { readonly message: string; readonly cwd: string; readonly modelSelection: ModelSelection; readonly operation: TextGenOperation; + readonly images?: ReadonlyArray; }): Effect.Effect => Effect.gen(function* () { const transport = yield* makePiRpcTransport({ @@ -64,10 +113,19 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* env: environment, onExit: Effect.void, }); - yield* transport.writeCommand({ type: "prompt", message: input.message }); + yield* transport.writeCommand({ + type: "prompt", + message: input.message, + ...(input.images !== undefined && input.images.length > 0 + ? { images: [...input.images] } + : {}), + }); yield* Stream.fromQueue(transport.messages).pipe( Stream.takeUntil( - (message) => message._tag === "event" && message.event.type === "agent_end", + (message) => + message._tag === "event" && + message.event.type === "agent_end" && + message.event.willRetry !== true, ), Stream.runDrain, ); @@ -125,12 +183,14 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* prompt, outputSchemaJson, modelSelection, + images, }: { operation: TextGenOperation; cwd: string; prompt: string; outputSchemaJson: S; modelSelection: ModelSelection; + images?: ReadonlyArray; }): Effect.fn.Return { const schemaJson = yield* encodeJsonString(toJsonSchemaObject(outputSchemaJson)).pipe( Effect.mapError( @@ -150,6 +210,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* cwd, modelSelection, operation, + ...(images !== undefined ? { images } : {}), }); const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); @@ -218,6 +279,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( "PiTextGeneration.generateBranchName", )(function* (input) { + const images = yield* materializePiImages("generateBranchName", input.attachments); const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, @@ -228,6 +290,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, + images, }); return { branch: sanitizeBranchFragment(generated.branch) }; }); @@ -235,6 +298,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( "PiTextGeneration.generateThreadTitle", )(function* (input) { + const images = yield* materializePiImages("generateThreadTitle", input.attachments); const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, attachments: input.attachments, @@ -245,6 +309,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, + images, }); return { title: sanitizeThreadTitle(generated.title) }; }); From 7260626155dfa27b1927befc5b004d0b344b938d Mon Sep 17 00:00:00 2001 From: Oussama Douhou <16113844+oussamadouhou@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:22:58 +0200 Subject: [PATCH 4/5] fix: harden Pi turn request handling --- .../Layers/PiAdapter.integration.test.ts | 61 ++++++++++++++++++- apps/server/src/provider/Layers/PiAdapter.ts | 13 ++-- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts index 628ec0b5b9c..ba72dcf9db5 100644 --- a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts +++ b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; @@ -92,10 +93,21 @@ const makeFakePiRpcTransport = Effect.gen(function* () { }); }), writeExtensionResponse: (response) => + Effect.suspend(() => { + if (nextWriteDefect !== undefined) { + const defect = nextWriteDefect; + nextWriteDefect = undefined; + return Effect.die(defect); + } + return Effect.sync(() => { + extensionResponses.push(response); + }); + }), + request: (command) => Effect.sync(() => { - extensionResponses.push(response); + commands.push(command); + return responses.get((command as { type: string }).type); }), - request: (command) => Effect.succeed(responses.get((command as { type: string }).type)), messages, isClosed: Effect.succeed(false), kill: Effect.void, @@ -496,6 +508,13 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { } as RpcExtensionUIRequest); const requestId = yield* Deferred.await(opened); + fake.failNextWrite(); + const firstAttempt = yield* adapter + .respondToRequest(threadId, requestId, "accept") + .pipe(Effect.exit); + expect(Exit.isFailure(firstAttempt)).toBe(true); + expect(fake.extensionResponses).toHaveLength(0); + yield* adapter.respondToRequest(threadId, requestId, "accept"); yield* Deferred.await(resolved); yield* Fiber.interrupt(fiber); @@ -562,6 +581,15 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { if (requested && requested.type === "user-input.requested") { const questionId = requested.payload.questions[0]?.id; expect(questionId).toBeDefined(); + fake.failNextWrite(); + const firstAttempt = yield* adapter + .respondToUserInput(threadId, requestId, { + [String(questionId)]: "Option A", + }) + .pipe(Effect.exit); + expect(Exit.isFailure(firstAttempt)).toBe(true); + expect(fake.extensionResponses).toHaveLength(0); + yield* adapter.respondToUserInput(threadId, requestId, { [String(questionId)]: "Option A", }); @@ -792,4 +820,33 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect("ignores thinking options selected for another provider instance", () => + Effect.gen(function* () { + const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); + const threadId = ThreadId.make("pi-int-foreign-thinking"); + yield* adapter.startSession({ + threadId, + provider: PI, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "openai/gpt-5", + options: [{ id: "thinking", value: "high" }], + }, + }); + + expect(fake.commands.some((command) => command.type === "set_model")).toBe(false); + expect(fake.commands.some((command) => command.type === "set_thinking_level")).toBe(false); + + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index f36f90aa699..d45448fd456 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -1048,8 +1048,9 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( 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 modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedModel = modelSelection?.model; 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); @@ -1068,7 +1069,7 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( // 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); + yield* applyThinkingLevel(context, modelSelection); } if (!context.turnState) { @@ -1125,10 +1126,9 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( detail: `Unknown pending approval request: ${requestId}.`, }); } - context.pendingApprovals.delete(requestId); - const response: RpcExtensionUIResponse = buildPiApprovalResponse(pending.piId, decision); yield* context.transport.writeExtensionResponse(response); + context.pendingApprovals.delete(requestId); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ @@ -1155,11 +1155,10 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( detail: `Unknown pending user-input request: ${requestId}.`, }); } - context.pendingUserInputs.delete(requestId); - const response: RpcExtensionUIResponse = buildPiUserInputResponse(pending, answers); yield* context.transport.writeExtensionResponse(response); + context.pendingUserInputs.delete(requestId); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ From 0bc5db3722c070bd15698da51633657e5bbdf40b Mon Sep 17 00:00:00 2001 From: Oussama Douhou <16113844+oussamadouhou@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:34:35 +0200 Subject: [PATCH 5/5] fix: serialize concurrent Pi sends --- .../Layers/PiAdapter.integration.test.ts | 91 ++++++++++++++- apps/server/src/provider/Layers/PiAdapter.ts | 106 ++++++++++-------- 2 files changed, 146 insertions(+), 51 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts index ba72dcf9db5..4747152da4a 100644 --- a/apps/server/src/provider/Layers/PiAdapter.integration.test.ts +++ b/apps/server/src/provider/Layers/PiAdapter.integration.test.ts @@ -28,6 +28,7 @@ import type { AgentSessionEvent, PiRpcTransport, PiStdoutMessage, + PiTurnCommand, RpcCommand, RpcExtensionUIRequest, RpcExtensionUIResponse, @@ -48,6 +49,11 @@ interface FakePiTransport { readonly pushEvent: (event: AgentSessionEvent) => Effect.Effect; readonly pushExtensionUI: (request: RpcExtensionUIRequest) => Effect.Effect; readonly setResponse: (commandType: string, response: RpcResponse) => void; + readonly gateRequests: ( + commandType: string, + entered: Deferred.Deferred, + release: Deferred.Deferred, + ) => void; readonly failNextWrite: (defect?: Error) => void; } @@ -58,6 +64,10 @@ const makeFakePiRpcTransport = Effect.gen(function* () { const commands: Array = []; const extensionResponses: Array = []; const responses = new Map(); + const requestGates = new Map< + string, + { readonly entered: Deferred.Deferred; readonly release: Deferred.Deferred } + >(); let nextWriteDefect: Error | undefined; responses.set( "get_state", @@ -104,9 +114,15 @@ const makeFakePiRpcTransport = Effect.gen(function* () { }); }), request: (command) => - Effect.sync(() => { + Effect.gen(function* () { + const commandType = (command as { type: string }).type; commands.push(command); - return responses.get((command as { type: string }).type); + const gate = requestGates.get(commandType); + if (gate !== undefined) { + yield* Deferred.succeed(gate.entered, undefined).pipe(Effect.ignore); + yield* Deferred.await(gate.release); + } + return responses.get(commandType); }), messages, isClosed: Effect.succeed(false), @@ -123,6 +139,9 @@ const makeFakePiRpcTransport = Effect.gen(function* () { setResponse: (commandType, response) => { responses.set(commandType, response); }, + gateRequests: (commandType, entered, release) => { + requestGates.set(commandType, { entered, release }); + }, failNextWrite: (defect = new Error("Pi transport write failed.")) => { nextWriteDefect = defect; }, @@ -786,6 +805,74 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => { }), ); + it.effect("serializes concurrent sends into one prompt followed by a steer", () => + Effect.gen(function* () { + const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings()); + const threadId = ThreadId.make("pi-int-concurrent-send"); + yield* adapter.startSession({ + threadId, + provider: PI, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + fake.setResponse( + "set_model", + asResponse({ + type: "response", + id: "x", + command: "set_model", + success: true, + data: {}, + }), + ); + const enteredSetModel = yield* Deferred.make(); + const releaseSetModel = yield* Deferred.make(); + fake.gateRequests("set_model", enteredSetModel, releaseSetModel); + const modelSelection = { + instanceId: ProviderInstanceId.make("pi"), + model: "anthropic/claude-test", + }; + + const sendsFiber = yield* Effect.all( + [ + adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection, + }), + adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection, + }), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + + yield* Deferred.await(enteredSetModel); + yield* Effect.yieldNow; + const modelRequestsBeforeRelease = fake.commands.filter( + (command) => command.type === "set_model", + ).length; + yield* Deferred.succeed(releaseSetModel, undefined).pipe(Effect.ignore); + const results = yield* Fiber.join(sendsFiber); + + const turnCommands = fake.commands.filter( + (command): command is PiTurnCommand => + command.type === "prompt" || command.type === "steer", + ); + expect(modelRequestsBeforeRelease).toBe(1); + expect(results[1].turnId).toBe(results[0].turnId); + expect(turnCommands.map((command) => command.type)).toEqual(["prompt", "steer"]); + expect(turnCommands.map((command) => command.message).sort()).toEqual(["first", "second"]); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("exposes the selected model in fresh-turn turn.started.payload", () => Effect.gen(function* () { const { adapter } = yield* makePiAdapterForTest(enabledSettings()); diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index d45448fd456..77b3ca627cc 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -29,6 +29,7 @@ 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 Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import type * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -138,6 +139,7 @@ interface PiSessionContext { notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; + readonly sendTurnSemaphore: Semaphore.Semaphore; turnState: PiTurnState | undefined; readonly turns: Array<{ id: TurnId; items: Array }>; stopped: boolean; @@ -923,6 +925,7 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( createdAt: startedAt, updatedAt: startedAt, }; + const sendTurnSemaphore = yield* Semaphore.make(1); const context: PiSessionContext = { session, @@ -931,6 +934,7 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( notificationFiber: undefined, pendingApprovals: new Map(), pendingUserInputs: new Map(), + sendTurnSemaphore, turnState: undefined, turns: [], stopped: false, @@ -1048,62 +1052,66 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( const sendTurn: PiAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* requireSession(input.threadId); - const modelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const requestedModel = modelSelection?.model; - 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; + return yield* context.sendTurnSemaphore.withPermit( + Effect.gen(function* () { + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedModel = modelSelection?.model; + 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.", + }); + } - // only on a fresh turn — changing options mid-stream would race the active turn - if (!isMidTurn) { - yield* maybeSwitchPiModel(context, requestedModel); - yield* applyThinkingLevel(context, modelSelection); - } + // a message mid-turn steers the running turn; otherwise it opens a fresh one + const isMidTurn = context.turnState !== undefined; - if (!context.turnState) { - yield* openTurn(context); - } + // only on a fresh turn — changing options mid-stream would race the active turn + if (!isMidTurn) { + yield* maybeSwitchPiModel(context, requestedModel); + yield* applyThinkingLevel(context, modelSelection); + } - const turnId = context.turnState!.turnId; + if (!context.turnState) { + 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: isMidTurn ? "steer" : "prompt", - detail: "Failed to deliver the message to Pi.", - cause, - }), + const turnId = context.turnState!.turnId; + + 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: isMidTurn ? "steer" : "prompt", + detail: "Failed to deliver the message to Pi.", + cause, + }), + ), + ), ), ), - ), - ), - ); + ); - return { - threadId: context.session.threadId, - turnId, - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - }; + return { + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }), + ); }); const interruptTurn: PiAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(